feat(worksheets): add shuffle button with animated dice icon
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 <noreply@anthropic.com>
This commit is contained in:
@@ -3,17 +3,107 @@
|
|||||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||||
import { css } from '@styled/css'
|
import { css } from '@styled/css'
|
||||||
import { useRouter } from 'next/navigation'
|
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 type { WorksheetFormState } from '@/app/create/worksheets/types'
|
||||||
import { UploadWorksheetModal } from '@/components/worksheets/UploadWorksheetModal'
|
import { UploadWorksheetModal } from '@/components/worksheets/UploadWorksheetModal'
|
||||||
import { useTheme } from '@/contexts/ThemeContext'
|
import { useTheme } from '@/contexts/ThemeContext'
|
||||||
import { extractConfigFields } from '../utils/extractConfigFields'
|
import { extractConfigFields } from '../utils/extractConfigFields'
|
||||||
import { ShareModal } from './ShareModal'
|
|
||||||
import { WorksheetPreview } from './WorksheetPreview'
|
|
||||||
import { FloatingPageIndicator } from './FloatingPageIndicator'
|
import { FloatingPageIndicator } from './FloatingPageIndicator'
|
||||||
|
import { ShareModal } from './ShareModal'
|
||||||
|
import { useWorksheetConfig } from './WorksheetConfigContext'
|
||||||
|
import { WorksheetPreview } from './WorksheetPreview'
|
||||||
import { DuplicateWarningBanner } from './worksheet-preview/DuplicateWarningBanner'
|
import { DuplicateWarningBanner } from './worksheet-preview/DuplicateWarningBanner'
|
||||||
import { WorksheetPreviewProvider } from './worksheet-preview/WorksheetPreviewContext'
|
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 (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
width="22"
|
||||||
|
height="22"
|
||||||
|
style={{
|
||||||
|
animation: isRolling ? 'diceRoll 0.4s ease-in-out infinite' : 'none',
|
||||||
|
transformOrigin: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<rect x="2" y="2" width="20" height="20" rx="2" />
|
||||||
|
{dots.map(([cx, cy], i) => (
|
||||||
|
<circle
|
||||||
|
key={`${cx}-${cy}-${i}`}
|
||||||
|
cx={cx}
|
||||||
|
cy={cy}
|
||||||
|
r="1.5"
|
||||||
|
fill="currentColor"
|
||||||
|
stroke="none"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
interface PreviewCenterProps {
|
interface PreviewCenterProps {
|
||||||
formState: WorksheetFormState
|
formState: WorksheetFormState
|
||||||
initialPreview?: string[]
|
initialPreview?: string[]
|
||||||
@@ -35,6 +125,7 @@ export function PreviewCenter({
|
|||||||
}: PreviewCenterProps) {
|
}: PreviewCenterProps) {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme } = useTheme()
|
||||||
|
const { onChange } = useWorksheetConfig()
|
||||||
const isDark = resolvedTheme === 'dark'
|
const isDark = resolvedTheme === 'dark'
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||||
const [isScrolling, setIsScrolling] = useState(false)
|
const [isScrolling, setIsScrolling] = useState(false)
|
||||||
@@ -43,6 +134,11 @@ export function PreviewCenter({
|
|||||||
const [isShareModalOpen, setIsShareModalOpen] = useState(false)
|
const [isShareModalOpen, setIsShareModalOpen] = useState(false)
|
||||||
const [isGeneratingShare, setIsGeneratingShare] = useState(false)
|
const [isGeneratingShare, setIsGeneratingShare] = useState(false)
|
||||||
const [justCopied, setJustCopied] = 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<NodeJS.Timeout>()
|
||||||
|
const diceFaceIntervalRef = useRef<NodeJS.Timeout>()
|
||||||
const isGenerating = status === 'generating'
|
const isGenerating = status === 'generating'
|
||||||
const [pageData, setPageData] = useState<{
|
const [pageData, setPageData] = useState<{
|
||||||
currentPage: number
|
currentPage: number
|
||||||
@@ -50,6 +146,59 @@ export function PreviewCenter({
|
|||||||
jumpToPage: (pageIndex: number) => void
|
jumpToPage: (pageIndex: number) => void
|
||||||
} | null>(null)
|
} | 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
|
// Detect scrolling in the scroll container
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const container = scrollContainerRef.current
|
const container = scrollContainerRef.current
|
||||||
@@ -128,6 +277,16 @@ export function PreviewCenter({
|
|||||||
position: 'relative',
|
position: 'relative',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
{/* Inject keyframes for dice roll animation */}
|
||||||
|
<style>
|
||||||
|
{`
|
||||||
|
@keyframes diceRoll {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
|
||||||
{/* Floating Action Button - Top Right */}
|
{/* Floating Action Button - Top Right */}
|
||||||
<div
|
<div
|
||||||
data-component="floating-action-button"
|
data-component="floating-action-button"
|
||||||
@@ -199,6 +358,38 @@ export function PreviewCenter({
|
|||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Shuffle Button - only in edit mode (1/3 split secondary action) */}
|
||||||
|
{!isReadOnly && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-action="shuffle-problems"
|
||||||
|
onClick={handleShuffle}
|
||||||
|
disabled={isGenerating}
|
||||||
|
title="Shuffle problems (generate new set)"
|
||||||
|
className={css({
|
||||||
|
px: '3',
|
||||||
|
py: '2.5',
|
||||||
|
bg: 'brand.600',
|
||||||
|
color: 'white',
|
||||||
|
cursor: isGenerating ? 'not-allowed' : 'pointer',
|
||||||
|
opacity: isGenerating ? '0.7' : '1',
|
||||||
|
borderLeft: '1px solid',
|
||||||
|
borderColor: 'brand.700',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
transition: 'all 0.2s',
|
||||||
|
_hover: isGenerating
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
bg: 'brand.700',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<DiceIcon isRolling={isShuffling} currentFace={diceFace} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Dropdown Trigger */}
|
{/* Dropdown Trigger */}
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger asChild>
|
<DropdownMenu.Trigger asChild>
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export function generateProblemStackFunction(cellSize: number, maxDigits: number
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Second addend row with + sign (right to left)
|
// 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() {
|
..for i in range(0, actual-digits).rev() {
|
||||||
let digit = b-digits.at(i)
|
let digit = b-digits.at(i)
|
||||||
let place-color = place-colors.at(i) // Dynamic color lookup by place value
|
let place-color = place-colors.at(i) // Dynamic color lookup by place value
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function generateSubtrahendRow(cellDimensions: CellDimensions): string {
|
|||||||
|
|
||||||
return String.raw`
|
return String.raw`
|
||||||
// Subtrahend row with − sign
|
// Subtrahend row with − sign
|
||||||
box(width: ${cellSizeIn}, height: ${cellSizeIn})[
|
box(width: 0.5em, height: ${cellSizeIn})[
|
||||||
#align(center + horizon)[
|
#align(center + horizon)[
|
||||||
#text(size: ${(cellSizePt * 0.8).toFixed(1)}pt)[−]
|
#text(size: ${(cellSizePt * 0.8).toFixed(1)}pt)[−]
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user