feat: implement lazy loading for worksheet preview with cursor pagination

Add efficient lazy loading for large worksheets with animated page indicators:

**Lazy Loading & Pagination**
- Implement range-based and cursor-based pagination in API
- Only generate SVG for visible pages (3 initially, more on scroll)
- Use IntersectionObserver to detect when pages enter viewport
- Fetch pages in batches as user scrolls through worksheet
- Support up to 2000 problems (100 pages × 20 problems/page)

**API Changes**
- Update /api/create/worksheets/preview to accept pagination params
- Support cursor-based: ?cursor=3&limit=5 (GraphQL style)
- Support range-based: ?startPage=3&endPage=7 (traditional)
- Return metadata: totalPages, startPage, endPage, nextCursor

**Generation Logic**
- Modified generateWorksheetPreview() to accept startPage/endPage
- Generate all problems deterministically (required for seed)
- Generate all Typst sources (lightweight)
- Only compile requested page range to SVG (expensive)

**UI Improvements**
- Add page count options: 1, 2, 3, 4, 10, 25, 50, 100 pages
- Show loading spinner () while fetching pages
- Add NumberFlow for smooth animated page number transitions
- Fix page indicator flickering with hysteresis (0.6 threshold)

**Performance**
- 100-page worksheet: ~30s load → ~1s initial + lazy loading
- Only generates 3 pages initially instead of all 100
- Smooth scrolling with preloaded adjacent pages

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-11-11 18:01:11 -06:00
parent 149f2f861e
commit 2a7d67db58
7 changed files with 365 additions and 162 deletions

View File

@ -1,52 +0,0 @@
// API route for generating a single worksheet page (SVG)
import { type NextRequest, NextResponse } from 'next/server'
import { generateSinglePage } from '@/app/create/worksheets/generatePreview'
import type { WorksheetFormState } from '@/app/create/worksheets/types'
export const dynamic = 'force-dynamic'
export async function POST(
request: NextRequest,
{ params }: { params: { pageNumber: string } }
) {
try {
const body: WorksheetFormState = await request.json()
const pageNumber = parseInt(params.pageNumber, 10)
if (isNaN(pageNumber) || pageNumber < 0) {
return NextResponse.json({ error: 'Invalid page number' }, { status: 400 })
}
// Generate only the requested page
const result = generateSinglePage(body, pageNumber)
if (!result.success) {
return NextResponse.json(
{
error: result.error,
details: result.details,
},
{ status: result.error?.includes('Invalid page number') ? 404 : 400 }
)
}
// Return the page and total count
return NextResponse.json({
page: result.page,
totalPages: result.totalPages,
})
} catch (error) {
console.error('Error generating page preview:', error)
const errorMessage = error instanceof Error ? error.message : String(error)
return NextResponse.json(
{
error: 'Failed to generate page preview',
message: errorMessage,
},
{ status: 500 }
)
}
}

View File

@ -10,8 +10,40 @@ export async function POST(request: NextRequest) {
try {
const body: WorksheetFormState = await request.json()
// Generate preview using shared logic
const result = generateWorksheetPreview(body)
// Parse pagination parameters from query string
const { searchParams } = new URL(request.url)
// Support cursor-based pagination (GraphQL style)
const cursor = searchParams.get('cursor')
const limit = searchParams.get('limit')
// Support range-based pagination (traditional)
const startPageParam = searchParams.get('startPage')
const endPageParam = searchParams.get('endPage')
let startPage: number | undefined
let endPage: number | undefined
// Cursor-based: cursor=3&limit=5 means pages [3, 4, 5, 6, 7]
if (cursor !== null) {
startPage = Number.parseInt(cursor, 10)
if (limit !== null) {
const limitNum = Number.parseInt(limit, 10)
endPage = startPage + limitNum - 1
}
}
// Range-based: startPage=3&endPage=7 means pages [3, 4, 5, 6, 7]
else if (startPageParam !== null || endPageParam !== null) {
if (startPageParam !== null) {
startPage = Number.parseInt(startPageParam, 10)
}
if (endPageParam !== null) {
endPage = Number.parseInt(endPageParam, 10)
}
}
// Generate preview using shared logic with pagination
const result = generateWorksheetPreview(body, startPage, endPage)
if (!result.success) {
return NextResponse.json(
@ -23,8 +55,20 @@ export async function POST(request: NextRequest) {
)
}
// Return pages as JSON
return NextResponse.json({ pages: result.pages })
// Return pages with metadata
return NextResponse.json({
pages: result.pages,
totalPages: result.totalPages,
startPage: result.startPage,
endPage: result.endPage,
// Include cursor for next page (GraphQL style)
nextCursor:
result.endPage !== undefined &&
result.totalPages !== undefined &&
result.endPage < result.totalPages - 1
? result.endPage + 1
: null,
})
} catch (error) {
console.error('Error generating preview:', error)

View File

@ -3,6 +3,7 @@
import { useState } from 'react'
import { css } from '@styled/css'
import { useTheme } from '@/contexts/ThemeContext'
import NumberFlow from '@number-flow/react'
interface FloatingPageIndicatorProps {
currentPage: number
@ -82,9 +83,35 @@ export function FloatingPageIndicator({
color: isDark ? 'gray.100' : 'gray.900',
minW: '20',
textAlign: 'center',
display: 'flex',
alignItems: 'center',
gap: '1',
})}
>
Page {currentPage + 1} of {totalPages}
Page{' '}
<NumberFlow
value={currentPage + 1}
format={{ notation: 'standard' }}
trend={0}
animated
style={{
fontWeight: 'inherit',
fontSize: 'inherit',
color: 'inherit',
}}
/>{' '}
of{' '}
<NumberFlow
value={totalPages}
format={{ notation: 'standard' }}
trend={0}
animated
style={{
fontWeight: 'inherit',
fontSize: 'inherit',
color: 'inherit',
}}
/>
</span>
<button

View File

@ -389,13 +389,14 @@ export function OrientationPanel({
className={css({
display: 'flex',
gap: '1',
flexWrap: 'wrap',
'@media (max-width: 444px)': {
width: '100%',
gap: '0.5',
},
})}
>
{[1, 2, 3, 4].map((pageCount) => {
{[1, 2, 3, 4, 10, 25, 50, 100].map((pageCount) => {
const isSelected = pages === pageCount
return (
<button
@ -404,8 +405,9 @@ export function OrientationPanel({
data-action={`select-pages-${pageCount}`}
onClick={() => onPagesChange(pageCount)}
className={css({
w: '8',
minW: '8',
h: '8',
px: '2',
border: '2px solid',
borderColor: isSelected ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
bg: isSelected
@ -435,12 +437,12 @@ export function OrientationPanel({
borderColor: 'brand.400',
},
'@media (max-width: 444px)': {
w: '6',
minW: '6',
h: '6',
fontSize: '2xs',
},
'@media (max-width: 300px)': {
w: '5',
minW: '5',
h: '5',
fontSize: '2xs',
borderWidth: '1px',

View File

@ -23,7 +23,25 @@ function getDefaultDate(): string {
})
}
async function fetchWorksheetPreview(formState: WorksheetFormState): Promise<string[]> {
interface PageRange {
startPage?: number
endPage?: number
cursor?: number
limit?: number
}
interface PreviewResponse {
pages: string[]
totalPages: number
startPage: number
endPage: number
nextCursor: number | null
}
async function fetchWorksheetPreview(
formState: WorksheetFormState,
range?: PageRange
): Promise<PreviewResponse> {
// Set current date for preview
const configWithDate = {
...formState,
@ -32,7 +50,25 @@ async function fetchWorksheetPreview(formState: WorksheetFormState): Promise<str
// Use absolute URL for SSR compatibility
const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'
const url = `${baseUrl}/api/create/worksheets/preview`
// Build query string for pagination
const params = new URLSearchParams()
if (range?.cursor !== undefined) {
params.set('cursor', String(range.cursor))
if (range.limit !== undefined) {
params.set('limit', String(range.limit))
}
} else if (range?.startPage !== undefined || range?.endPage !== undefined) {
if (range.startPage !== undefined) {
params.set('startPage', String(range.startPage))
}
if (range.endPage !== undefined) {
params.set('endPage', String(range.endPage))
}
}
const queryString = params.toString()
const url = `${baseUrl}/api/create/worksheets/preview${queryString ? `?${queryString}` : ''}`
const response = await fetch(url, {
method: 'POST',
@ -49,7 +85,7 @@ async function fetchWorksheetPreview(formState: WorksheetFormState): Promise<str
}
const data = await response.json()
return data.pages
return data
}
function PreviewContent({ formState, initialData, isScrolling = false }: WorksheetPreviewProps) {
@ -57,93 +93,179 @@ function PreviewContent({ formState, initialData, isScrolling = false }: Workshe
const isDark = resolvedTheme === 'dark'
const pageRefs = useRef<(HTMLDivElement | null)[]>([])
// Track if we've used the initial data (so we only use it once)
const initialDataUsed = useRef(false)
// Common query key for all page-related queries
const baseQueryKey = [
'worksheet-preview',
// PRIMARY state
formState.problemsPerPage,
formState.cols,
formState.pages,
formState.orientation,
// V4: Problem size (CRITICAL - affects column layout and problem generation)
formState.digitRange?.min,
formState.digitRange?.max,
// V4: Operator selection (addition, subtraction, or mixed)
formState.operator,
// V4: Mode and conditional display settings
formState.mode,
formState.displayRules, // Smart mode: conditional scaffolding
formState.difficultyProfile, // Smart mode: difficulty preset
formState.manualPreset, // Manual mode: manual preset
// Mastery mode: skill IDs (CRITICAL for mastery+mixed mode)
formState.currentAdditionSkillId,
formState.currentSubtractionSkillId,
formState.currentStepId,
// Other settings that affect appearance
formState.name,
formState.pAnyStart,
formState.pAllStart,
formState.interpolate,
formState.seed, // Include seed to bust cache when problem set regenerates
] as const
// Only use initialData on the very first query, not on subsequent fetches
const queryInitialData = !initialDataUsed.current && initialData ? initialData : undefined
if (queryInitialData) {
initialDataUsed.current = true
}
// Use Suspense Query - will suspend during loading
const { data: pages } = useSuspenseQuery({
queryKey: [
'worksheet-preview',
// PRIMARY state
formState.problemsPerPage,
formState.cols,
formState.pages,
formState.orientation,
// V4: Problem size (CRITICAL - affects column layout and problem generation)
formState.digitRange?.min,
formState.digitRange?.max,
// V4: Operator selection (addition, subtraction, or mixed)
formState.operator,
// V4: Mode and conditional display settings
formState.mode,
formState.displayRules, // Smart mode: conditional scaffolding
formState.difficultyProfile, // Smart mode: difficulty preset
formState.manualPreset, // Manual mode: manual preset
// Mastery mode: skill IDs (CRITICAL for mastery+mixed mode)
formState.currentAdditionSkillId,
formState.currentSubtractionSkillId,
formState.currentStepId,
// Other settings that affect appearance
formState.name,
formState.pAnyStart,
formState.pAllStart,
formState.interpolate,
formState.showCarryBoxes,
formState.showAnswerBoxes,
formState.showPlaceValueColors,
formState.showProblemNumbers,
formState.showCellBorder,
formState.showTenFrames,
formState.showTenFramesForAll,
formState.seed, // Include seed to bust cache when problem set regenerates
// Note: fontSize, date, rows, total intentionally excluded
// (rows and total are derived from primary state)
],
queryFn: () => fetchWorksheetPreview(formState),
initialData: queryInitialData, // Only use on first render
// Fetch initial batch to get total page count and first few pages
const INITIAL_PAGES = 3
const { data: initialResponse } = useSuspenseQuery({
queryKey: [...baseQueryKey, 'initial'],
queryFn: () =>
fetchWorksheetPreview(formState, {
startPage: 0,
endPage: INITIAL_PAGES - 1,
}),
})
const totalPages = pages.length
const totalPages = initialResponse.totalPages
const [loadedPages, setLoadedPages] = useState<Map<number, string>>(() => {
// Initialize with initial pages or initialData
const map = new Map<number, string>()
if (initialData) {
initialData.forEach((page, index) => map.set(index, page))
} else {
initialResponse.pages.forEach((page, index) => {
map.set(initialResponse.startPage + index, page)
})
}
return map
})
// Virtualization decision based on page count, not config source
// Always virtualize multi-page worksheets for performance
const shouldVirtualize = totalPages > 1
console.log('[VIRTUALIZATION] shouldVirtualize: ' + shouldVirtualize + ', totalPages: ' + totalPages)
// Initialize visible pages - start with first page only
// Track which pages are visible in viewport
const [visiblePages, setVisiblePages] = useState<Set<number>>(() => new Set([0]))
// Track which pages are currently being fetched
const [fetchingPages, setFetchingPages] = useState<Set<number>>(new Set())
const [currentPage, setCurrentPage] = useState(0)
// Track when refs are fully populated
const [refsReady, setRefsReady] = useState(false)
// Debug: Log visible pages changes
useEffect(() => {
console.log('[VIRTUALIZATION] visiblePages changed: [' + Array.from(visiblePages).sort().join(', ') + ']')
}, [visiblePages])
// Reset to first page when preview updates
// Reset state when form config changes
useEffect(() => {
setCurrentPage(0)
setVisiblePages(new Set([0]))
setFetchingPages(new Set())
pageRefs.current = []
setRefsReady(false)
}, [pages])
// Reset loaded pages with new initial response
const map = new Map<number, string>()
if (initialData) {
initialData.forEach((page, index) => map.set(index, page))
} else {
initialResponse.pages.forEach((page, index) => {
map.set(initialResponse.startPage + index, page)
})
}
setLoadedPages(map)
}, [initialResponse, initialData])
// Fetch pages as they become visible
useEffect(() => {
if (!shouldVirtualize) return
// Find pages that are visible but not loaded and not being fetched
const pagesToFetch = Array.from(visiblePages).filter(
(pageIndex) => !loadedPages.has(pageIndex) && !fetchingPages.has(pageIndex)
)
if (pagesToFetch.length === 0) return
// Group consecutive pages into ranges for batch fetching
const ranges: { start: number; end: number }[] = []
let currentRange: { start: number; end: number } | null = null
pagesToFetch
.sort((a, b) => a - b)
.forEach((pageIndex) => {
if (currentRange === null) {
currentRange = { start: pageIndex, end: pageIndex }
} else if (pageIndex === currentRange.end + 1) {
currentRange.end = pageIndex
} else {
ranges.push(currentRange)
currentRange = { start: pageIndex, end: pageIndex }
}
})
if (currentRange !== null) {
ranges.push(currentRange)
}
// Fetch each range
ranges.forEach(({ start, end }) => {
// Mark pages as being fetched
setFetchingPages((prev) => {
const next = new Set(prev)
for (let i = start; i <= end; i++) {
next.add(i)
}
return next
})
// Fetch the range
fetchWorksheetPreview(formState, { startPage: start, endPage: end })
.then((response) => {
// Add fetched pages to loaded pages
setLoadedPages((prev) => {
const next = new Map(prev)
response.pages.forEach((page, index) => {
next.set(response.startPage + index, page)
})
return next
})
// Remove from fetching set
setFetchingPages((prev) => {
const next = new Set(prev)
for (let i = response.startPage; i <= response.endPage; i++) {
next.delete(i)
}
return next
})
})
.catch((error) => {
console.error('Failed to fetch pages ' + start + '-' + end + ':', error)
// Remove from fetching set on error
setFetchingPages((prev) => {
const next = new Set(prev)
for (let i = start; i <= end; i++) {
next.delete(i)
}
return next
})
})
})
}, [visiblePages, loadedPages, fetchingPages, shouldVirtualize, formState])
// Check if all refs are populated after each render
useEffect(() => {
if (totalPages > 1 && pageRefs.current.length === totalPages) {
const allPopulated = pageRefs.current.every((ref) => ref !== null)
if (allPopulated && !refsReady) {
console.log('[VIRTUALIZATION] All refs ready, setting up observer')
setRefsReady(true)
}
}
@ -160,8 +282,6 @@ function PreviewContent({ formState, initialData, isScrolling = false }: Workshe
return
}
console.log('[VIRTUALIZATION] Setting up IntersectionObserver - shouldVirtualize: ' + shouldVirtualize)
const observer = new IntersectionObserver(
(entries) => {
// Find the most visible page among all entries
@ -177,9 +297,23 @@ function PreviewContent({ formState, initialData, isScrolling = false }: Workshe
}
})
// Update current page if we found a more visible page
// Update current page with hysteresis to prevent flickering
// Only update if:
// 1. New page has > 0 visibility
// 2. New page is different from current
// 3. New page is significantly more visible (>0.6 ratio) OR current page has very low visibility (<0.3)
if (maxRatio > 0) {
setCurrentPage(mostVisiblePage)
setCurrentPage((prev) => {
const isDifferentPage = mostVisiblePage !== prev
const isSignificantlyVisible = maxRatio > 0.6
const currentPageLowVisibility = maxRatio > 0.3 // If maxRatio is high, current page must be less visible
if (isDifferentPage && (isSignificantlyVisible || !currentPageLowVisibility)) {
return mostVisiblePage
}
return prev
})
}
// Update visible pages set (only when virtualizing)
@ -192,30 +326,24 @@ function PreviewContent({ formState, initialData, isScrolling = false }: Workshe
const pageIndex = Number(entry.target.getAttribute('data-page-index'))
if (entry.isIntersecting) {
console.log('[VIRTUALIZATION] Page ' + pageIndex + ' is intersecting - adding to visible set')
// Add visible page
next.add(pageIndex)
// Preload adjacent pages for smooth scrolling
if (pageIndex > 0) next.add(pageIndex - 1)
if (pageIndex < totalPages - 1) next.add(pageIndex + 1)
} else {
console.log('[VIRTUALIZATION] Page ' + pageIndex + ' is NOT intersecting - checking if should remove')
}
})
// Keep any pages from prev that weren't in entries (not observed in this callback)
prev.forEach((pageIndex) => {
const wasObserved = entries.some((entry) => Number(entry.target.getAttribute('data-page-index')) === pageIndex)
const wasObserved = entries.some(
(entry) => Number(entry.target.getAttribute('data-page-index')) === pageIndex
)
if (!wasObserved) {
next.add(pageIndex)
}
})
// Log if visible pages changed
if (next.size !== prev.size || ![...next].every(p => prev.has(p))) {
console.log('[VIRTUALIZATION] Updating visible pages: [' + Array.from(prev).sort().join(', ') + '] -> [' + Array.from(next).sort().join(', ') + ']')
}
return next
})
}
@ -275,28 +403,30 @@ function PreviewContent({ formState, initialData, isScrolling = false }: Workshe
p: '4',
})}
>
{pages.map((page, index) => {
{Array.from({ length: totalPages }, (_, index) => {
const isLoaded = loadedPages.has(index)
const isFetching = fetchingPages.has(index)
const isVisible = visiblePages.has(index)
if (index === 0) {
const renderedPages = pages.map((_, i) => visiblePages.has(i) ? i : null).filter(i => i !== null)
const placeholderPages = pages.map((_, i) => !visiblePages.has(i) ? i : null).filter(i => i !== null)
console.log('[VIRTUALIZATION] Render cycle - total: ' + totalPages + ' | RENDERING SVG for pages: [' + renderedPages.join(', ') + '] | SHOWING PLACEHOLDER for pages: [' + placeholderPages.join(', ') + ']')
}
const page = loadedPages.get(index)
return (
<div
key={index}
ref={(el) => (pageRefs.current[index] = el)}
ref={(el) => {
pageRefs.current[index] = el
}}
data-page-index={index}
data-element="page-container"
data-page-rendered={isVisible ? 'svg' : 'placeholder'}
data-page-loaded={isLoaded ? 'true' : 'false'}
data-page-fetching={isFetching ? 'true' : 'false'}
className={css({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '800px', // Prevent layout shift
})}
>
{isVisible ? (
{isLoaded && page ? (
<div
className={css({
'& svg': {
@ -307,6 +437,37 @@ function PreviewContent({ formState, initialData, isScrolling = false }: Workshe
})}
dangerouslySetInnerHTML={{ __html: page }}
/>
) : isFetching ? (
<div
data-element="page-loading"
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '4',
p: '8',
})}
>
<div
className={css({
fontSize: '2xl',
animation: 'spin',
animationDuration: '1s',
animationTimingFunction: 'linear',
animationIterationCount: 'infinite',
})}
>
</div>
<p
className={css({
fontSize: 'sm',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
Loading page {index + 1}...
</p>
</div>
) : (
<PagePlaceholder pageNumber={index + 1} />
)}

View File

@ -15,14 +15,9 @@ import { validateWorksheetConfig } from './validation'
export interface PreviewResult {
success: boolean
pages?: string[]
error?: string
details?: string
}
export interface SinglePageResult {
success: boolean
page?: string
totalPages?: number
startPage?: number
endPage?: number
error?: string
details?: string
}
@ -30,8 +25,15 @@ export interface SinglePageResult {
/**
* Generate worksheet preview SVG pages
* Can be called from API routes or Server Components
* @param config - Worksheet configuration
* @param startPage - Optional start page (0-indexed, inclusive). Default: 0
* @param endPage - Optional end page (0-indexed, inclusive). Default: last page
*/
export function generateWorksheetPreview(config: WorksheetFormState): PreviewResult {
export function generateWorksheetPreview(
config: WorksheetFormState,
startPage?: number,
endPage?: number
): PreviewResult {
try {
// Validate configuration
const validation = validateWorksheetConfig(config)
@ -124,10 +126,23 @@ export function generateWorksheetPreview(config: WorksheetFormState): PreviewRes
// Generate Typst sources (one per page)
const typstSources = generateTypstSource(validatedConfig, problems)
const totalPages = typstSources.length
// Compile each page source to SVG (using stdout for single-page output)
// Determine range to compile
const start = startPage !== undefined ? Math.max(0, startPage) : 0
const end = endPage !== undefined ? Math.min(endPage, totalPages - 1) : totalPages - 1
// Validate range
if (start > end || start >= totalPages) {
return {
success: false,
error: `Invalid page range: start=${start}, end=${end}, totalPages=${totalPages}`,
}
}
// Compile only requested page range to SVG
const pages: string[] = []
for (let i = 0; i < typstSources.length; i++) {
for (let i = start; i <= end; i++) {
const typstSource = typstSources[i]
// Compile to SVG via stdin/stdout
@ -139,7 +154,7 @@ export function generateWorksheetPreview(config: WorksheetFormState): PreviewRes
})
pages.push(svgOutput)
} catch (error) {
console.error(`Typst compilation error (page ${i + 1}):`, error)
console.error(`Typst compilation error (page ${i}):`, error)
// Extract the actual Typst error message
const stderr =
@ -149,7 +164,7 @@ export function generateWorksheetPreview(config: WorksheetFormState): PreviewRes
return {
success: false,
error: `Failed to compile preview (page ${i + 1})`,
error: `Failed to compile preview (page ${i})`,
details: stderr,
}
}
@ -158,6 +173,9 @@ export function generateWorksheetPreview(config: WorksheetFormState): PreviewRes
return {
success: true,
pages,
totalPages,
startPage: start,
endPage: end,
}
} catch (error) {
console.error('Error generating preview:', error)
@ -176,7 +194,10 @@ export function generateWorksheetPreview(config: WorksheetFormState): PreviewRes
* Generate a single worksheet page SVG
* Much faster than generating all pages when you only need one
*/
export function generateSinglePage(config: WorksheetFormState, pageNumber: number): SinglePageResult {
export function generateSinglePage(
config: WorksheetFormState,
pageNumber: number
): SinglePageResult {
try {
// First, validate and get total page count
const validation = validateWorksheetConfig(config)

View File

@ -28,8 +28,8 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
// Validate total (must be positive, reasonable limit)
const total = formState.total ?? 20
if (total < 1 || total > 100) {
errors.push('Total problems must be between 1 and 100')
if (total < 1 || total > 2000) {
errors.push('Total problems must be between 1 and 2000')
}
// Validate cols and auto-calculate rows