Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"fumadocs-core": "16.9.3",
"fumadocs-mdx": "15.0.10",
"fumadocs-ui": "16.9.3",
"gsap": "^3.15.0",
"lucide-react": "^1.17.0",
"posthog-js": "^1.376.6",
"radix-ui": "^1.4.3",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

277 changes: 277 additions & 0 deletions src/components/AnimatedSVG.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
/**
* AnimatedSVG Component
*
* A React component wrapper for SVG animations using GSAP.
* Provides an easy way to add animations to SVG diagrams in Docusaurus.
*
* @example
* ```tsx
* import AnimatedSVG from '@site/src/components/AnimatedSVG';
*
* <AnimatedSVG
* src="/img/site/svgviewer-output.svg"
* onAnimate={(animator) => {
* animator
* .fadeIn('g[id="node-1"]', { duration: 1 })
* .drawPath('g[id="arrow-1"] path', { duration: 2 })
* .play();
* }}
* autoPlay={true}
* />
* ```
*/

import React, { useEffect, useRef, useState } from "react";
import { SVGAnimator } from "../lib/svg-animator";

interface AnimatedSVGProps {
/** URL or path to the SVG file */
src: string;
/** Animation callback that receives the animator instance */
onAnimate?: (animator: SVGAnimator) => void;
/** Whether to auto-play the animation on mount */
autoPlay?: boolean;
/** Whether to show manual controls (forward/reverse buttons) */
showControls?: boolean;
/** Whether to show restart button only */
showRestartButton?: boolean;
/** Additional CSS classes */
className?: string;
/** Additional inline styles */
style?: React.CSSProperties;
/** Alt text for accessibility */
alt?: string;
/** Width of the SVG container */
width?: string | number;
/** Height of the SVG container */
height?: string | number;
}

const AnimatedSVG: React.FC<AnimatedSVGProps> = ({
src,
onAnimate,
autoPlay = false,
showControls = false,
showRestartButton = false,
className = "",
style = {},
alt = "Animated SVG",
width,
height,
}) => {
const containerRef = useRef<HTMLSpanElement>(null);
const animatorRef = useRef<SVGAnimator | null>(null);
const [isLoaded, setIsLoaded] = useState(false);
const [canGoNext, setCanGoNext] = useState(true);

useEffect(() => {
const container = containerRef.current;
if (!container) return;

// Fetch and inject SVG
const loadSVG = async () => {
try {
const response = await fetch(src);
const svgText = await response.text();
container.innerHTML = svgText;

const svgElement = container.querySelector("svg");
if (!svgElement) {
console.error("No SVG element found in the loaded content");
return;
}

// Set dimensions if provided
if (width)
svgElement.style.width =
typeof width === "number" ? `${width}px` : width;
if (height)
svgElement.style.height =
typeof height === "number" ? `${height}px` : height;

// Create animator instance
animatorRef.current = new SVGAnimator(svgElement);
setIsLoaded(true);

// Call animation callback
if (onAnimate && animatorRef.current) {
onAnimate(animatorRef.current);

if (autoPlay) {
animatorRef.current.play();
}
}
} catch (error) {
console.error("Error loading SVG:", error);
}
};

loadSVG();

// Cleanup
return () => {
if (animatorRef.current) {
animatorRef.current.kill();
}
};
}, [src, onAnimate, autoPlay, width, height]);

const updateButtonStates = () => {
if (animatorRef.current) {
setCanGoNext(animatorRef.current.hasNextStep());
}
};

const handleNext = () => {
if (animatorRef.current) {
animatorRef.current.nextStep();
updateButtonStates();
}
};

const handleReset = () => {
// Reload the component by toggling isLoaded
setIsLoaded(false);
setCanGoNext(true);

// Re-trigger the useEffect by incrementing a key would be cleaner,
// but we can also just re-run the SVG loading logic
const container = containerRef.current;
if (!container) return;

// Kill existing animator
if (animatorRef.current) {
animatorRef.current.kill();
animatorRef.current = null;
}

// Reload SVG
const loadSVG = async () => {
try {
const response = await fetch(src);
const svgText = await response.text();
container.innerHTML = svgText;

const svgElement = container.querySelector("svg");
if (!svgElement) {
console.error("No SVG element found in the loaded content");
return;
}

// Set dimensions if provided
if (width)
svgElement.style.width =
typeof width === "number" ? `${width}px` : width;
if (height)
svgElement.style.height =
typeof height === "number" ? `${height}px` : height;

// Create animator instance
animatorRef.current = new SVGAnimator(svgElement);
setIsLoaded(true);

// Call animation callback
if (onAnimate && animatorRef.current) {
onAnimate(animatorRef.current);

// Auto-play if enabled
if (autoPlay) {
animatorRef.current.play();
}
}
} catch (error) {
console.error("Error loading SVG:", error);
}
};

loadSVG();
};

/*
* Markdown images are wrapped in a paragraph, and a <div> anywhere inside a
* <p> makes the browser close the paragraph early — which desyncs the parsed
* DOM from React's tree and breaks hydration. Every wrapper here is therefore
* a <span> carrying an explicit display.
*/
return (
<span style={{ display: "block" }}>
<span
ref={containerRef}
className={`animated-svg-container ${className}`}
style={{
display: "inline-block",
...style,
}}
role="img"
aria-label={alt}
/>
{showControls && isLoaded && (
<span
style={{
display: "flex",
gap: "10px",
marginTop: "10px",
justifyContent: "center",
}}
>
<button
onClick={handleReset}
style={{
padding: "8px 16px",
fontSize: "14px",
cursor: "pointer",
backgroundColor: "#6c757d",
color: "white",
border: "none",
borderRadius: "8px",
}}
>
↺ Reset
</button>
<button
onClick={handleNext}
disabled={!canGoNext}
style={{
padding: "8px 16px",
fontSize: "14px",
cursor: canGoNext ? "pointer" : "not-allowed",
opacity: canGoNext ? 1 : 0.5,
backgroundColor: canGoNext ? "#2f9e44" : undefined,
color: canGoNext ? "white" : undefined,
border: canGoNext ? "none" : undefined,
borderRadius: "8px",
}}
>
Next →
</button>
</span>
)}
{showRestartButton && !showControls && isLoaded && (
<span
style={{
display: "flex",
marginTop: "10px",
justifyContent: "center",
}}
>
<button
onClick={handleReset}
style={{
padding: "8px 16px",
fontSize: "14px",
cursor: "pointer",
backgroundColor: "#6c757d",
color: "white",
border: "none",
borderRadius: "8px",
}}
>
↺ Replay
</button>
</span>
)}
</span>
);
};

export default AnimatedSVG;
38 changes: 38 additions & 0 deletions src/components/animated-figure.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import defaultMdxComponents from 'fumadocs-ui/mdx';
import type { ImgHTMLAttributes } from 'react';
import AnimatedSVG from '@/components/AnimatedSVG';
import { getFigureAnimation } from '@/components/consensus-animations';

const DefaultImg = defaultMdxComponents.img as (
props: ImgHTMLAttributes<HTMLImageElement>,
) => React.ReactNode;

/**
* Renders markdown images normally, except for the consensus figures whose SVG
* is a stack of animation states. Those are handed to AnimatedSVG so the states
* are revealed one step at a time instead of all at once.
*/
export function AnimatedFigure(props: ImgHTMLAttributes<HTMLImageElement>) {
const { src, alt } = props;

if (typeof src === 'string' && typeof alt === 'string') {
const spec = getFigureAnimation(src, alt);
if (spec) {
return (
<AnimatedSVG
src={src}
alt={alt}
onAnimate={spec.animate}
autoPlay={spec.autoPlay}
showControls={spec.showControls}
showRestartButton={spec.showRestartButton}
width={spec.width}
height={spec.height}
style={spec.style}
/>
);
}
}

return <DefaultImg {...props} />;
}
Loading