Compare commits

...

4 Commits

Author SHA1 Message Date
semantic-release-bot
fe1e8979c8 chore(release): 4.62.0 [skip ci]
## [4.62.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.61.3...v4.62.0) (2025-10-21)

### Features

* **flashcards:** add dynamic shadow based on drag speed ([92148a4](92148a4cf8))
2025-10-21 15:52:28 +00:00
Thomas Hallock
92148a4cf8 feat(flashcards): add dynamic shadow based on drag speed
First physics enhancement - shadow changes based on how fast you drag:
- Tracks drag velocity (distance/time) during pointer move
- Shadow grows larger and darker with faster dragging
- Base: 8px offset, 24px blur, 0.3 opacity
- Fast: 32px offset, 64px blur, 0.6 opacity
- Smooth decay when released

Console logging included:
- [Shadow] logs on drag start/release
- Speed/distance/time logged during drag (throttled to ~100ms)

Test: Drag cards slowly vs fast and watch shadow change

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-21 10:51:08 -05:00
semantic-release-bot
7088a7096a chore(release): 4.61.3 [skip ci]
## [4.61.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.61.2...v4.61.3) (2025-10-21)

### Code Refactoring

* **flashcards:** completely rewrite drag-drop with simple approach ([5f0ad14](5f0ad14133))
2025-10-21 15:45:03 +00:00
Thomas Hallock
5f0ad14133 refactor(flashcards): completely rewrite drag-drop with simple approach
Replaced complex react-spring + useDrag implementation with simple
PointerEvents-based drag and drop:

- Uses native onPointerDown/Move/Up events
- Tracks position with useState (no animation library)
- Cards stay exactly where dropped (no physics or snap-back)
- Simple scale-up effect while dragging
- Much more predictable and maintainable

Removed dependencies on:
- @react-spring/web
- @use-gesture/react
- Complex velocity tracking and decay physics
- Transform-origin calculations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-21 10:43:43 -05:00
3 changed files with 128 additions and 175 deletions

View File

@@ -1,3 +1,17 @@
## [4.62.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.61.3...v4.62.0) (2025-10-21)
### Features
* **flashcards:** add dynamic shadow based on drag speed ([92148a4](https://github.com/antialias/soroban-abacus-flashcards/commit/92148a4cf87e828ba2e5ec1740fb51d9667c1d73))
## [4.61.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.61.2...v4.61.3) (2025-10-21)
### Code Refactoring
* **flashcards:** completely rewrite drag-drop with simple approach ([5f0ad14](https://github.com/antialias/soroban-abacus-flashcards/commit/5f0ad14133340d073e861f5721cb48e1abab03ff))
## [4.61.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.61.1...v4.61.2) (2025-10-21)

View File

@@ -1,9 +1,7 @@
'use client'
import { AbacusReact } from '@soroban/abacus-react'
import { useDrag } from '@use-gesture/react'
import { useEffect, useRef, useState } from 'react'
import { animated, config, to, useSpring } from '@react-spring/web'
import { css } from '../../styled-system/css'
interface Flashcard {
@@ -16,12 +14,11 @@ interface Flashcard {
}
/**
* InteractiveFlashcards - A fun, physics-based flashcard display
* Users can drag and throw flashcards around with realistic momentum
* InteractiveFlashcards - A fun flashcard display where you can drag cards around
* Cards stay where you drop them - simple and intuitive
*/
export function InteractiveFlashcards() {
const containerRef = useRef<HTMLDivElement>(null)
// Generate 8-15 random flashcards (client-side only to avoid hydration errors)
const [cards, setCards] = useState<Flashcard[]>([])
useEffect(() => {
@@ -95,204 +92,146 @@ interface DraggableCardProps {
}
function DraggableCard({ card }: DraggableCardProps) {
// Track the card's current position in state (separate from the animation values)
const currentPositionRef = useRef({
x: card.initialX,
y: card.initialY,
rotation: card.initialRotation,
})
const [{ x, y, rotation, scale }, api] = useSpring(() => ({
x: card.initialX,
y: card.initialY,
rotation: card.initialRotation,
scale: 1,
config: config.wobbly,
}))
// Track position - starts at initial, updates when dragged
const [position, setPosition] = useState({ x: card.initialX, y: card.initialY })
const [rotation] = useState(card.initialRotation)
const [zIndex, setZIndex] = useState(card.zIndex)
const cardRef = useRef<HTMLDivElement>(null)
const dragOffsetRef = useRef({ x: 0, y: 0 })
const lastVelocityRef = useRef({ vx: 0, vy: 0 })
const velocityHistoryRef = useRef<Array<{ vx: number; vy: number }>>([])
const [transformOrigin, setTransformOrigin] = useState('center center')
const [isDragging, setIsDragging] = useState(false)
const [dragSpeed, setDragSpeed] = useState(0) // Speed for dynamic shadow
const bind = useDrag(
({ down, movement: [mx, my], velocity: [vx, vy], direction: [dx, dy], first, xy }) => {
// Bring card to front when dragging
if (down) {
setZIndex(1000)
// Track drag state
const dragStartRef = useRef<{ x: number; y: number; cardX: number; cardY: number } | null>(null)
const lastMoveTimeRef = useRef<number>(0)
const lastMovePositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 })
// Calculate drag offset from card center on first touch
if (first && cardRef.current) {
const cardRect = cardRef.current.getBoundingClientRect()
const cardWidth = cardRect.width
const cardHeight = cardRect.height
const handlePointerDown = (e: React.PointerEvent) => {
setIsDragging(true)
setZIndex(1000) // Bring to front
setDragSpeed(0)
// xy is in viewport coordinates, convert to position relative to card
const clickRelativeToCard = {
x: xy[0] - cardRect.left,
y: xy[1] - cardRect.top,
}
// Capture the pointer
e.currentTarget.setPointerCapture(e.pointerId)
// Calculate offset from card center
const cardCenterX = cardWidth / 2
const cardCenterY = cardHeight / 2
const offsetX = clickRelativeToCard.x - cardCenterX
const offsetY = clickRelativeToCard.y - cardCenterY
dragOffsetRef.current = { x: offsetX, y: offsetY }
// Convert offset to transform-origin (50% + offset as percentage of card size)
const originX = 50 + (offsetX / cardWidth) * 100
const originY = 50 + (offsetY / cardHeight) * 100
const transformOriginValue = `${originX}% ${originY}%`
console.log(
`Drag start: click at (${clickRelativeToCard.x.toFixed(0)}, ${clickRelativeToCard.y.toFixed(0)}) in card, offset from center: (${offsetX.toFixed(0)}, ${offsetY.toFixed(0)}), origin: ${transformOriginValue}`
)
setTransformOrigin(transformOriginValue)
velocityHistoryRef.current = []
}
// Smooth velocity by averaging last 3 frames
velocityHistoryRef.current.push({ vx, vy })
if (velocityHistoryRef.current.length > 3) {
velocityHistoryRef.current.shift()
}
const avgVx =
velocityHistoryRef.current.reduce((sum, v) => sum + v.vx, 0) /
velocityHistoryRef.current.length
const avgVy =
velocityHistoryRef.current.reduce((sum, v) => sum + v.vy, 0) /
velocityHistoryRef.current.length
// Calculate rotation based on smoothed velocity and drag offset
const velocityAngle = Math.atan2(avgVy, avgVx) * (180 / Math.PI)
const offsetAngle =
Math.atan2(dragOffsetRef.current.y, dragOffsetRef.current.x) * (180 / Math.PI)
// Card rotates to align with movement direction, offset by where we're grabbing
const targetRotation = velocityAngle - offsetAngle + 90
const speed = Math.sqrt(avgVx * avgVx + avgVy * avgVy)
// Store smoothed velocity for throw
lastVelocityRef.current = { vx: avgVx, vy: avgVy }
const finalRotation = speed > 0.01 ? targetRotation : currentPositionRef.current.rotation
api.start({
x: currentPositionRef.current.x + mx,
y: currentPositionRef.current.y + my,
scale: 1.1,
rotation: finalRotation,
immediate: (key) => key !== 'rotation', // Position immediate, rotation smooth
config: { tension: 200, friction: 30 }, // Smoother rotation spring
})
} else {
// On release, reset transform origin to center
setTransformOrigin('center center')
// Update current position to where the card was dropped
currentPositionRef.current.x = currentPositionRef.current.x + mx
currentPositionRef.current.y = currentPositionRef.current.y + my
// First, snap the spring to the dropped position immediately
api.set({
x: currentPositionRef.current.x,
y: currentPositionRef.current.y,
})
// On release, apply momentum with decay physics
const throwVelocityX = lastVelocityRef.current.vx * 1000
const throwVelocityY = lastVelocityRef.current.vy * 1000
// Calculate final rotation based on throw direction
const throwAngle = Math.atan2(throwVelocityY, throwVelocityX) * (180 / Math.PI)
// Start position decay and rotation/scale animations
api.start({
x: {
velocity: throwVelocityX,
config: { decay: true },
},
y: {
velocity: throwVelocityY,
config: { decay: true },
},
scale: {
value: 1,
config: config.wobbly,
},
rotation: {
value: throwAngle + 90, // Card aligns with throw direction
config: config.wobbly,
},
onChange: (result) => {
// Continue updating position as card settles with momentum
if (result.value.x !== undefined) {
currentPositionRef.current.x = result.value.x
}
if (result.value.y !== undefined) {
currentPositionRef.current.y = result.value.y
}
if (result.value.rotation !== undefined) {
currentPositionRef.current.rotation = result.value.rotation
}
},
})
}
},
{
// Prevent scrolling when dragging
preventDefault: true,
filterTaps: true,
// Record where the drag started (pointer position and card position)
dragStartRef.current = {
x: e.clientX,
y: e.clientY,
cardX: position.x,
cardY: position.y,
}
)
// Initialize velocity tracking
lastMoveTimeRef.current = Date.now()
lastMovePositionRef.current = { x: e.clientX, y: e.clientY }
console.log('[Shadow] Drag started, speed reset to 0')
}
const handlePointerMove = (e: React.PointerEvent) => {
if (!isDragging || !dragStartRef.current) return
// Calculate how far the pointer has moved since drag started
const deltaX = e.clientX - dragStartRef.current.x
const deltaY = e.clientY - dragStartRef.current.y
// Calculate velocity for dynamic shadow
const now = Date.now()
const timeDelta = now - lastMoveTimeRef.current
if (timeDelta > 0) {
// Distance moved since last frame
const distX = e.clientX - lastMovePositionRef.current.x
const distY = e.clientY - lastMovePositionRef.current.y
const distance = Math.sqrt(distX * distX + distY * distY)
// Speed in pixels per millisecond, then convert to reasonable scale
const speed = distance / timeDelta
const scaledSpeed = Math.min(speed * 100, 100) // Cap at 100 for reasonable shadow size
setDragSpeed(scaledSpeed)
// Log occasionally (every ~100ms) to avoid console spam
if (timeDelta > 100) {
console.log(
`[Shadow] Speed: ${scaledSpeed.toFixed(1)}, distance: ${distance.toFixed(0)}px, time: ${timeDelta}ms`
)
}
lastMoveTimeRef.current = now
lastMovePositionRef.current = { x: e.clientX, y: e.clientY }
}
// Update card position
setPosition({
x: dragStartRef.current.cardX + deltaX,
y: dragStartRef.current.cardY + deltaY,
})
}
const handlePointerUp = (e: React.PointerEvent) => {
setIsDragging(false)
dragStartRef.current = null
console.log('[Shadow] Drag released, speed decaying to 0')
// Gradually decay speed back to 0 for smooth shadow transition
const decayInterval = setInterval(() => {
setDragSpeed((prev) => {
const newSpeed = prev * 0.8 // Decay by 20% each frame
if (newSpeed < 1) {
clearInterval(decayInterval)
return 0
}
return newSpeed
})
}, 50) // Update every 50ms
// Release the pointer capture
e.currentTarget.releasePointerCapture(e.pointerId)
}
// Calculate dynamic shadow based on drag speed
// Base shadow: 0 8px 24px rgba(0, 0, 0, 0.3)
// Fast drag: 0 32px 64px rgba(0, 0, 0, 0.6)
const shadowY = 8 + (dragSpeed / 100) * 24 // 8px to 32px
const shadowBlur = 24 + (dragSpeed / 100) * 40 // 24px to 64px
const shadowOpacity = 0.3 + (dragSpeed / 100) * 0.3 // 0.3 to 0.6
const boxShadow = `0 ${shadowY}px ${shadowBlur}px rgba(0, 0, 0, ${shadowOpacity})`
return (
<animated.div
ref={cardRef}
{...bind()}
<div
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
style={{
position: 'absolute',
left: 0,
top: 0,
transform: to(
[x, y, rotation, scale],
(x, y, r, s) => `translate(${x}px, ${y}px) rotate(${r}deg) scale(${s})`
),
transformOrigin,
transform: `translate(${position.x}px, ${position.y}px) rotate(${rotation}deg) scale(${isDragging ? 1.05 : 1})`,
zIndex,
touchAction: 'none',
cursor: 'grab',
cursor: isDragging ? 'grabbing' : 'grab',
transition: isDragging ? 'none' : 'transform 0.2s ease-out',
}}
className={css({
userSelect: 'none',
_active: {
cursor: 'grabbing',
},
})}
>
<div
style={{
boxShadow, // Dynamic shadow based on drag speed
}}
className={css({
bg: 'white',
rounded: 'lg',
p: '4',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.3)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '2',
minW: '120px',
border: '2px solid rgba(0, 0, 0, 0.1)',
transition: 'box-shadow 0.2s',
_hover: {
boxShadow: '0 12px 32px rgba(0, 0, 0, 0.4)',
},
transition: 'box-shadow 0.1s', // Quick transition for responsive feel
})}
>
{/* Abacus visualization */}
@@ -317,6 +256,6 @@ function DraggableCard({ card }: DraggableCardProps) {
{card.number}
</div>
</div>
</animated.div>
</div>
)
}

View File

@@ -1,6 +1,6 @@
{
"name": "soroban-monorepo",
"version": "4.61.2",
"version": "4.62.0",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [