Add crossfade animation to example problems synced with dice roll
- Create AnimatedMathDisplay component with opacity crossfade transitions - Use CSS Grid to stack old/new expressions independently (no layout shift) - Update examples at roll start (handleRoll) instead of roll complete - Set crossfade duration to 700ms to match dice animation timing - Both animations now end together for smooth visual experience Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
bb192c5e20
commit
f67b39f315
|
|
@ -0,0 +1,90 @@
|
|||
'use client'
|
||||
|
||||
import { useRef, useEffect, useState } from 'react'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { MathDisplay } from './MathDisplay'
|
||||
|
||||
interface AnimatedMathDisplayProps {
|
||||
/** Math expression string to render (e.g., "52 − 37" or "3 2/9 − 1 1/2") */
|
||||
expression: string
|
||||
/** Font size for the display */
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
/** Animation duration in ms (default: 700 to match dice animation) */
|
||||
duration?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* AnimatedMathDisplay - MathML display with crossfade transitions
|
||||
*
|
||||
* When the expression changes, the old expression fades out while
|
||||
* the new expression fades in (inverse opacity). Uses CSS Grid to
|
||||
* stack layers independently.
|
||||
*/
|
||||
export function AnimatedMathDisplay({
|
||||
expression,
|
||||
size = 'lg',
|
||||
duration = 700,
|
||||
}: AnimatedMathDisplayProps) {
|
||||
const prevExpressionRef = useRef<string>(expression)
|
||||
|
||||
const [layers, setLayers] = useState<
|
||||
Array<{ expression: string; opacity: number; id: number }>
|
||||
>([{ expression, opacity: 1, id: 0 }])
|
||||
|
||||
const idCounter = useRef(1)
|
||||
|
||||
useEffect(() => {
|
||||
if (expression !== prevExpressionRef.current) {
|
||||
const oldExpression = prevExpressionRef.current
|
||||
prevExpressionRef.current = expression
|
||||
const newId = idCounter.current++
|
||||
|
||||
// Add new layer at opacity 0, keep old layer at opacity 1
|
||||
setLayers([
|
||||
{ expression: oldExpression, opacity: 1, id: newId - 1 },
|
||||
{ expression, opacity: 0, id: newId },
|
||||
])
|
||||
|
||||
// After a frame, trigger the crossfade
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
setLayers([
|
||||
{ expression: oldExpression, opacity: 0, id: newId - 1 },
|
||||
{ expression, opacity: 1, id: newId },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
// After animation, remove the old layer
|
||||
const timeout = setTimeout(() => {
|
||||
setLayers([{ expression, opacity: 1, id: newId }])
|
||||
}, duration + 50)
|
||||
|
||||
return () => clearTimeout(timeout)
|
||||
}
|
||||
}, [expression, duration])
|
||||
|
||||
return (
|
||||
<span
|
||||
data-testid="animated-math-display"
|
||||
data-expression={expression}
|
||||
className={css({
|
||||
display: 'inline-grid',
|
||||
placeItems: 'center',
|
||||
})}
|
||||
>
|
||||
{layers.map((layer) => (
|
||||
<span
|
||||
key={layer.id}
|
||||
style={{
|
||||
gridArea: '1 / 1',
|
||||
opacity: layer.opacity,
|
||||
transition: `opacity ${duration}ms ease-in-out`,
|
||||
}}
|
||||
>
|
||||
<MathDisplay expression={layer.expression} size={size} />
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -28,7 +28,7 @@ import { TeacherConfigPanel } from './TeacherConfigPanel'
|
|||
import { useVisualDebugSafe } from '@/contexts/VisualDebugContext'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { vstack, hstack } from '../../../styled-system/patterns'
|
||||
import { MathDisplay } from './MathDisplay'
|
||||
import { AnimatedMathDisplay } from './AnimatedMathDisplay'
|
||||
|
||||
/** Difficulty tier for filtering examples */
|
||||
type DifficultyTier = 'easy' | 'medium' | 'hard' | 'all'
|
||||
|
|
@ -352,8 +352,13 @@ export function FlowchartProblemInput({
|
|||
pendingExamplesRef.current = generateExamplesAsync(flowchart, exampleCount, constraints)
|
||||
}, [flowchart, exampleCount, constraints])
|
||||
|
||||
// Show new examples after the dice roll animation completes
|
||||
const handleRollComplete = useCallback(async () => {
|
||||
// Dice roll animation complete - examples already updated in handleRoll
|
||||
const handleRollComplete = useCallback(() => {
|
||||
// No-op: examples are now updated at roll start (handleRoll) so crossfade syncs with dice
|
||||
}, [])
|
||||
|
||||
// Handler for dice roll - update examples immediately so crossfade syncs with dice animation
|
||||
const handleRoll = useCallback(async () => {
|
||||
let newExamples: GeneratedExample[] | null = null
|
||||
|
||||
// If we have pre-computed examples from drag (promise), await them
|
||||
|
|
@ -365,7 +370,7 @@ export function FlowchartProblemInput({
|
|||
}
|
||||
pendingExamplesRef.current = null
|
||||
} else if (flowchart) {
|
||||
// Click-only roll - compute via worker (animation masked the compute time)
|
||||
// Click-only roll - compute via worker
|
||||
try {
|
||||
newExamples = await generateExamplesAsync(flowchart, exampleCount, constraints)
|
||||
} catch (e) {
|
||||
|
|
@ -386,13 +391,6 @@ export function FlowchartProblemInput({
|
|||
}
|
||||
}, [flowchart, exampleCount, constraints, storageKey])
|
||||
|
||||
// Handler for dice roll - we don't update examples here anymore
|
||||
// (examples update on completion via handleRollComplete)
|
||||
const handleRoll = useCallback(() => {
|
||||
// The actual example update happens in handleRollComplete
|
||||
// This is called immediately when rolled - could add a spinning indicator here
|
||||
}, [])
|
||||
|
||||
// Handler for instant roll (shift+click) - bypasses animation
|
||||
const handleInstantRoll = useCallback(async () => {
|
||||
if (!flowchart) return
|
||||
|
|
@ -1066,7 +1064,7 @@ export function FlowchartProblemInput({
|
|||
})}
|
||||
>
|
||||
<div className={css({ color: { base: 'gray.900', _dark: 'white' } })}>
|
||||
<MathDisplay
|
||||
<AnimatedMathDisplay
|
||||
expression={formatExampleDisplay(schema.schema, example.values)}
|
||||
size="md"
|
||||
/>
|
||||
|
|
@ -1202,7 +1200,7 @@ export function FlowchartProblemInput({
|
|||
})}
|
||||
>
|
||||
<div className={css({ color: { base: 'gray.900', _dark: 'white' } })}>
|
||||
<MathDisplay
|
||||
<AnimatedMathDisplay
|
||||
expression={formatExampleDisplay(schema.schema, example.values)}
|
||||
size="md"
|
||||
/>
|
||||
|
|
@ -1306,7 +1304,7 @@ export function FlowchartProblemInput({
|
|||
})}
|
||||
>
|
||||
<div className={css({ color: { base: 'gray.900', _dark: 'white' } })}>
|
||||
<MathDisplay
|
||||
<AnimatedMathDisplay
|
||||
expression={formatExampleDisplay(schema.schema, example.values)}
|
||||
size="md"
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in New Issue