feat(abacus): add dockable abacus feature for practice sessions
Add AbacusDock component that allows the floating MyAbacus to dock into designated areas within the UI: - New AbacusDock component with configurable props (columns, showNumbers, value, defaultValue, onValueChange, interactive, animated) - MyAbacus can now render as: hero, button, open overlay, or docked - Click floating button when dock is visible to dock the abacus - Undock button appears in top-right of docked abacus - Practice sessions use dock for answer input (auto-submit on correct answer) - Dock sizing now matches problem height with responsive widths 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
1a7945dd0b
commit
5fb4751728
|
|
@ -0,0 +1,144 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useRef, type CSSProperties, type HTMLAttributes } from 'react'
|
||||
import { useMyAbacus, type DockConfig } from '@/contexts/MyAbacusContext'
|
||||
|
||||
export interface AbacusDockProps extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
|
||||
/** Optional identifier for debugging */
|
||||
id?: string
|
||||
/** Number of columns to display (default: 5) */
|
||||
columns?: number
|
||||
/** Whether the abacus is interactive (default: true) */
|
||||
interactive?: boolean
|
||||
/** Whether to show numbers below columns (default: true) */
|
||||
showNumbers?: boolean
|
||||
/** Whether to animate bead movements (default: true) */
|
||||
animated?: boolean
|
||||
/** Scale factor for the abacus (default: auto-fit to container) */
|
||||
scaleFactor?: number
|
||||
/** Controlled value - when provided, dock controls the abacus value */
|
||||
value?: number
|
||||
/** Default value for uncontrolled mode */
|
||||
defaultValue?: number
|
||||
/** Callback when value changes (for controlled mode) */
|
||||
onValueChange?: (newValue: number) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* AbacusDock - A container that the global MyAbacus will render into
|
||||
*
|
||||
* Place this component anywhere you want the abacus to appear docked.
|
||||
* When mounted, the global abacus will portal into this container instead
|
||||
* of showing as a floating button.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* // Basic usage - abacus will auto-fit to the container
|
||||
* <AbacusDock className={css({ width: '300px', height: '400px' })} />
|
||||
*
|
||||
* // With custom configuration
|
||||
* <AbacusDock
|
||||
* columns={4}
|
||||
* interactive={true}
|
||||
* showNumbers={true}
|
||||
* className={css({ width: '250px', height: '350px' })}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function AbacusDock({
|
||||
id,
|
||||
columns = 5,
|
||||
interactive = true,
|
||||
showNumbers = true,
|
||||
animated = true,
|
||||
scaleFactor,
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
style,
|
||||
...divProps
|
||||
}: AbacusDockProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const { registerDock, unregisterDock, updateDockVisibility } = useMyAbacus()
|
||||
|
||||
// Register the dock
|
||||
useEffect(() => {
|
||||
const element = containerRef.current
|
||||
if (!element) return
|
||||
|
||||
const config: DockConfig = {
|
||||
element,
|
||||
id,
|
||||
columns,
|
||||
interactive,
|
||||
showNumbers,
|
||||
animated,
|
||||
scaleFactor,
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
isVisible: false, // Will be updated by IntersectionObserver
|
||||
}
|
||||
|
||||
registerDock(config)
|
||||
|
||||
return () => {
|
||||
unregisterDock(element)
|
||||
}
|
||||
}, [
|
||||
id,
|
||||
columns,
|
||||
interactive,
|
||||
showNumbers,
|
||||
animated,
|
||||
scaleFactor,
|
||||
value,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
registerDock,
|
||||
unregisterDock,
|
||||
])
|
||||
|
||||
// Track visibility with IntersectionObserver
|
||||
useEffect(() => {
|
||||
const element = containerRef.current
|
||||
if (!element) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
// Consider visible if at least 20% is in view
|
||||
updateDockVisibility(element, entry.isIntersecting && entry.intersectionRatio >= 0.2)
|
||||
}
|
||||
},
|
||||
{
|
||||
threshold: [0, 0.2, 0.5, 1.0],
|
||||
}
|
||||
)
|
||||
|
||||
observer.observe(element)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [updateDockVisibility])
|
||||
|
||||
// Default styles for the dock container
|
||||
const defaultStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
...style,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
data-component="abacus-dock"
|
||||
data-dock-id={id}
|
||||
style={defaultStyle}
|
||||
{...divProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default AbacusDock
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { AbacusReact, useAbacusConfig, ABACUS_THEMES } from '@soroban/abacus-react'
|
||||
import { css } from '../../styled-system/css'
|
||||
|
|
@ -9,18 +10,45 @@ import { HomeHeroContext } from '@/contexts/HomeHeroContext'
|
|||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
|
||||
export function MyAbacus() {
|
||||
const { isOpen, close, toggle, isHidden, showInGame } = useMyAbacus()
|
||||
const { isOpen, close, toggle, isHidden, showInGame, dock, isDockedByUser, dockInto, undock } =
|
||||
useMyAbacus()
|
||||
const appConfig = useAbacusConfig()
|
||||
const pathname = usePathname()
|
||||
const { resolvedTheme } = useTheme()
|
||||
const isDark = resolvedTheme === 'dark'
|
||||
|
||||
// Track dock container size for auto-scaling
|
||||
const [dockSize, setDockSize] = useState<{ width: number; height: number } | null>(null)
|
||||
|
||||
// Sync with hero context if on home page
|
||||
const homeHeroContext = useContext(HomeHeroContext)
|
||||
const [localAbacusValue, setLocalAbacusValue] = useState(1234)
|
||||
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 === '/' ||
|
||||
|
|
@ -32,6 +60,10 @@ export function MyAbacus() {
|
|||
pathname === '/la'
|
||||
const isHeroVisible = homeHeroContext?.isHeroVisible ?? false
|
||||
const isHeroMode = isOnHomePage && isHeroVisible && !isOpen
|
||||
// Only render in docked mode if user has chosen to dock
|
||||
const isDocked = isDockedByUser && dock !== null && !isOpen
|
||||
// Show dockable indicator when dock is visible in viewport but not yet docked
|
||||
const isDockable = !isDockedByUser && dock?.isVisible && !isOpen && !isHeroMode
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
|
|
@ -67,12 +99,32 @@ 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()
|
||||
|
||||
// Hide completely when:
|
||||
// 1. isHidden is true (e.g., virtual keyboard is shown on non-game pages)
|
||||
// 2. On a game route and the game hasn't opted in to show it
|
||||
// 3. NOT docked (docked abacus should always show)
|
||||
// Still allow open state to work (user explicitly opened it)
|
||||
// NOTE: This must come after all hooks to follow React's rules of hooks
|
||||
if (!isOpen && (isHidden || (isOnGameRoute && !showInGame))) {
|
||||
if (!isOpen && !isDocked && (isHidden || (isOnGameRoute && !showInGame))) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -133,122 +185,236 @@ export function MyAbacus() {
|
|||
</button>
|
||||
)}
|
||||
|
||||
{/* Single abacus element that morphs between states */}
|
||||
<div
|
||||
data-component="my-abacus"
|
||||
data-mode={isOpen ? 'open' : isHeroMode ? 'hero' : 'button'}
|
||||
onClick={isOpen || isHeroMode ? undefined : toggle}
|
||||
className={css({
|
||||
position: isHeroMode ? 'absolute' : 'fixed',
|
||||
zIndex: 102,
|
||||
cursor: isOpen || isHeroMode ? 'default' : 'pointer',
|
||||
transition: 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
// Three modes: hero (absolute - scrolls with document), button (fixed), open (fixed)
|
||||
...(isOpen
|
||||
? {
|
||||
// Open mode: fixed to center of viewport
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}
|
||||
: isHeroMode
|
||||
{/* Docked mode: render directly into the dock container via portal */}
|
||||
{isDocked &&
|
||||
dock?.element &&
|
||||
createPortal(
|
||||
<div
|
||||
data-component="my-abacus"
|
||||
data-mode="docked"
|
||||
data-dock-id={dock.id}
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
})}
|
||||
>
|
||||
{/* Undock button - positioned at top-right of dock container */}
|
||||
<button
|
||||
data-action="undock-abacus"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
undock()
|
||||
}}
|
||||
title="Undock abacus"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
right: 0,
|
||||
margin: '4px',
|
||||
}}
|
||||
className={css({
|
||||
w: '24px',
|
||||
h: '24px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
bg: 'rgba(0, 0, 0, 0.5)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: 'md',
|
||||
color: 'white',
|
||||
fontSize: 'xs',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
zIndex: 10,
|
||||
opacity: 0.7,
|
||||
_hover: {
|
||||
bg: 'rgba(0, 0, 0, 0.7)',
|
||||
opacity: 1,
|
||||
transform: 'scale(1.1)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
↗
|
||||
</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))',
|
||||
})}
|
||||
>
|
||||
<AbacusReact
|
||||
key="docked"
|
||||
value={dock.value ?? abacusValue}
|
||||
defaultValue={dock.defaultValue}
|
||||
columns={dock.columns ?? 5}
|
||||
beadShape={appConfig.beadShape}
|
||||
showNumbers={dock.showNumbers ?? true}
|
||||
interactive={dock.interactive ?? true}
|
||||
animated={dock.animated ?? true}
|
||||
customStyles={structuralStyles}
|
||||
onValueChange={(newValue: number | bigint) => {
|
||||
const numValue = Number(newValue)
|
||||
// Always update local state so abacus reflects the change
|
||||
// (unless dock provides its own value prop for full control)
|
||||
if (dock.value === undefined) {
|
||||
setAbacusValue(numValue)
|
||||
}
|
||||
// Also call dock's callback if provided
|
||||
if (dock.onValueChange) {
|
||||
dock.onValueChange(numValue)
|
||||
}
|
||||
}}
|
||||
enhanced3d="realistic"
|
||||
material3d={{
|
||||
heavenBeads: 'glossy',
|
||||
earthBeads: 'satin',
|
||||
lighting: 'dramatic',
|
||||
woodGrain: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
dock.element
|
||||
)}
|
||||
|
||||
{/* Non-docked modes: hero, button, open */}
|
||||
{!isDocked && (
|
||||
<div
|
||||
data-component="my-abacus"
|
||||
data-mode={isOpen ? 'open' : isHeroMode ? 'hero' : 'button'}
|
||||
data-dockable={isDockable ? 'true' : undefined}
|
||||
onClick={isOpen || isHeroMode ? undefined : isDockable ? dockInto : toggle}
|
||||
className={css({
|
||||
position: isHeroMode ? 'absolute' : 'fixed',
|
||||
zIndex: 102,
|
||||
cursor: isOpen || isHeroMode ? 'default' : 'pointer',
|
||||
transition: 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
// Three modes: hero (absolute - scrolls with document), button (fixed), open (fixed)
|
||||
...(isOpen
|
||||
? {
|
||||
// Hero mode: absolute positioning - scrolls naturally with document
|
||||
top: '60vh',
|
||||
// Open mode: fixed to center of viewport
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}
|
||||
: {
|
||||
// Button mode: fixed to bottom-right corner
|
||||
bottom: { base: '4', md: '6' },
|
||||
right: { base: '4', md: '6' },
|
||||
transform: 'translate(0, 0)',
|
||||
}),
|
||||
})}
|
||||
>
|
||||
{/* Container that changes between hero, button, and open states */}
|
||||
<div
|
||||
className={css({
|
||||
transition: 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
...(isOpen || isHeroMode
|
||||
? {
|
||||
// Open/Hero state: no background, just the abacus
|
||||
bg: 'transparent',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
borderRadius: '0',
|
||||
}
|
||||
: {
|
||||
// Button state: button styling
|
||||
bg: isDark ? 'rgba(0, 0, 0, 0.7)' : 'rgba(255, 255, 255, 0.9)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: isDark
|
||||
? '3px solid rgba(251, 191, 36, 0.5)'
|
||||
: '3px solid rgba(251, 191, 36, 0.6)',
|
||||
boxShadow: isDark
|
||||
? '0 8px 32px rgba(251, 191, 36, 0.4)'
|
||||
: '0 8px 32px rgba(251, 191, 36, 0.5)',
|
||||
borderRadius: 'xl',
|
||||
w: { base: '80px', md: '100px' },
|
||||
h: { base: '80px', md: '100px' },
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
animation: 'pulse 2s ease-in-out infinite',
|
||||
_hover: {
|
||||
transform: 'scale(1.1)',
|
||||
boxShadow: isDark
|
||||
? '0 12px 48px rgba(251, 191, 36, 0.6)'
|
||||
: '0 12px 48px rgba(251, 191, 36, 0.7)',
|
||||
borderColor: 'rgba(251, 191, 36, 0.8)',
|
||||
},
|
||||
}),
|
||||
: isHeroMode
|
||||
? {
|
||||
// Hero mode: absolute positioning - scrolls naturally with document
|
||||
top: '60vh',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}
|
||||
: {
|
||||
// Button mode: fixed to bottom-right corner
|
||||
bottom: { base: '4', md: '6' },
|
||||
right: { base: '4', md: '6' },
|
||||
transform: 'translate(0, 0)',
|
||||
}),
|
||||
})}
|
||||
>
|
||||
{/* The abacus itself - same element, scales between hero/button/open */}
|
||||
{/* Container that changes between hero, button, and open states */}
|
||||
<div
|
||||
data-element="abacus-display"
|
||||
className={css({
|
||||
transform: isOpen
|
||||
? { base: 'scale(2.5)', md: 'scale(3.5)', lg: 'scale(4.5)' }
|
||||
: isHeroMode
|
||||
? { base: 'scale(3)', md: 'scale(3.5)', lg: 'scale(4.25)' }
|
||||
: 'scale(0.35)',
|
||||
transformOrigin: 'center center',
|
||||
transition: 'transform 0.6s cubic-bezier(0.4, 0, 0.2, 1), filter 0.6s ease',
|
||||
filter:
|
||||
isOpen || isHeroMode
|
||||
? 'drop-shadow(0 10px 40px rgba(251, 191, 36, 0.3))'
|
||||
: 'drop-shadow(0 4px 12px rgba(251, 191, 36, 0.2))',
|
||||
pointerEvents: isOpen || isHeroMode ? 'auto' : 'none',
|
||||
transition: 'all 0.6s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
...(isOpen || isHeroMode
|
||||
? {
|
||||
// Open/Hero state: no background, just the abacus
|
||||
bg: 'transparent',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
borderRadius: '0',
|
||||
}
|
||||
: {
|
||||
// Button state: button styling
|
||||
// Use cyan/teal when dockable to indicate "dock me" state
|
||||
bg: isDark ? 'rgba(0, 0, 0, 0.7)' : 'rgba(255, 255, 255, 0.9)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
border: isDockable
|
||||
? '3px solid rgba(34, 211, 238, 0.7)'
|
||||
: isDark
|
||||
? '3px solid rgba(251, 191, 36, 0.5)'
|
||||
: '3px solid rgba(251, 191, 36, 0.6)',
|
||||
boxShadow: isDockable
|
||||
? '0 8px 32px rgba(34, 211, 238, 0.5)'
|
||||
: isDark
|
||||
? '0 8px 32px rgba(251, 191, 36, 0.4)'
|
||||
: '0 8px 32px rgba(251, 191, 36, 0.5)',
|
||||
borderRadius: 'xl',
|
||||
w: { base: '80px', md: '100px' },
|
||||
h: { base: '80px', md: '100px' },
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
animation: isDockable
|
||||
? 'pulseDock 1.5s ease-in-out infinite'
|
||||
: 'pulse 2s ease-in-out infinite',
|
||||
_hover: {
|
||||
transform: 'scale(1.1)',
|
||||
boxShadow: isDockable
|
||||
? '0 12px 48px rgba(34, 211, 238, 0.7)'
|
||||
: isDark
|
||||
? '0 12px 48px rgba(251, 191, 36, 0.6)'
|
||||
: '0 12px 48px rgba(251, 191, 36, 0.7)',
|
||||
borderColor: isDockable
|
||||
? 'rgba(34, 211, 238, 0.9)'
|
||||
: 'rgba(251, 191, 36, 0.8)',
|
||||
},
|
||||
}),
|
||||
})}
|
||||
>
|
||||
<AbacusReact
|
||||
key={isHeroMode ? 'hero' : isOpen ? 'open' : 'closed'}
|
||||
value={abacusValue}
|
||||
columns={isHeroMode ? 4 : 5}
|
||||
beadShape={appConfig.beadShape}
|
||||
showNumbers={isOpen || isHeroMode}
|
||||
interactive={isOpen || isHeroMode}
|
||||
animated={isOpen || isHeroMode}
|
||||
customStyles={isHeroMode ? structuralStyles : trophyStyles}
|
||||
onValueChange={setAbacusValue}
|
||||
// 3D Enhancement - realistic mode for hero and open states
|
||||
enhanced3d={isOpen || isHeroMode ? 'realistic' : undefined}
|
||||
material3d={
|
||||
isOpen || isHeroMode
|
||||
? {
|
||||
heavenBeads: 'glossy',
|
||||
earthBeads: 'satin',
|
||||
lighting: 'dramatic',
|
||||
woodGrain: true,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{/* The abacus itself - same element, scales between hero/button/open */}
|
||||
<div
|
||||
data-element="abacus-display"
|
||||
className={css({
|
||||
transform: isOpen
|
||||
? { base: 'scale(2.5)', md: 'scale(3.5)', lg: 'scale(4.5)' }
|
||||
: isHeroMode
|
||||
? { base: 'scale(3)', md: 'scale(3.5)', lg: 'scale(4.25)' }
|
||||
: 'scale(0.35)',
|
||||
transformOrigin: 'center center',
|
||||
transition: 'transform 0.6s cubic-bezier(0.4, 0, 0.2, 1), filter 0.6s ease',
|
||||
filter:
|
||||
isOpen || isHeroMode
|
||||
? 'drop-shadow(0 10px 40px rgba(251, 191, 36, 0.3))'
|
||||
: 'drop-shadow(0 4px 12px rgba(251, 191, 36, 0.2))',
|
||||
pointerEvents: isOpen || isHeroMode ? 'auto' : 'none',
|
||||
})}
|
||||
>
|
||||
<AbacusReact
|
||||
key={isHeroMode ? 'hero' : isOpen ? 'open' : 'closed'}
|
||||
value={abacusValue}
|
||||
columns={isHeroMode ? 4 : 5}
|
||||
beadShape={appConfig.beadShape}
|
||||
showNumbers={isOpen || isHeroMode}
|
||||
interactive={isOpen || isHeroMode}
|
||||
animated={isOpen || isHeroMode}
|
||||
customStyles={isHeroMode ? structuralStyles : trophyStyles}
|
||||
onValueChange={setAbacusValue}
|
||||
// 3D Enhancement - realistic mode for hero and open states
|
||||
enhanced3d={isOpen || isHeroMode ? 'realistic' : undefined}
|
||||
material3d={
|
||||
isOpen || isHeroMode
|
||||
? {
|
||||
heavenBeads: 'glossy',
|
||||
earthBeads: 'satin',
|
||||
lighting: 'dramatic',
|
||||
woodGrain: true,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Keyframes for animations */}
|
||||
<style
|
||||
|
|
@ -266,6 +432,10 @@ export function MyAbacus() {
|
|||
0%, 100% { box-shadow: 0 8px 32px rgba(251, 191, 36, 0.4); }
|
||||
50% { box-shadow: 0 12px 48px rgba(251, 191, 36, 0.6); }
|
||||
}
|
||||
@keyframes pulseDock {
|
||||
0%, 100% { box-shadow: 0 8px 32px rgba(34, 211, 238, 0.5); }
|
||||
50% { box-shadow: 0 12px 48px rgba(34, 211, 238, 0.8); }
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import type {
|
|||
SlotResult,
|
||||
} from '@/db/schema/session-plans'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { AbacusDock } from '../AbacusDock'
|
||||
import { DecompositionProvider, DecompositionSection } from '../decomposition'
|
||||
import { generateCoachHint } from './coachHintGenerator'
|
||||
import { useHasPhysicalKeyboard } from './hooks/useDeviceDetection'
|
||||
|
|
@ -432,6 +433,15 @@ export function ActiveSession({
|
|||
}, 600) // Brief pause to see success state on abacus
|
||||
}, [phase.phase, helpContext, setAnswer, clearAnswer, exitHelpMode])
|
||||
|
||||
// Handle value change from the docked abacus
|
||||
const handleAbacusDockValueChange = useCallback(
|
||||
(newValue: number) => {
|
||||
// When the abacus shows the correct answer, set it and auto-submit will trigger
|
||||
setAnswer(String(newValue))
|
||||
},
|
||||
[setAnswer]
|
||||
)
|
||||
|
||||
// Handle submit
|
||||
const handleSubmit = useCallback(async () => {
|
||||
// Allow submitting from inputting, awaitingDisambiguation, or helpMode
|
||||
|
|
@ -1150,6 +1160,34 @@ export function ActiveSession({
|
|||
</DecompositionProvider>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Abacus dock - positioned to the right of the problem when in abacus mode and not in help mode */}
|
||||
{currentPart.type === 'abacus' && !showHelpOverlay && (
|
||||
<AbacusDock
|
||||
id="practice-abacus"
|
||||
columns={String(Math.abs(attempt.problem.answer)).length}
|
||||
interactive={true}
|
||||
showNumbers={false}
|
||||
animated={true}
|
||||
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',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</animated.div>
|
||||
</animated.div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -614,7 +614,8 @@ export function StartPracticeModal({
|
|||
top: '-8px',
|
||||
right: '-8px',
|
||||
minWidth: '22px',
|
||||
height: '22px',
|
||||
minHeight: '22px',
|
||||
aspectRatio: '1 / 1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
|
|
@ -622,8 +623,8 @@ export function StartPracticeModal({
|
|||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
backgroundColor: 'green.500',
|
||||
borderRadius: '11px',
|
||||
padding: '0 6px',
|
||||
borderRadius: '50%',
|
||||
padding: '2px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
|
||||
})}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,35 @@
|
|||
import type React from 'react'
|
||||
import { createContext, useContext, useState, useCallback } from 'react'
|
||||
|
||||
/**
|
||||
* Configuration for a docked abacus
|
||||
* Props align with AbacusReact from @soroban/abacus-react
|
||||
*/
|
||||
export interface DockConfig {
|
||||
/** The DOM element to render the abacus into */
|
||||
element: HTMLElement
|
||||
/** Optional identifier for debugging */
|
||||
id?: string
|
||||
/** Number of columns (default: 5) */
|
||||
columns?: number
|
||||
/** Whether the abacus is interactive (default: true) */
|
||||
interactive?: boolean
|
||||
/** Whether to show numbers below columns (default: true) */
|
||||
showNumbers?: boolean
|
||||
/** Whether to animate bead movements (default: true) */
|
||||
animated?: boolean
|
||||
/** Scale factor for the abacus (default: auto-fit to container) */
|
||||
scaleFactor?: number
|
||||
/** Whether the dock is currently visible in the viewport */
|
||||
isVisible?: boolean
|
||||
/** Controlled value - when provided, dock controls the abacus value */
|
||||
value?: number
|
||||
/** Default value for uncontrolled mode */
|
||||
defaultValue?: number
|
||||
/** Callback when value changes (for controlled mode) */
|
||||
onValueChange?: (newValue: number) => void
|
||||
}
|
||||
|
||||
interface MyAbacusContextValue {
|
||||
isOpen: boolean
|
||||
open: () => void
|
||||
|
|
@ -14,6 +43,20 @@ interface MyAbacusContextValue {
|
|||
/** Opt-in to show the abacus while in a game (games hide it by default) */
|
||||
showInGame: boolean
|
||||
setShowInGame: (show: boolean) => void
|
||||
/** Currently registered dock (if any) */
|
||||
dock: DockConfig | null
|
||||
/** Register a dock for the abacus to render into */
|
||||
registerDock: (config: DockConfig) => void
|
||||
/** Unregister a dock (should be called on unmount) */
|
||||
unregisterDock: (element: HTMLElement) => void
|
||||
/** Update dock visibility status */
|
||||
updateDockVisibility: (element: HTMLElement, isVisible: boolean) => void
|
||||
/** Whether the abacus is currently docked (user chose to dock it) */
|
||||
isDockedByUser: boolean
|
||||
/** Dock the abacus into the current dock */
|
||||
dockInto: () => void
|
||||
/** Undock the abacus (return to button mode) */
|
||||
undock: () => void
|
||||
}
|
||||
|
||||
const MyAbacusContext = createContext<MyAbacusContextValue | undefined>(undefined)
|
||||
|
|
@ -22,14 +65,67 @@ export function MyAbacusProvider({ children }: { children: React.ReactNode }) {
|
|||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [isHidden, setIsHidden] = useState(false)
|
||||
const [showInGame, setShowInGame] = useState(false)
|
||||
const [dock, setDock] = useState<DockConfig | null>(null)
|
||||
const [isDockedByUser, setIsDockedByUser] = useState(false)
|
||||
|
||||
const open = useCallback(() => setIsOpen(true), [])
|
||||
const close = useCallback(() => setIsOpen(false), [])
|
||||
const toggle = useCallback(() => setIsOpen((prev) => !prev), [])
|
||||
|
||||
const registerDock = useCallback((config: DockConfig) => {
|
||||
setDock(config)
|
||||
}, [])
|
||||
|
||||
const unregisterDock = useCallback((element: HTMLElement) => {
|
||||
setDock((current) => {
|
||||
if (current?.element === element) {
|
||||
// Also undock if this dock is being removed
|
||||
setIsDockedByUser(false)
|
||||
return null
|
||||
}
|
||||
return current
|
||||
})
|
||||
}, [])
|
||||
|
||||
const updateDockVisibility = useCallback((element: HTMLElement, isVisible: boolean) => {
|
||||
setDock((current) => {
|
||||
if (current?.element === element) {
|
||||
return { ...current, isVisible }
|
||||
}
|
||||
return current
|
||||
})
|
||||
}, [])
|
||||
|
||||
const dockInto = useCallback(() => {
|
||||
if (dock) {
|
||||
setIsDockedByUser(true)
|
||||
setIsOpen(false)
|
||||
}
|
||||
}, [dock])
|
||||
|
||||
const undock = useCallback(() => {
|
||||
setIsDockedByUser(false)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<MyAbacusContext.Provider
|
||||
value={{ isOpen, open, close, toggle, isHidden, setIsHidden, showInGame, setShowInGame }}
|
||||
value={{
|
||||
isOpen,
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
isHidden,
|
||||
setIsHidden,
|
||||
showInGame,
|
||||
setShowInGame,
|
||||
dock,
|
||||
registerDock,
|
||||
unregisterDock,
|
||||
updateDockVisibility,
|
||||
isDockedByUser,
|
||||
dockInto,
|
||||
undock,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MyAbacusContext.Provider>
|
||||
|
|
|
|||
Loading…
Reference in New Issue