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'
|
||||
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { animated, useSpring } from '@react-spring/web'
|
||||
import { ABACUS_THEMES, AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { AbacusReact, useAbacusConfig, ABACUS_THEMES } from '@soroban/abacus-react'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { useMyAbacus } from '@/contexts/MyAbacusContext'
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { HomeHeroContext } from '@/contexts/HomeHeroContext'
|
||||
import { type DockAnimationState, useMyAbacus } from '@/contexts/MyAbacusContext'
|
||||
import { useTheme } from '@/contexts/ThemeContext'
|
||||
import { css } from '../../styled-system/css'
|
||||
|
||||
export function MyAbacus() {
|
||||
const { isOpen, close, toggle, isHidden, showInGame, dock, isDockedByUser, dockInto, undock } =
|
||||
useMyAbacus()
|
||||
const {
|
||||
isOpen,
|
||||
close,
|
||||
toggle,
|
||||
isHidden,
|
||||
showInGame,
|
||||
dock,
|
||||
isDockedByUser,
|
||||
dockInto,
|
||||
undock,
|
||||
dockAnimationState,
|
||||
buttonRef,
|
||||
startDockAnimation,
|
||||
completeDockAnimation,
|
||||
startUndockAnimation,
|
||||
completeUndockAnimation,
|
||||
} = useMyAbacus()
|
||||
const appConfig = useAbacusConfig()
|
||||
const pathname = usePathname()
|
||||
const { resolvedTheme } = useTheme()
|
||||
|
|
@ -20,6 +36,9 @@ export function MyAbacus() {
|
|||
// 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)
|
||||
|
||||
// Sync with hero context if on home page
|
||||
const homeHeroContext = useContext(HomeHeroContext)
|
||||
const [localAbacusValue, setLocalAbacusValue] = useState(1234)
|
||||
|
|
@ -118,13 +137,165 @@ export function MyAbacus() {
|
|||
|
||||
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:
|
||||
// 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)
|
||||
// 4. NOT animating (animation layer should 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 && !isDocked && (isHidden || (isOnGameRoute && !showInGame))) {
|
||||
if (!isOpen && !isDocked && !isAnimating && (isHidden || (isOnGameRoute && !showInGame))) {
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
@ -207,7 +378,7 @@ export function MyAbacus() {
|
|||
data-action="undock-abacus"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
undock()
|
||||
handleUndockClick()
|
||||
}}
|
||||
title="Undock abacus"
|
||||
style={{
|
||||
|
|
@ -286,12 +457,13 @@ export function MyAbacus() {
|
|||
)}
|
||||
|
||||
{/* Non-docked modes: hero, button, open */}
|
||||
{!isDocked && (
|
||||
{!isDocked && !isAnimating && (
|
||||
<div
|
||||
ref={localButtonRef}
|
||||
data-component="my-abacus"
|
||||
data-mode={isOpen ? 'open' : isHeroMode ? 'hero' : 'button'}
|
||||
data-dockable={isDockable ? 'true' : undefined}
|
||||
onClick={isOpen || isHeroMode ? undefined : isDockable ? dockInto : toggle}
|
||||
onClick={isOpen || isHeroMode ? undefined : isDockable ? handleDockClick : toggle}
|
||||
className={css({
|
||||
position: isHeroMode ? 'absolute' : 'fixed',
|
||||
zIndex: 102,
|
||||
|
|
@ -416,6 +588,66 @@ export function MyAbacus() {
|
|||
</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 */}
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
'use client'
|
||||
|
||||
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
|
||||
|
|
@ -32,6 +39,21 @@ export interface DockConfig {
|
|||
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 {
|
||||
isOpen: boolean
|
||||
open: () => void
|
||||
|
|
@ -57,6 +79,18 @@ interface MyAbacusContextValue {
|
|||
dockInto: () => void
|
||||
/** Undock the abacus (return to button mode) */
|
||||
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)
|
||||
|
|
@ -67,6 +101,8 @@ export function MyAbacusProvider({ children }: { children: React.ReactNode }) {
|
|||
const [showInGame, setShowInGame] = useState(false)
|
||||
const [dock, setDock] = useState<DockConfig | null>(null)
|
||||
const [isDockedByUser, setIsDockedByUser] = useState(false)
|
||||
const [dockAnimationState, setDockAnimationState] = useState<DockAnimationState | null>(null)
|
||||
const buttonRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
const open = useCallback(() => setIsOpen(true), [])
|
||||
const close = useCallback(() => setIsOpen(false), [])
|
||||
|
|
@ -107,6 +143,26 @@ export function MyAbacusProvider({ children }: { children: React.ReactNode }) {
|
|||
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 (
|
||||
<MyAbacusContext.Provider
|
||||
value={{
|
||||
|
|
@ -125,6 +181,12 @@ export function MyAbacusProvider({ children }: { children: React.ReactNode }) {
|
|||
isDockedByUser,
|
||||
dockInto,
|
||||
undock,
|
||||
dockAnimationState,
|
||||
buttonRef,
|
||||
startDockAnimation,
|
||||
completeDockAnimation,
|
||||
startUndockAnimation,
|
||||
completeUndockAnimation,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
Loading…
Reference in New Issue