assistant/renderer/components/MotionAssembly.tsx

59 lines
3.0 KiB
TypeScript

import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
import '../companion.css';
// A history card mounted again must not replay its delivery transition.
const revealedResults = new Set<string>();
export function MotionAssembly({ active, reducedMotion, revealKey, children }: { active: boolean; reducedMotion: boolean; revealKey?: string; children?: ReactNode }) {
const host = useRef<HTMLDivElement>(null);
const previous = useRef({ active, revealKey });
const [revealing, setRevealing] = useState(false);
const [visible, setVisible] = useState(!document.hidden);
useEffect(() => {
const before = previous.current;
previous.current = { active, revealKey };
if (active || reducedMotion) { setRevealing(false); return; }
if (before.active) {
const alreadyRevealed = revealKey ? revealedResults.has(revealKey) : false;
if (revealKey) {
revealedResults.add(revealKey);
if (revealedResults.size > 256) revealedResults.delete(revealedResults.values().next().value!);
}
setRevealing(!alreadyRevealed);
} else if (before.revealKey !== revealKey) setRevealing(false);
}, [active, reducedMotion, revealKey]);
useEffect(() => {
const element = host.current;
if (!element) return;
let inViewport = true;
const sync = () => setVisible(inViewport && !document.hidden);
const observer = new IntersectionObserver(entries => {
inViewport = entries.some(entry => entry.isIntersecting);
sync();
});
observer.observe(element);
document.addEventListener('visibilitychange', sync);
return () => { observer.disconnect(); document.removeEventListener('visibilitychange', sync); };
}, []);
return <div ref={host} className="motion-assembly" data-testid="motion-assembly" data-active={active}
data-playing={active && visible && !reducedMotion} data-revealing={revealing}
data-reduced-motion={reducedMotion} aria-busy={active}>
{active && <div className="motion-assembly-matrix" aria-hidden="true">
<div className="motion-assembly-grid">
{Array.from({ length: 50 }, (_, index) => {
const column = index % 10;
const row = Math.floor(index / 10);
return <span className="motion-assembly-tile" key={index} style={{ '--wave-delay': `${(column + row) * 70}ms` } as CSSProperties}>
<svg className="motion-assembly-face motion-assembly-front" viewBox="0 0 32 32"><polygon points="2,2 30,2 30,30 2,30" /><circle className="motion-assembly-node" cx="2" cy="2" r=".8" /><circle className="motion-assembly-node" cx="30" cy="30" r=".8" /></svg>
<svg className="motion-assembly-face motion-assembly-back" viewBox="0 0 32 32"><polygon points="2,2 30,2 30,30 2,30" /><circle className="motion-assembly-node" cx="2" cy="2" r=".8" /><circle className="motion-assembly-node" cx="30" cy="30" r=".8" /></svg>
</span>;
})}
</div>
</div>}
{children != null && <div className="motion-assembly-content" onAnimationEnd={() => setRevealing(false)}>{children}</div>}
</div>;
}