import React, { useState, useEffect, useRef, useCallback } from 'react'; import { motion, useAnimation, AnimatePresence } from 'framer-motion'; // Helper function for random numbers const random = (min, max) => Math.random() * (max - min) + min; /* ──────────────────────────────────────────────────────────────────────────── Document: single paper in the stack ------------------------------------------------------------------------*/ const Document = ({ id, yOffset, zIndex, onAnimationComplete, onSortMoveComplete, isTop, isBeingRemoved, isExploding, isSortingMove, targetYOffset, dimensions, }) => { const controls = useAnimation(); // Random explosion vectors (memoised per mount) const explodeX = useRef(random(-dimensions.width * 0.4, dimensions.width * 0.4)).current; const explodeY = useRef(random(-dimensions.height * 0.4, dimensions.height * 0.4)).current; const explodeRotate = useRef(random(-180, 180)).current; /* --------------------------------------------------------------------- Imperative animation orchestrator ---------------------------------------------------------------------*/ const animateDocument = useCallback(async () => { if (isExploding) { // Radial blast await controls.start({ x: explodeX, translateY: yOffset + explodeY, rotate: explodeRotate, opacity: 0, transition: { duration: 1.5, ease: 'easeOut' }, }); } else if (isBeingRemoved && isTop) { // Toss off-screen await controls.start({ x: random(dimensions.width * 0.3, dimensions.width * 0.6) * (Math.random() > 0.5 ? 1 : -1), translateY: -dimensions.height * 0.6, rotate: random(-90, 90), opacity: 0, transition: { duration: 0.8, ease: 'easeOut' }, }); onAnimationComplete?.(); } else if (isSortingMove) { /* --- Three-stage sideways shuffle --- */ const sideOffset = 250; const moveOutDuration = 0.3; const moveDownDuration = 0.4; const moveInDuration = 0.3; // Bring to front instantly await controls.start({ opacity: 1, x: 0, translateY: yOffset, rotate: 0, zIndex: zIndex + 10 }, { duration: 0 }); // 1. Sideways await controls.start({ x: sideOffset, transition: { duration: moveOutDuration, ease: 'easeOut' } }); // 2. Downwards to slot await controls.start({ translateY: targetYOffset, transition: { duration: moveDownDuration, ease: 'circIn' } }); // 3. Back in await controls.start({ x: 0, transition: { duration: moveInDuration, ease: 'easeOut' } }); onSortMoveComplete?.(id); } else { // Idle re-stacking movement await controls.start({ x: 0, translateY: yOffset, zIndex, rotate: 0, opacity: 1, transition: { duration: 0.5, ease: 'easeOut' }, }); onAnimationComplete?.(); } }, [controls, dimensions, explodeRotate, explodeX, explodeY, id, isBeingRemoved, isExploding, isSortingMove, isTop, targetYOffset, yOffset, zIndex, onAnimationComplete, onSortMoveComplete]); /* --------------------------------------------------------------------- Run animator only *after* the motion.div has mounted. ---------------------------------------------------------------------*/ useEffect(() => { const raf = requestAnimationFrame(animateDocument); return () => cancelAnimationFrame(raf); }, [animateDocument]); /* --------------------------------------------------------------------- Disable Framer‑Motion layout magic while this doc is under manual control (sorting, toss, or explosion). This prevents FM from measuring a fresh layout on every React re‑render – the culprit behind the "teleport‑back‑to‑centre" behaviour when the parent component updates. ---------------------------------------------------------------------*/ const enableLayout = !isSortingMove && !isBeingRemoved && !isExploding; return ( ); }; /* ──────────────────────────────────────────────────────────────────────────── DocumentStackBackground: orchestrates the pile of papers ------------------------------------------------------------------------*/ const DocumentStackBackground = ({ triggerExplode = false, triggerTossTop = false, onExplodeComplete, onTossComplete, }) => { const NUM_DOCUMENTS = 15; const STACK_OFFSET_Y = 30; const [documents, setDocuments] = useState([]); const [isAnimating, setIsAnimating] = useState(false); const [isExploded, setIsExploded] = useState(false); const intervalRef = useRef(null); const [movingDocInfo, setMovingDocInfo] = useState({ id: null, targetYOffset: 0 }); const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); const containerRef = useRef(null); /* ------------------------------------------------------------------- Helpers: size, (re)initialisation, periodic shuffle -------------------------------------------------------------------*/ useEffect(() => { const handleResize = () => { if (containerRef.current) { setDimensions({ width: containerRef.current.offsetWidth, height: containerRef.current.offsetHeight, }); } }; handleResize(); window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); /* (re)build the stack */ const initializeDocuments = useCallback(() => { if (dimensions.height === 0) return; const initialDocs = Array.from({ length: NUM_DOCUMENTS }).map((_, i) => ({ id: `doc-${i}-${Date.now()}`, yOffset: (i - NUM_DOCUMENTS / 2 + 0.5) * STACK_OFFSET_Y, zIndex: i, isBeingRemoved: false, })); setDocuments(initialDocs); setMovingDocInfo({ id: null, targetYOffset: 0 }); setIsExploded(false); setIsAnimating(false); }, [NUM_DOCUMENTS, STACK_OFFSET_Y, dimensions.height]); /* kick-off on mount */ useEffect(() => initializeDocuments(), [initializeDocuments]); /* --------------------------------------------------------------- Idle shuffling logic (unchanged) ----------------------------------------------------------------*/ const sortStep = useCallback(() => { if (isAnimating || documents.length < 2 || isExploded || movingDocInfo.id) return; const indexToRemove = Math.floor(random(1, documents.length - 1)); const docToMove = documents[indexToRemove]; if (!docToMove) return; const targetYOffset = (documents.length - 1 - documents.length / 2 + 0.5) * STACK_OFFSET_Y; setIsAnimating(true); setMovingDocInfo({ id: docToMove.id, targetYOffset }); }, [STACK_OFFSET_Y, documents, isAnimating, isExploded, movingDocInfo.id]); /* … interval helpers (unchanged) … */ const stopSortInterval = useCallback(() => { if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; } }, []); const startSortInterval = useCallback(() => { stopSortInterval(); if (!isAnimating && !isExploded) { intervalRef.current = setInterval(sortStep, 1000); } }, [sortStep, stopSortInterval, isAnimating, isExploded]); /* start-stop idle shuffle based on explosion and animation state */ useEffect(() => { if (!isExploded && !isAnimating) { startSortInterval(); } else { stopSortInterval(); } return stopSortInterval; }, [isExploded, isAnimating, startSortInterval, stopSortInterval]); /* ----------------------------------------------------------------- Toss-top trigger - Stop interval implicitly by setting isAnimating flag - Remove the doc from state *after* animation completes via onAnimationComplete - Interval restart is handled by the useEffect watching isAnimating ----------------------------------------------------------------*/ useEffect(() => { if (triggerTossTop && documents.length > 0 && !isAnimating && !isExploded) { setIsAnimating(true); setDocuments((prev) => { const next = [...prev]; if (next.length > 0) { next[next.length - 1] = { ...next[next.length - 1], isBeingRemoved: true }; } return next; }); } }, [triggerTossTop, documents.length, isAnimating, isExploded]); /* ----------------------------------------------------------------- Explode trigger – *do not* immediately clear the list so that the papers have something to animate! ----------------------------------------------------------------*/ useEffect(() => { if (triggerExplode && !isExploded && !isAnimating) { setIsAnimating(true); setIsExploded(true); setMovingDocInfo({ id: null, targetYOffset: 0 }); } }, [triggerExplode, isExploded, isAnimating]); /* once exploded, wait, then rebuild the stack */ useEffect(() => { if (!isExploded) return; const explosionDuration = 1500; const reinitDelay = 1000; const tid = setTimeout(() => { initializeDocuments(); onExplodeComplete?.(); }, explosionDuration + reinitDelay); return () => clearTimeout(tid); }, [isExploded, initializeDocuments, onExplodeComplete]); /* Callback for when *any* document's animation finishes */ const handleDocumentAnimationComplete = useCallback((docId, isRemoval = false) => { if (isRemoval) { setDocuments((prev) => prev.filter(doc => doc.id !== docId)); onTossComplete?.(); setIsAnimating(false); } }, [onTossComplete]); /* -----------------------------------------------------------------*/ return (
{documents.map((doc, idx) => ( handleDocumentAnimationComplete(doc.id, doc.isBeingRemoved)} onSortMoveComplete={(id) => { setDocuments((prev) => { const i = prev.findIndex((d) => d.id === id); if (i === -1) return prev; const copy = [...prev]; const [moved] = copy.splice(i, 1); copy.push(moved); return copy.map((d, j) => ({ ...d, yOffset: (j - copy.length / 2 + 0.5) * STACK_OFFSET_Y, zIndex: j, })); }); setMovingDocInfo({ id: null, targetYOffset: 0 }); setIsAnimating(false); }} dimensions={dimensions} /> ))}
); }; // Wrap the component with React.memo const MemoizedDocumentStackBackground = React.memo(DocumentStackBackground); export default MemoizedDocumentStackBackground;