From f97efb5c94ca00bf7b9fbaa48d65981f2d1766a1 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Fri, 5 Dec 2025 09:17:49 -0600 Subject: [PATCH] feat(worksheets): add shuffle button with animated dice icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a shuffle button to the worksheet preview floating action bar that generates a new random seed for problem generation: - 1/3 split button design: [Download] [🎲] [▼ dropdown] - Animated dice that rolls and changes faces (2-6) during regeneration - Final dice face derived from seed, never lands on same number twice - Excludes face 1 to ensure the icon is clearly recognizable as a dice Also includes attempted fix for operator layering in Typst templates (changed operator box width to 0.5em). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../worksheets/components/PreviewCenter.tsx | 197 +++++++++++++++++- .../src/app/create/worksheets/typstHelpers.ts | 2 +- .../typstHelpers/subtraction/subtrahendRow.ts | 2 +- 3 files changed, 196 insertions(+), 5 deletions(-) diff --git a/apps/web/src/app/create/worksheets/components/PreviewCenter.tsx b/apps/web/src/app/create/worksheets/components/PreviewCenter.tsx index da334959..2b7534d8 100644 --- a/apps/web/src/app/create/worksheets/components/PreviewCenter.tsx +++ b/apps/web/src/app/create/worksheets/components/PreviewCenter.tsx @@ -3,17 +3,107 @@ import * as DropdownMenu from '@radix-ui/react-dropdown-menu' import { css } from '@styled/css' import { useRouter } from 'next/navigation' -import { useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import type { WorksheetFormState } from '@/app/create/worksheets/types' import { UploadWorksheetModal } from '@/components/worksheets/UploadWorksheetModal' import { useTheme } from '@/contexts/ThemeContext' import { extractConfigFields } from '../utils/extractConfigFields' -import { ShareModal } from './ShareModal' -import { WorksheetPreview } from './WorksheetPreview' import { FloatingPageIndicator } from './FloatingPageIndicator' +import { ShareModal } from './ShareModal' +import { useWorksheetConfig } from './WorksheetConfigContext' +import { WorksheetPreview } from './WorksheetPreview' import { DuplicateWarningBanner } from './worksheet-preview/DuplicateWarningBanner' import { WorksheetPreviewProvider } from './worksheet-preview/WorksheetPreviewContext' +// Dice face configurations: positions of dots for faces 1-6 +const DICE_FACES = [ + // Face 1: center dot + [[12, 12]], + // Face 2: diagonal dots + [ + [8, 8], + [16, 16], + ], + // Face 3: diagonal line + [ + [8, 8], + [12, 12], + [16, 16], + ], + // Face 4: four corners + [ + [8, 8], + [16, 8], + [8, 16], + [16, 16], + ], + // Face 5: four corners + center + [ + [8, 8], + [16, 8], + [12, 12], + [8, 16], + [16, 16], + ], + // Face 6: two columns of three + [ + [8, 6], + [8, 12], + [8, 18], + [16, 6], + [16, 12], + [16, 18], + ], +] + +/** + * Animated dice icon that shows rolling dice with changing faces + * @param isRolling - When true, shows a rolling animation with changing faces + * @param currentFace - The current face to display (1-6), used during rolling + */ +function DiceIcon({ + className, + isRolling, + currentFace = 5, +}: { + className?: string + isRolling?: boolean + currentFace?: number +}) { + const dots = DICE_FACES[(currentFace - 1) % 6] + + return ( + + + {dots.map(([cx, cy], i) => ( + + ))} + + ) +} + interface PreviewCenterProps { formState: WorksheetFormState initialPreview?: string[] @@ -35,6 +125,7 @@ export function PreviewCenter({ }: PreviewCenterProps) { const router = useRouter() const { resolvedTheme } = useTheme() + const { onChange } = useWorksheetConfig() const isDark = resolvedTheme === 'dark' const scrollContainerRef = useRef(null) const [isScrolling, setIsScrolling] = useState(false) @@ -43,6 +134,11 @@ export function PreviewCenter({ const [isShareModalOpen, setIsShareModalOpen] = useState(false) const [isGeneratingShare, setIsGeneratingShare] = useState(false) const [justCopied, setJustCopied] = useState(false) + const [isShuffling, setIsShuffling] = useState(false) + // Default to face derived from initial seed (2-6, excluding 1) + const [diceFace, setDiceFace] = useState(() => (formState.seed % 5) + 2) + const shuffleTimeoutRef = useRef() + const diceFaceIntervalRef = useRef() const isGenerating = status === 'generating' const [pageData, setPageData] = useState<{ currentPage: number @@ -50,6 +146,59 @@ export function PreviewCenter({ jumpToPage: (pageIndex: number) => void } | null>(null) + // Shuffle problems by generating a new random seed + const handleShuffle = useCallback(() => { + // Generate a new random seed (use modulo to keep it in 32-bit int range) + const newSeed = Date.now() % 2147483647 + onChange({ seed: newSeed }) + + // Start rolling animation + setIsShuffling(true) + + // Clear any existing intervals/timeouts + if (shuffleTimeoutRef.current) { + clearTimeout(shuffleTimeoutRef.current) + } + if (diceFaceIntervalRef.current) { + clearInterval(diceFaceIntervalRef.current) + } + + // Cycle through dice faces rapidly + diceFaceIntervalRef.current = setInterval(() => { + setDiceFace((prev) => (prev % 6) + 1) + }, 100) // Change face every 100ms + + // Stop animation after preview should have updated (debounce time + render time) + shuffleTimeoutRef.current = setTimeout(() => { + setIsShuffling(false) + if (diceFaceIntervalRef.current) { + clearInterval(diceFaceIntervalRef.current) + } + // End on a face derived from the seed (2-6, excluding 1 so it's clearly a dice) + // Also ensure it's different from the current face by offsetting if collision + setDiceFace((currentFace) => { + const baseFace = (newSeed % 5) + 2 // Results in 2, 3, 4, 5, or 6 + if (baseFace === currentFace) { + // If same, rotate to next face (wrapping 6 -> 2, skipping 1) + return baseFace === 6 ? 2 : baseFace + 1 + } + return baseFace + }) + }, 1500) // 1.5 seconds should cover debounce + render + }, [onChange]) + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (shuffleTimeoutRef.current) { + clearTimeout(shuffleTimeoutRef.current) + } + if (diceFaceIntervalRef.current) { + clearInterval(diceFaceIntervalRef.current) + } + } + }, []) + // Detect scrolling in the scroll container useEffect(() => { const container = scrollContainerRef.current @@ -128,6 +277,16 @@ export function PreviewCenter({ position: 'relative', })} > + {/* Inject keyframes for dice roll animation */} + + {/* Floating Action Button - Top Right */}
+ {/* Shuffle Button - only in edit mode (1/3 split secondary action) */} + {!isReadOnly && ( + + )} + {/* Dropdown Trigger */} diff --git a/apps/web/src/app/create/worksheets/typstHelpers.ts b/apps/web/src/app/create/worksheets/typstHelpers.ts index c538ef3f..b87a3db3 100644 --- a/apps/web/src/app/create/worksheets/typstHelpers.ts +++ b/apps/web/src/app/create/worksheets/typstHelpers.ts @@ -153,7 +153,7 @@ export function generateProblemStackFunction(cellSize: number, maxDigits: number }, // Second addend row with + sign (right to left) - box(width: ${cellSizeIn}, height: ${cellSizeIn})[#align(center + horizon)[#text(size: ${(cellSizePt * 0.8).toFixed(1)}pt)[+]]], + box(width: 0.5em, height: ${cellSizeIn})[#align(center + horizon)[#text(size: ${(cellSizePt * 0.8).toFixed(1)}pt)[+]]], ..for i in range(0, actual-digits).rev() { let digit = b-digits.at(i) let place-color = place-colors.at(i) // Dynamic color lookup by place value diff --git a/apps/web/src/app/create/worksheets/typstHelpers/subtraction/subtrahendRow.ts b/apps/web/src/app/create/worksheets/typstHelpers/subtraction/subtrahendRow.ts index 80a85f68..4467a7b3 100644 --- a/apps/web/src/app/create/worksheets/typstHelpers/subtraction/subtrahendRow.ts +++ b/apps/web/src/app/create/worksheets/typstHelpers/subtraction/subtrahendRow.ts @@ -19,7 +19,7 @@ export function generateSubtrahendRow(cellDimensions: CellDimensions): string { return String.raw` // Subtrahend row with − sign - box(width: ${cellSizeIn}, height: ${cellSizeIn})[ + box(width: 0.5em, height: ${cellSizeIn})[ #align(center + horizon)[ #text(size: ${(cellSizePt * 0.8).toFixed(1)}pt)[−] ]