|
|
|
|
@@ -81,7 +81,7 @@ export function InteractiveFlashcards() {
|
|
|
|
|
})}
|
|
|
|
|
>
|
|
|
|
|
{cards.map((card) => (
|
|
|
|
|
<DraggableCard key={card.id} card={card} />
|
|
|
|
|
<DraggableCard key={card.id} card={card} containerRef={containerRef} />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
@@ -89,21 +89,30 @@ export function InteractiveFlashcards() {
|
|
|
|
|
|
|
|
|
|
interface DraggableCardProps {
|
|
|
|
|
card: Flashcard
|
|
|
|
|
containerRef: React.RefObject<HTMLDivElement>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function DraggableCard({ card }: DraggableCardProps) {
|
|
|
|
|
function DraggableCard({ card, containerRef }: DraggableCardProps) {
|
|
|
|
|
// Track position - starts at initial, updates when dragged
|
|
|
|
|
const [position, setPosition] = useState({ x: card.initialX, y: card.initialY })
|
|
|
|
|
const [rotation] = useState(card.initialRotation)
|
|
|
|
|
const [rotation, setRotation] = useState(card.initialRotation) // Now dynamic!
|
|
|
|
|
const [zIndex, setZIndex] = useState(card.zIndex)
|
|
|
|
|
const [isDragging, setIsDragging] = useState(false)
|
|
|
|
|
const [dragSpeed, setDragSpeed] = useState(0) // Speed for dynamic shadow
|
|
|
|
|
|
|
|
|
|
// Track drag state
|
|
|
|
|
const dragStartRef = useRef<{ x: number; y: number; cardX: number; cardY: number } | null>(null)
|
|
|
|
|
const grabOffsetRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 }) // Offset from card center where grabbed
|
|
|
|
|
const baseRotationRef = useRef(card.initialRotation) // Starting rotation
|
|
|
|
|
const lastMoveTimeRef = useRef<number>(0)
|
|
|
|
|
const lastMovePositionRef = useRef<{ x: number; y: number }>({ x: 0, y: 0 })
|
|
|
|
|
const lastLogTimeRef = useRef<number>(0) // Separate throttling for logging
|
|
|
|
|
const cardRef = useRef<HTMLDivElement>(null) // Reference to card element
|
|
|
|
|
|
|
|
|
|
const handlePointerDown = (e: React.PointerEvent) => {
|
|
|
|
|
setIsDragging(true)
|
|
|
|
|
setZIndex(1000) // Bring to front
|
|
|
|
|
setDragSpeed(0)
|
|
|
|
|
|
|
|
|
|
// Capture the pointer
|
|
|
|
|
e.currentTarget.setPointerCapture(e.pointerId)
|
|
|
|
|
@@ -115,6 +124,31 @@ function DraggableCard({ card }: DraggableCardProps) {
|
|
|
|
|
cardX: position.x,
|
|
|
|
|
cardY: position.y,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate grab offset from card center
|
|
|
|
|
if (cardRef.current) {
|
|
|
|
|
const rect = cardRef.current.getBoundingClientRect()
|
|
|
|
|
const cardCenterX = rect.left + rect.width / 2
|
|
|
|
|
const cardCenterY = rect.top + rect.height / 2
|
|
|
|
|
grabOffsetRef.current = {
|
|
|
|
|
x: e.clientX - cardCenterX,
|
|
|
|
|
y: e.clientY - cardCenterY,
|
|
|
|
|
}
|
|
|
|
|
console.log(
|
|
|
|
|
`[GrabPoint] Grabbed at offset: (${grabOffsetRef.current.x.toFixed(0)}, ${grabOffsetRef.current.y.toFixed(0)})px from center`
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Store the current rotation as the base for this drag
|
|
|
|
|
baseRotationRef.current = rotation
|
|
|
|
|
|
|
|
|
|
// Initialize velocity tracking
|
|
|
|
|
const now = Date.now()
|
|
|
|
|
lastMoveTimeRef.current = now
|
|
|
|
|
lastMovePositionRef.current = { x: e.clientX, y: e.clientY }
|
|
|
|
|
lastLogTimeRef.current = now
|
|
|
|
|
|
|
|
|
|
console.log('[Shadow] Drag started, speed reset to 0')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handlePointerMove = (e: React.PointerEvent) => {
|
|
|
|
|
@@ -124,10 +158,95 @@ function DraggableCard({ card }: DraggableCardProps) {
|
|
|
|
|
const deltaX = e.clientX - dragStartRef.current.x
|
|
|
|
|
const deltaY = e.clientY - dragStartRef.current.y
|
|
|
|
|
|
|
|
|
|
// Update card position
|
|
|
|
|
// 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 ~200ms) to avoid console spam
|
|
|
|
|
const timeSinceLastLog = now - lastLogTimeRef.current
|
|
|
|
|
if (timeSinceLastLog > 200) {
|
|
|
|
|
console.log(
|
|
|
|
|
`[Shadow] Speed: ${scaledSpeed.toFixed(1)}, distance: ${distance.toFixed(0)}px, timeDelta: ${timeDelta}ms`
|
|
|
|
|
)
|
|
|
|
|
lastLogTimeRef.current = now
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
lastMoveTimeRef.current = now
|
|
|
|
|
lastMovePositionRef.current = { x: e.clientX, y: e.clientY }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Calculate rotation based on grab point physics
|
|
|
|
|
// Cross product of grab offset and drag direction determines rotation
|
|
|
|
|
// If grabbed on left and dragged right → clockwise rotation
|
|
|
|
|
// If grabbed on right and dragged left → counter-clockwise rotation
|
|
|
|
|
const crossProduct = grabOffsetRef.current.x * deltaY - grabOffsetRef.current.y * deltaX
|
|
|
|
|
const rotationInfluence = crossProduct / 500 // Reduced scale factor for more visible rotation
|
|
|
|
|
const newRotation = baseRotationRef.current + rotationInfluence
|
|
|
|
|
|
|
|
|
|
// Clamp rotation to prevent excessive spinning
|
|
|
|
|
const clampedRotation = Math.max(-45, Math.min(45, newRotation))
|
|
|
|
|
setRotation(clampedRotation)
|
|
|
|
|
|
|
|
|
|
// Log rotation changes occasionally (same throttle as shadow logging)
|
|
|
|
|
const timeSinceLastLog = now - lastLogTimeRef.current
|
|
|
|
|
if (timeSinceLastLog > 200) {
|
|
|
|
|
console.log(
|
|
|
|
|
`[GrabPoint] Rotation: ${clampedRotation.toFixed(1)}° (influence: ${rotationInfluence.toFixed(1)}°, cross: ${crossProduct.toFixed(0)})`
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Update card position - keep grab point under cursor while rotating
|
|
|
|
|
// Calculate the rotated grab offset
|
|
|
|
|
const rotationRad = (clampedRotation * Math.PI) / 180
|
|
|
|
|
const cosRot = Math.cos(rotationRad)
|
|
|
|
|
const sinRot = Math.sin(rotationRad)
|
|
|
|
|
|
|
|
|
|
// Rotate the grab offset by the current rotation angle
|
|
|
|
|
const rotatedGrabX = grabOffsetRef.current.x * cosRot - grabOffsetRef.current.y * sinRot
|
|
|
|
|
const rotatedGrabY = grabOffsetRef.current.x * sinRot + grabOffsetRef.current.y * cosRot
|
|
|
|
|
|
|
|
|
|
// Get container bounds for coordinate conversion
|
|
|
|
|
if (!containerRef.current || !cardRef.current) {
|
|
|
|
|
// Fallback to simple delta if refs not ready
|
|
|
|
|
setPosition({
|
|
|
|
|
x: dragStartRef.current.cardX + deltaX,
|
|
|
|
|
y: dragStartRef.current.cardY + deltaY,
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const containerRect = containerRef.current.getBoundingClientRect()
|
|
|
|
|
const cardRect = cardRef.current.getBoundingClientRect()
|
|
|
|
|
|
|
|
|
|
// Current cursor position in viewport space
|
|
|
|
|
const cursorViewportX = e.clientX
|
|
|
|
|
const cursorViewportY = e.clientY
|
|
|
|
|
|
|
|
|
|
// Card center should be at: cursor - rotated grab offset (viewport space)
|
|
|
|
|
const cardCenterViewportX = cursorViewportX - rotatedGrabX
|
|
|
|
|
const cardCenterViewportY = cursorViewportY - rotatedGrabY
|
|
|
|
|
|
|
|
|
|
// Convert card center from viewport space to container space
|
|
|
|
|
const cardCenterContainerX = cardCenterViewportX - containerRect.left
|
|
|
|
|
const cardCenterContainerY = cardCenterViewportY - containerRect.top
|
|
|
|
|
|
|
|
|
|
// position.x/y represents translate() which positions the top-left corner
|
|
|
|
|
// So we need: top-left = center - (width/2, height/2)
|
|
|
|
|
setPosition({
|
|
|
|
|
x: dragStartRef.current.cardX + deltaX,
|
|
|
|
|
y: dragStartRef.current.cardY + deltaY,
|
|
|
|
|
x: cardCenterContainerX - cardRect.width / 2,
|
|
|
|
|
y: cardCenterContainerY - cardRect.height / 2,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -135,12 +254,38 @@ function DraggableCard({ card }: DraggableCardProps) {
|
|
|
|
|
setIsDragging(false)
|
|
|
|
|
dragStartRef.current = null
|
|
|
|
|
|
|
|
|
|
console.log('[Shadow] Drag released, speed decaying to 0')
|
|
|
|
|
console.log(
|
|
|
|
|
`[GrabPoint] Final rotation: ${rotation.toFixed(1)}° (base was ${baseRotationRef.current.toFixed(1)}°)`
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// 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 (
|
|
|
|
|
<div
|
|
|
|
|
ref={cardRef}
|
|
|
|
|
onPointerDown={handlePointerDown}
|
|
|
|
|
onPointerMove={handlePointerMove}
|
|
|
|
|
onPointerUp={handlePointerUp}
|
|
|
|
|
@@ -159,20 +304,20 @@ function DraggableCard({ card }: DraggableCardProps) {
|
|
|
|
|
})}
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
style={{
|
|
|
|
|
boxShadow, // Dynamic shadow based on drag speed
|
|
|
|
|
}}
|
|
|
|
|
className={css({
|
|
|
|
|
bg: 'white',
|
|
|
|
|
rounded: 'lg',
|
|
|
|
|
p: '4',
|
|
|
|
|
boxShadow: isDragging
|
|
|
|
|
? '0 16px 48px rgba(0, 0, 0, 0.5)'
|
|
|
|
|
: '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',
|
|
|
|
|
transition: 'box-shadow 0.1s', // Quick transition for responsive feel
|
|
|
|
|
})}
|
|
|
|
|
>
|
|
|
|
|
{/* Abacus visualization */}
|
|
|
|
|
|