feat(abacus): add smooth animated transitions for dock/undock
Implement FLIP-style animation for the abacus docking feature: - Measure viewport positions of button and dock using getBoundingClientRect - Use react-spring to animate position, size, scale, and border-radius - Add chromeOpacity spring value to smoothly fade button styling (background, border, shadow, backdrop-blur) during transitions - Animation layer renders as fixed-position overlay during transition - Docking: button chrome fades out as abacus flies to dock - Undocking: button chrome fades in as abacus returns to corner 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
5fb4751728
commit
2c832c7944
|
|
@ -1,17 +1,33 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useContext, useEffect, useState } from 'react'
|
import { animated, useSpring } from '@react-spring/web'
|
||||||
import { createPortal } from 'react-dom'
|
import { ABACUS_THEMES, AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||||
import { usePathname } from 'next/navigation'
|
import { usePathname } from 'next/navigation'
|
||||||
import { AbacusReact, useAbacusConfig, ABACUS_THEMES } from '@soroban/abacus-react'
|
import { useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||||
import { css } from '../../styled-system/css'
|
import { createPortal } from 'react-dom'
|
||||||
import { useMyAbacus } from '@/contexts/MyAbacusContext'
|
|
||||||
import { HomeHeroContext } from '@/contexts/HomeHeroContext'
|
import { HomeHeroContext } from '@/contexts/HomeHeroContext'
|
||||||
|
import { type DockAnimationState, useMyAbacus } from '@/contexts/MyAbacusContext'
|
||||||
import { useTheme } from '@/contexts/ThemeContext'
|
import { useTheme } from '@/contexts/ThemeContext'
|
||||||
|
import { css } from '../../styled-system/css'
|
||||||
|
|
||||||
export function MyAbacus() {
|
export function MyAbacus() {
|
||||||
const { isOpen, close, toggle, isHidden, showInGame, dock, isDockedByUser, dockInto, undock } =
|
const {
|
||||||
useMyAbacus()
|
isOpen,
|
||||||
|
close,
|
||||||
|
toggle,
|
||||||
|
isHidden,
|
||||||
|
showInGame,
|
||||||
|
dock,
|
||||||
|
isDockedByUser,
|
||||||
|
dockInto,
|
||||||
|
undock,
|
||||||
|
dockAnimationState,
|
||||||
|
buttonRef,
|
||||||
|
startDockAnimation,
|
||||||
|
completeDockAnimation,
|
||||||
|
startUndockAnimation,
|
||||||
|
completeUndockAnimation,
|
||||||
|
} = useMyAbacus()
|
||||||
const appConfig = useAbacusConfig()
|
const appConfig = useAbacusConfig()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
const { resolvedTheme } = useTheme()
|
const { resolvedTheme } = useTheme()
|
||||||
|
|
@ -20,6 +36,9 @@ export function MyAbacus() {
|
||||||
// Track dock container size for auto-scaling
|
// Track dock container size for auto-scaling
|
||||||
const [dockSize, setDockSize] = useState<{ width: number; height: number } | null>(null)
|
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)
|
||||||
|
|
||||||
// Sync with hero context if on home page
|
// Sync with hero context if on home page
|
||||||
const homeHeroContext = useContext(HomeHeroContext)
|
const homeHeroContext = useContext(HomeHeroContext)
|
||||||
const [localAbacusValue, setLocalAbacusValue] = useState(1234)
|
const [localAbacusValue, setLocalAbacusValue] = useState(1234)
|
||||||
|
|
@ -118,13 +137,165 @@ export function MyAbacus() {
|
||||||
|
|
||||||
const dockedScale = calculateDockedScale()
|
const dockedScale = calculateDockedScale()
|
||||||
|
|
||||||
|
// Sync local button ref with context's buttonRef
|
||||||
|
useEffect(() => {
|
||||||
|
if (buttonRef && localButtonRef.current) {
|
||||||
|
buttonRef.current = localButtonRef.current
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (buttonRef) {
|
||||||
|
buttonRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [buttonRef])
|
||||||
|
|
||||||
|
// Spring animation for dock transitions
|
||||||
|
// We animate: x, y, width, height, scale, opacity, borderRadius, chromeOpacity
|
||||||
|
// chromeOpacity controls the button "chrome" (border, background, shadow) - fades out when docking
|
||||||
|
const [springStyles, springApi] = useSpring(() => ({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
scale: 1,
|
||||||
|
opacity: 1,
|
||||||
|
borderRadius: 16,
|
||||||
|
chromeOpacity: 1, // 1 = full button styling, 0 = no border/bg/shadow (docked look)
|
||||||
|
config: { tension: 200, friction: 24 },
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Start dock animation when dockAnimationState changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dockAnimationState) return
|
||||||
|
|
||||||
|
const { phase, fromRect, toRect, fromScale, toScale } = dockAnimationState
|
||||||
|
|
||||||
|
// Set initial position
|
||||||
|
// chromeOpacity: 1 = button look (border/bg/shadow), 0 = docked look (clean)
|
||||||
|
springApi.set({
|
||||||
|
x: fromRect.x,
|
||||||
|
y: fromRect.y,
|
||||||
|
width: fromRect.width,
|
||||||
|
height: fromRect.height,
|
||||||
|
scale: fromScale,
|
||||||
|
opacity: 1,
|
||||||
|
borderRadius: phase === 'docking' ? 16 : 8,
|
||||||
|
chromeOpacity: phase === 'docking' ? 1 : 0, // Start with button look when docking, clean when undocking
|
||||||
|
})
|
||||||
|
|
||||||
|
// Animate to target position
|
||||||
|
springApi.start({
|
||||||
|
x: toRect.x,
|
||||||
|
y: toRect.y,
|
||||||
|
width: toRect.width,
|
||||||
|
height: toRect.height,
|
||||||
|
scale: toScale,
|
||||||
|
opacity: 1,
|
||||||
|
borderRadius: phase === 'docking' ? 8 : 16,
|
||||||
|
chromeOpacity: phase === 'docking' ? 0 : 1, // Fade out chrome when docking, fade in when undocking
|
||||||
|
config: { tension: 180, friction: 22 },
|
||||||
|
onRest: () => {
|
||||||
|
if (phase === 'docking') {
|
||||||
|
completeDockAnimation()
|
||||||
|
} else {
|
||||||
|
completeUndockAnimation()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, [dockAnimationState, springApi, completeDockAnimation, completeUndockAnimation])
|
||||||
|
|
||||||
|
// Handler to initiate dock animation
|
||||||
|
const handleDockClick = useCallback(() => {
|
||||||
|
if (!dock?.element || !localButtonRef.current) {
|
||||||
|
// Fallback to instant dock if we can't measure
|
||||||
|
dockInto()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Measure positions
|
||||||
|
const buttonRect = localButtonRef.current.getBoundingClientRect()
|
||||||
|
const dockRect = dock.element.getBoundingClientRect()
|
||||||
|
|
||||||
|
// Calculate scales - button shows at 0.35 scale, dock uses dockedScale
|
||||||
|
const buttonScale = 0.35
|
||||||
|
const targetScale = dockedScale
|
||||||
|
|
||||||
|
const animState: DockAnimationState = {
|
||||||
|
phase: 'docking',
|
||||||
|
fromRect: {
|
||||||
|
x: buttonRect.x,
|
||||||
|
y: buttonRect.y,
|
||||||
|
width: buttonRect.width,
|
||||||
|
height: buttonRect.height,
|
||||||
|
},
|
||||||
|
toRect: {
|
||||||
|
x: dockRect.x,
|
||||||
|
y: dockRect.y,
|
||||||
|
width: dockRect.width,
|
||||||
|
height: dockRect.height,
|
||||||
|
},
|
||||||
|
fromScale: buttonScale,
|
||||||
|
toScale: targetScale,
|
||||||
|
}
|
||||||
|
|
||||||
|
startDockAnimation(animState)
|
||||||
|
}, [dock, dockInto, dockedScale, startDockAnimation])
|
||||||
|
|
||||||
|
// Handler to initiate undock animation
|
||||||
|
const handleUndockClick = useCallback(() => {
|
||||||
|
if (!dock?.element) {
|
||||||
|
// Fallback to instant undock if we can't measure dock position
|
||||||
|
undock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Measure dock position (source)
|
||||||
|
const dockRect = dock.element.getBoundingClientRect()
|
||||||
|
|
||||||
|
// Calculate target button position (we don't need the ref - button has known fixed position)
|
||||||
|
// Button is fixed at bottom-right with some margin
|
||||||
|
const viewportWidth = window.innerWidth
|
||||||
|
const viewportHeight = window.innerHeight
|
||||||
|
const buttonSize = viewportWidth >= 768 ? 100 : 80
|
||||||
|
const margin = viewportWidth >= 768 ? 24 : 16
|
||||||
|
const buttonX = viewportWidth - buttonSize - margin
|
||||||
|
const buttonY = viewportHeight - buttonSize - margin
|
||||||
|
|
||||||
|
const buttonScale = 0.35
|
||||||
|
const dockScale = dockedScale
|
||||||
|
|
||||||
|
const animState: DockAnimationState = {
|
||||||
|
phase: 'undocking',
|
||||||
|
fromRect: {
|
||||||
|
x: dockRect.x,
|
||||||
|
y: dockRect.y,
|
||||||
|
width: dockRect.width,
|
||||||
|
height: dockRect.height,
|
||||||
|
},
|
||||||
|
toRect: {
|
||||||
|
x: buttonX,
|
||||||
|
y: buttonY,
|
||||||
|
width: buttonSize,
|
||||||
|
height: buttonSize,
|
||||||
|
},
|
||||||
|
fromScale: dockScale,
|
||||||
|
toScale: buttonScale,
|
||||||
|
}
|
||||||
|
|
||||||
|
startUndockAnimation(animState)
|
||||||
|
}, [dock, undock, dockedScale, startUndockAnimation])
|
||||||
|
|
||||||
|
// Check if we're currently animating
|
||||||
|
const isAnimating = dockAnimationState !== null
|
||||||
|
|
||||||
// Hide completely when:
|
// Hide completely when:
|
||||||
// 1. isHidden is true (e.g., virtual keyboard is shown on non-game pages)
|
// 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
|
// 2. On a game route and the game hasn't opted in to show it
|
||||||
// 3. NOT docked (docked abacus should always show)
|
// 3. NOT docked (docked abacus should always show)
|
||||||
|
// 4. NOT animating (animation layer should show)
|
||||||
// Still allow open state to work (user explicitly opened it)
|
// Still allow open state to work (user explicitly opened it)
|
||||||
// NOTE: This must come after all hooks to follow React's rules of hooks
|
// NOTE: This must come after all hooks to follow React's rules of hooks
|
||||||
if (!isOpen && !isDocked && (isHidden || (isOnGameRoute && !showInGame))) {
|
if (!isOpen && !isDocked && !isAnimating && (isHidden || (isOnGameRoute && !showInGame))) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -207,7 +378,7 @@ export function MyAbacus() {
|
||||||
data-action="undock-abacus"
|
data-action="undock-abacus"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
undock()
|
handleUndockClick()
|
||||||
}}
|
}}
|
||||||
title="Undock abacus"
|
title="Undock abacus"
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -286,12 +457,13 @@ export function MyAbacus() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Non-docked modes: hero, button, open */}
|
{/* Non-docked modes: hero, button, open */}
|
||||||
{!isDocked && (
|
{!isDocked && !isAnimating && (
|
||||||
<div
|
<div
|
||||||
|
ref={localButtonRef}
|
||||||
data-component="my-abacus"
|
data-component="my-abacus"
|
||||||
data-mode={isOpen ? 'open' : isHeroMode ? 'hero' : 'button'}
|
data-mode={isOpen ? 'open' : isHeroMode ? 'hero' : 'button'}
|
||||||
data-dockable={isDockable ? 'true' : undefined}
|
data-dockable={isDockable ? 'true' : undefined}
|
||||||
onClick={isOpen || isHeroMode ? undefined : isDockable ? dockInto : toggle}
|
onClick={isOpen || isHeroMode ? undefined : isDockable ? handleDockClick : toggle}
|
||||||
className={css({
|
className={css({
|
||||||
position: isHeroMode ? 'absolute' : 'fixed',
|
position: isHeroMode ? 'absolute' : 'fixed',
|
||||||
zIndex: 102,
|
zIndex: 102,
|
||||||
|
|
@ -416,6 +588,66 @@ export function MyAbacus() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Animation layer - fixed position overlay during dock/undock transitions */}
|
||||||
|
{isAnimating && dockAnimationState && (
|
||||||
|
<animated.div
|
||||||
|
data-component="my-abacus-animation-layer"
|
||||||
|
data-animation-phase={dockAnimationState.phase}
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
left: springStyles.x,
|
||||||
|
top: springStyles.y,
|
||||||
|
width: springStyles.width,
|
||||||
|
height: springStyles.height,
|
||||||
|
zIndex: 103,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
// Animate background opacity based on chromeOpacity
|
||||||
|
backgroundColor: springStyles.chromeOpacity.to((o) =>
|
||||||
|
isDark ? `rgba(0, 0, 0, ${0.7 * o})` : `rgba(255, 255, 255, ${0.9 * o})`
|
||||||
|
),
|
||||||
|
backdropFilter: springStyles.chromeOpacity.to((o) => `blur(${8 * o}px)`),
|
||||||
|
WebkitBackdropFilter: springStyles.chromeOpacity.to((o) => `blur(${8 * o}px)`),
|
||||||
|
// Animate border opacity
|
||||||
|
border: springStyles.chromeOpacity.to((o) =>
|
||||||
|
isDark
|
||||||
|
? `3px solid rgba(251, 191, 36, ${0.5 * o})`
|
||||||
|
: `3px solid rgba(251, 191, 36, ${0.6 * o})`
|
||||||
|
),
|
||||||
|
// Animate shadow opacity
|
||||||
|
boxShadow: springStyles.chromeOpacity.to((o) =>
|
||||||
|
isDark
|
||||||
|
? `0 8px 32px rgba(251, 191, 36, ${0.4 * o})`
|
||||||
|
: `0 8px 32px rgba(251, 191, 36, ${0.5 * o})`
|
||||||
|
),
|
||||||
|
borderRadius: springStyles.borderRadius,
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Inner container with scale transform */}
|
||||||
|
<animated.div
|
||||||
|
style={{
|
||||||
|
transform: springStyles.scale.to((s) => `scale(${s})`),
|
||||||
|
transformOrigin: 'center center',
|
||||||
|
filter: 'drop-shadow(0 4px 12px rgba(251, 191, 36, 0.2))',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AbacusReact
|
||||||
|
key="animating"
|
||||||
|
value={dock?.value ?? abacusValue}
|
||||||
|
columns={dock?.columns ?? 5}
|
||||||
|
beadShape={appConfig.beadShape}
|
||||||
|
showNumbers={false}
|
||||||
|
interactive={false}
|
||||||
|
animated={false}
|
||||||
|
customStyles={trophyStyles}
|
||||||
|
/>
|
||||||
|
</animated.div>
|
||||||
|
</animated.div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Keyframes for animations */}
|
{/* Keyframes for animations */}
|
||||||
<style
|
<style
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,14 @@
|
||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
import { createContext, useContext, useState, useCallback } from 'react'
|
import {
|
||||||
|
createContext,
|
||||||
|
type MutableRefObject,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for a docked abacus
|
* Configuration for a docked abacus
|
||||||
|
|
@ -32,6 +39,21 @@ export interface DockConfig {
|
||||||
onValueChange?: (newValue: number) => void
|
onValueChange?: (newValue: number) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Animation state for dock transitions
|
||||||
|
*/
|
||||||
|
export interface DockAnimationState {
|
||||||
|
phase: 'docking' | 'undocking'
|
||||||
|
/** Viewport rect of the starting position */
|
||||||
|
fromRect: { x: number; y: number; width: number; height: number }
|
||||||
|
/** Viewport rect of the target position */
|
||||||
|
toRect: { x: number; y: number; width: number; height: number }
|
||||||
|
/** Scale of the abacus at start */
|
||||||
|
fromScale: number
|
||||||
|
/** Scale of the abacus at end */
|
||||||
|
toScale: number
|
||||||
|
}
|
||||||
|
|
||||||
interface MyAbacusContextValue {
|
interface MyAbacusContextValue {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
open: () => void
|
open: () => void
|
||||||
|
|
@ -57,6 +79,18 @@ interface MyAbacusContextValue {
|
||||||
dockInto: () => void
|
dockInto: () => void
|
||||||
/** Undock the abacus (return to button mode) */
|
/** Undock the abacus (return to button mode) */
|
||||||
undock: () => void
|
undock: () => void
|
||||||
|
/** Current dock animation state (null when not animating) */
|
||||||
|
dockAnimationState: DockAnimationState | null
|
||||||
|
/** Ref to the button element for measuring position */
|
||||||
|
buttonRef: MutableRefObject<HTMLDivElement | null>
|
||||||
|
/** Start the docking animation (called internally by MyAbacus when it measures rects) */
|
||||||
|
startDockAnimation: (animState: DockAnimationState) => void
|
||||||
|
/** Complete the docking animation (switches to docked state) */
|
||||||
|
completeDockAnimation: () => void
|
||||||
|
/** Start the undocking animation (called internally by MyAbacus when it measures rects) */
|
||||||
|
startUndockAnimation: (animState: DockAnimationState) => void
|
||||||
|
/** Complete the undocking animation (switches to button state) */
|
||||||
|
completeUndockAnimation: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const MyAbacusContext = createContext<MyAbacusContextValue | undefined>(undefined)
|
const MyAbacusContext = createContext<MyAbacusContextValue | undefined>(undefined)
|
||||||
|
|
@ -67,6 +101,8 @@ export function MyAbacusProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [showInGame, setShowInGame] = useState(false)
|
const [showInGame, setShowInGame] = useState(false)
|
||||||
const [dock, setDock] = useState<DockConfig | null>(null)
|
const [dock, setDock] = useState<DockConfig | null>(null)
|
||||||
const [isDockedByUser, setIsDockedByUser] = useState(false)
|
const [isDockedByUser, setIsDockedByUser] = useState(false)
|
||||||
|
const [dockAnimationState, setDockAnimationState] = useState<DockAnimationState | null>(null)
|
||||||
|
const buttonRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
const open = useCallback(() => setIsOpen(true), [])
|
const open = useCallback(() => setIsOpen(true), [])
|
||||||
const close = useCallback(() => setIsOpen(false), [])
|
const close = useCallback(() => setIsOpen(false), [])
|
||||||
|
|
@ -107,6 +143,26 @@ export function MyAbacusProvider({ children }: { children: React.ReactNode }) {
|
||||||
setIsDockedByUser(false)
|
setIsDockedByUser(false)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Animation callbacks
|
||||||
|
const startDockAnimation = useCallback((animState: DockAnimationState) => {
|
||||||
|
setDockAnimationState(animState)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const completeDockAnimation = useCallback(() => {
|
||||||
|
setDockAnimationState(null)
|
||||||
|
setIsDockedByUser(true)
|
||||||
|
setIsOpen(false)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const startUndockAnimation = useCallback((animState: DockAnimationState) => {
|
||||||
|
setDockAnimationState(animState)
|
||||||
|
setIsDockedByUser(false) // Remove from portal immediately so we can animate the overlay
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const completeUndockAnimation = useCallback(() => {
|
||||||
|
setDockAnimationState(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MyAbacusContext.Provider
|
<MyAbacusContext.Provider
|
||||||
value={{
|
value={{
|
||||||
|
|
@ -125,6 +181,12 @@ export function MyAbacusProvider({ children }: { children: React.ReactNode }) {
|
||||||
isDockedByUser,
|
isDockedByUser,
|
||||||
dockInto,
|
dockInto,
|
||||||
undock,
|
undock,
|
||||||
|
dockAnimationState,
|
||||||
|
buttonRef,
|
||||||
|
startDockAnimation,
|
||||||
|
completeDockAnimation,
|
||||||
|
startUndockAnimation,
|
||||||
|
completeUndockAnimation,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue