feat(practice): improve docked abacus UX and submit button behavior
- Force show submit button when abacus is docked (user needs manual submit) - Disable auto-help when docked; trigger help on submit for prefix sums - Fix dock animation to measure actual destination position - Keep problem centered when dock appears/disappears (absolute positioning) - Use scaleFactor prop for natural abacus sizing instead of manual calculations - Clean up unused dock size tracking and scale calculation code 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
2c832c7944
commit
60fc81bc2d
|
|
@ -4,12 +4,59 @@ import { animated, useSpring } from '@react-spring/web'
|
|||
import { ABACUS_THEMES, AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { createPortal, flushSync } from 'react-dom'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { HomeHeroContext } from '@/contexts/HomeHeroContext'
|
||||
import { type DockAnimationState, useMyAbacus } from '@/contexts/MyAbacusContext'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import { css } from '../../styled-system/css'
|
||||
|
||||
/**
|
||||
* Measure the size and position an AbacusReact will have when rendered into a dock element.
|
||||
* Temporarily renders an invisible abacus directly into the dock to get accurate positioning
|
||||
* (important for absolutely positioned docks where transforms depend on content size).
|
||||
*/
|
||||
function measureDockedAbacus(
|
||||
dockElement: HTMLElement,
|
||||
columns: number,
|
||||
scaleFactor: number | undefined,
|
||||
customStyles: typeof ABACUS_THEMES.light
|
||||
): { x: number; y: number; width: number; height: number } {
|
||||
// Create a temporary wrapper that matches how we render the docked abacus
|
||||
const measureWrapper = document.createElement('div')
|
||||
measureWrapper.style.visibility = 'hidden'
|
||||
measureWrapper.style.pointerEvents = 'none'
|
||||
|
||||
// Insert directly into the dock element so it gets proper size/position
|
||||
dockElement.appendChild(measureWrapper)
|
||||
|
||||
// Create a React root and render the abacus synchronously
|
||||
const root = createRoot(measureWrapper)
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<AbacusReact
|
||||
value={0}
|
||||
columns={columns}
|
||||
scaleFactor={scaleFactor}
|
||||
showNumbers={false}
|
||||
interactive={false}
|
||||
animated={false}
|
||||
customStyles={customStyles}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
// Measure the rendered size and position (dock element now has content, so transforms apply correctly)
|
||||
const rect = measureWrapper.getBoundingClientRect()
|
||||
const result = { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
|
||||
|
||||
// Clean up
|
||||
root.unmount()
|
||||
dockElement.removeChild(measureWrapper)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function MyAbacus() {
|
||||
const {
|
||||
isOpen,
|
||||
|
|
@ -33,9 +80,6 @@ export function MyAbacus() {
|
|||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
// Track dock container size for auto-scaling
|
||||
const [dockSize, setDockSize] = useState<{ width: number; height: number } | null>(null)
|
||||
|
||||
// Local ref for the button container (we'll connect this to context's buttonRef)
|
||||
const localButtonRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
|
|
@ -45,29 +89,6 @@ export function MyAbacus() {
|
|||
const abacusValue = homeHeroContext?.abacusValue ?? localAbacusValue
|
||||
const setAbacusValue = homeHeroContext?.setAbacusValue ?? setLocalAbacusValue
|
||||
|
||||
// Observe dock container size changes
|
||||
useEffect(() => {
|
||||
if (!dock?.element) {
|
||||
setDockSize(null)
|
||||
return
|
||||
}
|
||||
|
||||
const element = dock.element
|
||||
const updateSize = () => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
setDockSize({ width: rect.width, height: rect.height })
|
||||
}
|
||||
|
||||
// Initial size
|
||||
updateSize()
|
||||
|
||||
// Watch for size changes
|
||||
const resizeObserver = new ResizeObserver(updateSize)
|
||||
resizeObserver.observe(element)
|
||||
|
||||
return () => resizeObserver.disconnect()
|
||||
}, [dock?.element])
|
||||
|
||||
// Determine display mode - only hero mode on actual home page
|
||||
const isOnHomePage =
|
||||
pathname === '/' ||
|
||||
|
|
@ -118,25 +139,6 @@ export function MyAbacus() {
|
|||
// This matches /arcade, /arcade/*, and /arcade-rooms/*
|
||||
const isOnGameRoute = pathname?.startsWith('/arcade')
|
||||
|
||||
// Calculate scale factor for docked mode
|
||||
// Base abacus dimensions are approximately 120px wide per column, 200px tall
|
||||
const calculateDockedScale = () => {
|
||||
if (!dockSize || !dock) return 1
|
||||
if (dock.scaleFactor) return dock.scaleFactor
|
||||
|
||||
const columns = dock.columns ?? 5
|
||||
// Approximate base dimensions of AbacusReact at scale 1
|
||||
const baseWidth = columns * 24 + 20 // ~24px per column + padding
|
||||
const baseHeight = 55 // approximate height
|
||||
|
||||
const scaleX = dockSize.width / baseWidth
|
||||
const scaleY = dockSize.height / baseHeight
|
||||
// Use the smaller scale to fit within container, with some padding
|
||||
return Math.min(scaleX, scaleY) * 0.85
|
||||
}
|
||||
|
||||
const dockedScale = calculateDockedScale()
|
||||
|
||||
// Sync local button ref with context's buttonRef
|
||||
useEffect(() => {
|
||||
if (buttonRef && localButtonRef.current) {
|
||||
|
|
@ -212,13 +214,21 @@ export function MyAbacus() {
|
|||
return
|
||||
}
|
||||
|
||||
// Measure positions
|
||||
// Measure the button's current position
|
||||
const buttonRect = localButtonRef.current.getBoundingClientRect()
|
||||
const dockRect = dock.element.getBoundingClientRect()
|
||||
|
||||
// Calculate scales - button shows at 0.35 scale, dock uses dockedScale
|
||||
// Measure where the docked abacus will appear (renders temporarily to get accurate position)
|
||||
const dockColumns = dock.columns ?? 5
|
||||
const targetRect = measureDockedAbacus(
|
||||
dock.element,
|
||||
dockColumns,
|
||||
dock.scaleFactor,
|
||||
structuralStyles
|
||||
)
|
||||
|
||||
// Calculate scales - button shows at 0.35 scale, dock uses scaleFactor directly
|
||||
const buttonScale = 0.35
|
||||
const targetScale = dockedScale
|
||||
const targetScale = dock.scaleFactor ?? 1
|
||||
|
||||
const animState: DockAnimationState = {
|
||||
phase: 'docking',
|
||||
|
|
@ -228,18 +238,13 @@ export function MyAbacus() {
|
|||
width: buttonRect.width,
|
||||
height: buttonRect.height,
|
||||
},
|
||||
toRect: {
|
||||
x: dockRect.x,
|
||||
y: dockRect.y,
|
||||
width: dockRect.width,
|
||||
height: dockRect.height,
|
||||
},
|
||||
toRect: targetRect,
|
||||
fromScale: buttonScale,
|
||||
toScale: targetScale,
|
||||
}
|
||||
|
||||
startDockAnimation(animState)
|
||||
}, [dock, dockInto, dockedScale, startDockAnimation])
|
||||
}, [dock, dockInto, structuralStyles, startDockAnimation])
|
||||
|
||||
// Handler to initiate undock animation
|
||||
const handleUndockClick = useCallback(() => {
|
||||
|
|
@ -249,8 +254,24 @@ export function MyAbacus() {
|
|||
return
|
||||
}
|
||||
|
||||
// Measure dock position (source)
|
||||
const dockRect = dock.element.getBoundingClientRect()
|
||||
// The abacus is currently docked - find the actual rendered abacus element
|
||||
const dockedAbacus = dock.element.querySelector('[data-element="abacus-display"]')
|
||||
let sourceRect: { x: number; y: number; width: number; height: number }
|
||||
|
||||
if (dockedAbacus) {
|
||||
// Measure the actual docked abacus position
|
||||
const rect = dockedAbacus.getBoundingClientRect()
|
||||
sourceRect = { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
|
||||
} else {
|
||||
// Fallback: measure what it would be
|
||||
const dockColumns = dock.columns ?? 5
|
||||
sourceRect = measureDockedAbacus(
|
||||
dock.element,
|
||||
dockColumns,
|
||||
dock.scaleFactor,
|
||||
structuralStyles
|
||||
)
|
||||
}
|
||||
|
||||
// Calculate target button position (we don't need the ref - button has known fixed position)
|
||||
// Button is fixed at bottom-right with some margin
|
||||
|
|
@ -262,16 +283,11 @@ export function MyAbacus() {
|
|||
const buttonY = viewportHeight - buttonSize - margin
|
||||
|
||||
const buttonScale = 0.35
|
||||
const dockScale = dockedScale
|
||||
const dockScale = dock.scaleFactor ?? 1
|
||||
|
||||
const animState: DockAnimationState = {
|
||||
phase: 'undocking',
|
||||
fromRect: {
|
||||
x: dockRect.x,
|
||||
y: dockRect.y,
|
||||
width: dockRect.width,
|
||||
height: dockRect.height,
|
||||
},
|
||||
fromRect: sourceRect,
|
||||
toRect: {
|
||||
x: buttonX,
|
||||
y: buttonY,
|
||||
|
|
@ -283,7 +299,7 @@ export function MyAbacus() {
|
|||
}
|
||||
|
||||
startUndockAnimation(animState)
|
||||
}, [dock, undock, dockedScale, startUndockAnimation])
|
||||
}, [dock, undock, structuralStyles, startUndockAnimation])
|
||||
|
||||
// Check if we're currently animating
|
||||
const isAnimating = dockAnimationState !== null
|
||||
|
|
@ -365,8 +381,6 @@ export function MyAbacus() {
|
|||
data-mode="docked"
|
||||
data-dock-id={dock.id}
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
|
@ -414,10 +428,7 @@ export function MyAbacus() {
|
|||
</button>
|
||||
<div
|
||||
data-element="abacus-display"
|
||||
style={{ transform: `scale(${dockedScale})` }}
|
||||
className={css({
|
||||
transformOrigin: 'center center',
|
||||
transition: 'transform 0.3s ease',
|
||||
filter: 'drop-shadow(0 4px 12px rgba(251, 191, 36, 0.2))',
|
||||
})}
|
||||
>
|
||||
|
|
@ -426,6 +437,7 @@ export function MyAbacus() {
|
|||
value={dock.value ?? abacusValue}
|
||||
defaultValue={dock.defaultValue}
|
||||
columns={dock.columns ?? 5}
|
||||
scaleFactor={dock.scaleFactor}
|
||||
beadShape={appConfig.beadShape}
|
||||
showNumbers={dock.showNumbers ?? true}
|
||||
interactive={dock.interactive ?? true}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { animated, useSpring } from '@react-spring/web'
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { useMyAbacus } from '@/contexts/MyAbacusContext'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import type {
|
||||
ProblemSlot,
|
||||
|
|
@ -198,6 +199,9 @@ export function ActiveSession({
|
|||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
// Check if abacus is docked (to force show submit button)
|
||||
const { isDockedByUser } = useMyAbacus()
|
||||
|
||||
// Sound effects
|
||||
const { playSound } = usePracticeSoundEffects()
|
||||
|
||||
|
|
@ -288,10 +292,12 @@ export function ActiveSession({
|
|||
}))
|
||||
|
||||
// Spring for submit button entrance animation
|
||||
// Show submit button when: manual submit is required OR abacus is docked (user needs way to submit)
|
||||
const showSubmitButton = attempt?.manualSubmitRequired || isDockedByUser
|
||||
const submitButtonSpring = useSpring({
|
||||
transform: attempt?.manualSubmitRequired ? 'translateY(0px)' : 'translateY(60px)',
|
||||
opacity: attempt?.manualSubmitRequired ? 1 : 0,
|
||||
scale: attempt?.manualSubmitRequired ? 1 : 0.8,
|
||||
transform: showSubmitButton ? 'translateY(0px)' : 'translateY(60px)',
|
||||
opacity: showSubmitButton ? 1 : 0,
|
||||
scale: showSubmitButton ? 1 : 0.8,
|
||||
config: { tension: 280, friction: 14 },
|
||||
})
|
||||
|
||||
|
|
@ -391,7 +397,11 @@ export function ActiveSession({
|
|||
// Auto-trigger help when an unambiguous prefix sum is detected
|
||||
// The awaitingDisambiguation phase handles the timer and auto-transitions to helpMode when it expires
|
||||
// This effect only handles the inputting phase case for unambiguous matches
|
||||
// DISABLED when abacus is docked - user controls when to submit, help triggers on submit if needed
|
||||
useEffect(() => {
|
||||
// Skip auto-help when abacus is docked - user has manual control
|
||||
if (isDockedByUser) return
|
||||
|
||||
// Only handle unambiguous prefix matches in inputting phase
|
||||
// Ambiguous cases are handled by awaitingDisambiguation phase, which auto-transitions to helpMode
|
||||
if (phase.phase !== 'inputting') return
|
||||
|
|
@ -403,7 +413,7 @@ export function ActiveSession({
|
|||
enterHelpMode(newConfirmedCount)
|
||||
}
|
||||
}
|
||||
}, [phase, matchedPrefixIndex, prefixSums.length, enterHelpMode])
|
||||
}, [phase, matchedPrefixIndex, prefixSums.length, enterHelpMode, isDockedByUser])
|
||||
|
||||
// Handle when student reaches target value on help abacus
|
||||
// Sequence: show target value → dismiss abacus → show value in answer boxes → fade to empty → exit
|
||||
|
|
@ -458,6 +468,21 @@ export function ActiveSession({
|
|||
const answerNum = parseInt(attemptData.userAnswer, 10)
|
||||
if (Number.isNaN(answerNum)) return
|
||||
|
||||
// When abacus is docked and not already in help mode, check if answer is a prefix sum
|
||||
// If so, trigger help mode instead of submitting (mimic auto-help behavior on submit)
|
||||
if (isDockedByUser && phase.phase !== 'helpMode') {
|
||||
// Check if the answer matches a prefix sum (but not the final answer)
|
||||
const prefixIndex = prefixSums.indexOf(answerNum)
|
||||
if (prefixIndex >= 0 && prefixIndex < prefixSums.length - 1) {
|
||||
// Answer matches a prefix sum - enter help mode instead of submitting
|
||||
const newConfirmedCount = prefixIndex + 1
|
||||
if (newConfirmedCount < attemptData.problem.terms.length) {
|
||||
enterHelpMode(newConfirmedCount)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Transition to submitting phase
|
||||
startSubmit()
|
||||
|
||||
|
|
@ -512,10 +537,14 @@ export function ActiveSession({
|
|||
onAnswer,
|
||||
currentSlotIndex,
|
||||
currentPart,
|
||||
currentPartIndex,
|
||||
startSubmit,
|
||||
completeSubmit,
|
||||
startTransition,
|
||||
clearToLoading,
|
||||
isDockedByUser,
|
||||
prefixSums,
|
||||
enterHelpMode,
|
||||
])
|
||||
|
||||
// Auto-submit when correct answer is entered
|
||||
|
|
@ -1160,8 +1189,7 @@ export function ActiveSession({
|
|||
</DecompositionProvider>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Abacus dock - positioned to the right of the problem when in abacus mode and not in help mode */}
|
||||
{/* Abacus dock - positioned absolutely so it doesn't affect problem centering */}
|
||||
{currentPart.type === 'abacus' && !showHelpOverlay && (
|
||||
<AbacusDock
|
||||
id="practice-abacus"
|
||||
|
|
@ -1169,22 +1197,14 @@ export function ActiveSession({
|
|||
interactive={true}
|
||||
showNumbers={false}
|
||||
animated={true}
|
||||
scaleFactor={2.5}
|
||||
onValueChange={handleAbacusDockValueChange}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
left: '100%',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
marginLeft: '1rem',
|
||||
width: '120px',
|
||||
'@media (min-width: 768px)': {
|
||||
marginLeft: '1.5rem',
|
||||
width: '150px',
|
||||
},
|
||||
'@media (min-width: 1024px)': {
|
||||
marginLeft: '2rem',
|
||||
width: '180px',
|
||||
},
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
marginLeft: '1.5rem',
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -1226,9 +1246,9 @@ export function ActiveSession({
|
|||
<animated.button
|
||||
type="button"
|
||||
data-action="submit"
|
||||
data-visible={attempt.manualSubmitRequired}
|
||||
data-visible={showSubmitButton}
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit || isSubmitting || !attempt.manualSubmitRequired}
|
||||
disabled={!canSubmit || isSubmitting || !showSubmitButton}
|
||||
style={submitButtonSpring}
|
||||
className={css({
|
||||
padding: '0.75rem 2rem',
|
||||
|
|
@ -1236,16 +1256,12 @@ export function ActiveSession({
|
|||
fontWeight: 'bold',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
cursor: !canSubmit || !attempt.manualSubmitRequired ? 'not-allowed' : 'pointer',
|
||||
cursor: !canSubmit || !showSubmitButton ? 'not-allowed' : 'pointer',
|
||||
backgroundColor: canSubmit ? 'blue.500' : isDark ? 'gray.700' : 'gray.300',
|
||||
color: !canSubmit ? (isDark ? 'gray.400' : 'gray.500') : 'white',
|
||||
_hover: {
|
||||
backgroundColor:
|
||||
canSubmit && attempt.manualSubmitRequired
|
||||
? 'blue.600'
|
||||
: isDark
|
||||
? 'gray.600'
|
||||
: 'gray.300',
|
||||
canSubmit && showSubmitButton ? 'blue.600' : isDark ? 'gray.600' : 'gray.300',
|
||||
},
|
||||
})}
|
||||
>
|
||||
|
|
@ -1261,7 +1277,7 @@ export function ActiveSession({
|
|||
onSubmit={handleSubmit}
|
||||
disabled={isSubmitting}
|
||||
currentValue={attempt.userAnswer}
|
||||
showSubmitButton={attempt.manualSubmitRequired}
|
||||
showSubmitButton={showSubmitButton}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue