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:
parent
149f2f861e
commit
2a7d67db58
|
|
@ -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 }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -10,8 +10,40 @@ export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body: WorksheetFormState = await request.json()
|
const body: WorksheetFormState = await request.json()
|
||||||
|
|
||||||
// Generate preview using shared logic
|
// Parse pagination parameters from query string
|
||||||
const result = generateWorksheetPreview(body)
|
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) {
|
if (!result.success) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|
@ -23,8 +55,20 @@ export async function POST(request: NextRequest) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return pages as JSON
|
// Return pages with metadata
|
||||||
return NextResponse.json({ pages: result.pages })
|
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) {
|
} catch (error) {
|
||||||
console.error('Error generating preview:', error)
|
console.error('Error generating preview:', error)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { css } from '@styled/css'
|
import { css } from '@styled/css'
|
||||||
import { useTheme } from '@/contexts/ThemeContext'
|
import { useTheme } from '@/contexts/ThemeContext'
|
||||||
|
import NumberFlow from '@number-flow/react'
|
||||||
|
|
||||||
interface FloatingPageIndicatorProps {
|
interface FloatingPageIndicatorProps {
|
||||||
currentPage: number
|
currentPage: number
|
||||||
|
|
@ -82,9 +83,35 @@ export function FloatingPageIndicator({
|
||||||
color: isDark ? 'gray.100' : 'gray.900',
|
color: isDark ? 'gray.100' : 'gray.900',
|
||||||
minW: '20',
|
minW: '20',
|
||||||
textAlign: 'center',
|
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>
|
</span>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -389,13 +389,14 @@ export function OrientationPanel({
|
||||||
className={css({
|
className={css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
gap: '1',
|
gap: '1',
|
||||||
|
flexWrap: 'wrap',
|
||||||
'@media (max-width: 444px)': {
|
'@media (max-width: 444px)': {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
gap: '0.5',
|
gap: '0.5',
|
||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{[1, 2, 3, 4].map((pageCount) => {
|
{[1, 2, 3, 4, 10, 25, 50, 100].map((pageCount) => {
|
||||||
const isSelected = pages === pageCount
|
const isSelected = pages === pageCount
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
@ -404,8 +405,9 @@ export function OrientationPanel({
|
||||||
data-action={`select-pages-${pageCount}`}
|
data-action={`select-pages-${pageCount}`}
|
||||||
onClick={() => onPagesChange(pageCount)}
|
onClick={() => onPagesChange(pageCount)}
|
||||||
className={css({
|
className={css({
|
||||||
w: '8',
|
minW: '8',
|
||||||
h: '8',
|
h: '8',
|
||||||
|
px: '2',
|
||||||
border: '2px solid',
|
border: '2px solid',
|
||||||
borderColor: isSelected ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
|
borderColor: isSelected ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
|
||||||
bg: isSelected
|
bg: isSelected
|
||||||
|
|
@ -435,12 +437,12 @@ export function OrientationPanel({
|
||||||
borderColor: 'brand.400',
|
borderColor: 'brand.400',
|
||||||
},
|
},
|
||||||
'@media (max-width: 444px)': {
|
'@media (max-width: 444px)': {
|
||||||
w: '6',
|
minW: '6',
|
||||||
h: '6',
|
h: '6',
|
||||||
fontSize: '2xs',
|
fontSize: '2xs',
|
||||||
},
|
},
|
||||||
'@media (max-width: 300px)': {
|
'@media (max-width: 300px)': {
|
||||||
w: '5',
|
minW: '5',
|
||||||
h: '5',
|
h: '5',
|
||||||
fontSize: '2xs',
|
fontSize: '2xs',
|
||||||
borderWidth: '1px',
|
borderWidth: '1px',
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Set current date for preview
|
||||||
const configWithDate = {
|
const configWithDate = {
|
||||||
...formState,
|
...formState,
|
||||||
|
|
@ -32,7 +50,25 @@ async function fetchWorksheetPreview(formState: WorksheetFormState): Promise<str
|
||||||
|
|
||||||
// Use absolute URL for SSR compatibility
|
// Use absolute URL for SSR compatibility
|
||||||
const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'
|
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, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -49,7 +85,7 @@ async function fetchWorksheetPreview(formState: WorksheetFormState): Promise<str
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
return data.pages
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
function PreviewContent({ formState, initialData, isScrolling = false }: WorksheetPreviewProps) {
|
function PreviewContent({ formState, initialData, isScrolling = false }: WorksheetPreviewProps) {
|
||||||
|
|
@ -57,19 +93,8 @@ function PreviewContent({ formState, initialData, isScrolling = false }: Workshe
|
||||||
const isDark = resolvedTheme === 'dark'
|
const isDark = resolvedTheme === 'dark'
|
||||||
const pageRefs = useRef<(HTMLDivElement | null)[]>([])
|
const pageRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||||
|
|
||||||
// Track if we've used the initial data (so we only use it once)
|
// Common query key for all page-related queries
|
||||||
const initialDataUsed = useRef(false)
|
const baseQueryKey = [
|
||||||
|
|
||||||
// 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',
|
'worksheet-preview',
|
||||||
// PRIMARY state
|
// PRIMARY state
|
||||||
formState.problemsPerPage,
|
formState.problemsPerPage,
|
||||||
|
|
@ -95,55 +120,152 @@ function PreviewContent({ formState, initialData, isScrolling = false }: Workshe
|
||||||
formState.pAnyStart,
|
formState.pAnyStart,
|
||||||
formState.pAllStart,
|
formState.pAllStart,
|
||||||
formState.interpolate,
|
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
|
formState.seed, // Include seed to bust cache when problem set regenerates
|
||||||
// Note: fontSize, date, rows, total intentionally excluded
|
] as const
|
||||||
// (rows and total are derived from primary state)
|
|
||||||
],
|
// Fetch initial batch to get total page count and first few pages
|
||||||
queryFn: () => fetchWorksheetPreview(formState),
|
const INITIAL_PAGES = 3
|
||||||
initialData: queryInitialData, // Only use on first render
|
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
|
// Virtualization decision based on page count, not config source
|
||||||
// Always virtualize multi-page worksheets for performance
|
// Always virtualize multi-page worksheets for performance
|
||||||
const shouldVirtualize = totalPages > 1
|
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]))
|
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)
|
const [currentPage, setCurrentPage] = useState(0)
|
||||||
|
|
||||||
// Track when refs are fully populated
|
// Track when refs are fully populated
|
||||||
const [refsReady, setRefsReady] = useState(false)
|
const [refsReady, setRefsReady] = useState(false)
|
||||||
|
|
||||||
// Debug: Log visible pages changes
|
// Reset state when form config changes
|
||||||
useEffect(() => {
|
|
||||||
console.log('[VIRTUALIZATION] visiblePages changed: [' + Array.from(visiblePages).sort().join(', ') + ']')
|
|
||||||
}, [visiblePages])
|
|
||||||
|
|
||||||
// Reset to first page when preview updates
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentPage(0)
|
setCurrentPage(0)
|
||||||
setVisiblePages(new Set([0]))
|
setVisiblePages(new Set([0]))
|
||||||
|
setFetchingPages(new Set())
|
||||||
pageRefs.current = []
|
pageRefs.current = []
|
||||||
setRefsReady(false)
|
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
|
// Check if all refs are populated after each render
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (totalPages > 1 && pageRefs.current.length === totalPages) {
|
if (totalPages > 1 && pageRefs.current.length === totalPages) {
|
||||||
const allPopulated = pageRefs.current.every((ref) => ref !== null)
|
const allPopulated = pageRefs.current.every((ref) => ref !== null)
|
||||||
if (allPopulated && !refsReady) {
|
if (allPopulated && !refsReady) {
|
||||||
console.log('[VIRTUALIZATION] All refs ready, setting up observer')
|
|
||||||
setRefsReady(true)
|
setRefsReady(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -160,8 +282,6 @@ function PreviewContent({ formState, initialData, isScrolling = false }: Workshe
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[VIRTUALIZATION] Setting up IntersectionObserver - shouldVirtualize: ' + shouldVirtualize)
|
|
||||||
|
|
||||||
const observer = new IntersectionObserver(
|
const observer = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
// Find the most visible page among all 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) {
|
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)
|
// 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'))
|
const pageIndex = Number(entry.target.getAttribute('data-page-index'))
|
||||||
|
|
||||||
if (entry.isIntersecting) {
|
if (entry.isIntersecting) {
|
||||||
console.log('[VIRTUALIZATION] Page ' + pageIndex + ' is intersecting - adding to visible set')
|
|
||||||
// Add visible page
|
// Add visible page
|
||||||
next.add(pageIndex)
|
next.add(pageIndex)
|
||||||
// Preload adjacent pages for smooth scrolling
|
// Preload adjacent pages for smooth scrolling
|
||||||
if (pageIndex > 0) next.add(pageIndex - 1)
|
if (pageIndex > 0) next.add(pageIndex - 1)
|
||||||
if (pageIndex < totalPages - 1) 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)
|
// Keep any pages from prev that weren't in entries (not observed in this callback)
|
||||||
prev.forEach((pageIndex) => {
|
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) {
|
if (!wasObserved) {
|
||||||
next.add(pageIndex)
|
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
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -275,28 +403,30 @@ function PreviewContent({ formState, initialData, isScrolling = false }: Workshe
|
||||||
p: '4',
|
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)
|
const isVisible = visiblePages.has(index)
|
||||||
if (index === 0) {
|
const page = loadedPages.get(index)
|
||||||
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(', ') + ']')
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
ref={(el) => (pageRefs.current[index] = el)}
|
ref={(el) => {
|
||||||
|
pageRefs.current[index] = el
|
||||||
|
}}
|
||||||
data-page-index={index}
|
data-page-index={index}
|
||||||
data-element="page-container"
|
data-element="page-container"
|
||||||
data-page-rendered={isVisible ? 'svg' : 'placeholder'}
|
data-page-loaded={isLoaded ? 'true' : 'false'}
|
||||||
|
data-page-fetching={isFetching ? 'true' : 'false'}
|
||||||
className={css({
|
className={css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
minHeight: '800px', // Prevent layout shift
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{isVisible ? (
|
{isLoaded && page ? (
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
'& svg': {
|
'& svg': {
|
||||||
|
|
@ -307,6 +437,37 @@ function PreviewContent({ formState, initialData, isScrolling = false }: Workshe
|
||||||
})}
|
})}
|
||||||
dangerouslySetInnerHTML={{ __html: page }}
|
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} />
|
<PagePlaceholder pageNumber={index + 1} />
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -15,14 +15,9 @@ import { validateWorksheetConfig } from './validation'
|
||||||
export interface PreviewResult {
|
export interface PreviewResult {
|
||||||
success: boolean
|
success: boolean
|
||||||
pages?: string[]
|
pages?: string[]
|
||||||
error?: string
|
|
||||||
details?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SinglePageResult {
|
|
||||||
success: boolean
|
|
||||||
page?: string
|
|
||||||
totalPages?: number
|
totalPages?: number
|
||||||
|
startPage?: number
|
||||||
|
endPage?: number
|
||||||
error?: string
|
error?: string
|
||||||
details?: string
|
details?: string
|
||||||
}
|
}
|
||||||
|
|
@ -30,8 +25,15 @@ export interface SinglePageResult {
|
||||||
/**
|
/**
|
||||||
* Generate worksheet preview SVG pages
|
* Generate worksheet preview SVG pages
|
||||||
* Can be called from API routes or Server Components
|
* 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 {
|
try {
|
||||||
// Validate configuration
|
// Validate configuration
|
||||||
const validation = validateWorksheetConfig(config)
|
const validation = validateWorksheetConfig(config)
|
||||||
|
|
@ -124,10 +126,23 @@ export function generateWorksheetPreview(config: WorksheetFormState): PreviewRes
|
||||||
|
|
||||||
// Generate Typst sources (one per page)
|
// Generate Typst sources (one per page)
|
||||||
const typstSources = generateTypstSource(validatedConfig, problems)
|
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[] = []
|
const pages: string[] = []
|
||||||
for (let i = 0; i < typstSources.length; i++) {
|
for (let i = start; i <= end; i++) {
|
||||||
const typstSource = typstSources[i]
|
const typstSource = typstSources[i]
|
||||||
|
|
||||||
// Compile to SVG via stdin/stdout
|
// Compile to SVG via stdin/stdout
|
||||||
|
|
@ -139,7 +154,7 @@ export function generateWorksheetPreview(config: WorksheetFormState): PreviewRes
|
||||||
})
|
})
|
||||||
pages.push(svgOutput)
|
pages.push(svgOutput)
|
||||||
} catch (error) {
|
} 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
|
// Extract the actual Typst error message
|
||||||
const stderr =
|
const stderr =
|
||||||
|
|
@ -149,7 +164,7 @@ export function generateWorksheetPreview(config: WorksheetFormState): PreviewRes
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
error: `Failed to compile preview (page ${i + 1})`,
|
error: `Failed to compile preview (page ${i})`,
|
||||||
details: stderr,
|
details: stderr,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -158,6 +173,9 @@ export function generateWorksheetPreview(config: WorksheetFormState): PreviewRes
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
pages,
|
pages,
|
||||||
|
totalPages,
|
||||||
|
startPage: start,
|
||||||
|
endPage: end,
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error generating preview:', error)
|
console.error('Error generating preview:', error)
|
||||||
|
|
@ -176,7 +194,10 @@ export function generateWorksheetPreview(config: WorksheetFormState): PreviewRes
|
||||||
* Generate a single worksheet page SVG
|
* Generate a single worksheet page SVG
|
||||||
* Much faster than generating all pages when you only need one
|
* 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 {
|
try {
|
||||||
// First, validate and get total page count
|
// First, validate and get total page count
|
||||||
const validation = validateWorksheetConfig(config)
|
const validation = validateWorksheetConfig(config)
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,8 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
|
||||||
|
|
||||||
// Validate total (must be positive, reasonable limit)
|
// Validate total (must be positive, reasonable limit)
|
||||||
const total = formState.total ?? 20
|
const total = formState.total ?? 20
|
||||||
if (total < 1 || total > 100) {
|
if (total < 1 || total > 2000) {
|
||||||
errors.push('Total problems must be between 1 and 100')
|
errors.push('Total problems must be between 1 and 2000')
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate cols and auto-calculate rows
|
// Validate cols and auto-calculate rows
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue