diff --git a/packages/review/app/package.json b/packages/review/app/package.json index d500c7c26..de2ed7b5c 100644 --- a/packages/review/app/package.json +++ b/packages/review/app/package.json @@ -31,7 +31,6 @@ "mdast-util-gfm": "3.1.0", "mdast-util-math": "3.0.0", "mdast-util-to-markdown": "2.1.2", - "pele": "1.6.0", "react": "19.2.5", "react-dom": "19.2.5", "zod": "catalog:", diff --git a/packages/review/app/src/animation/animation-block.tsx b/packages/review/app/src/animation/animation-block.tsx index 6292c5001..bf1e0a327 100644 --- a/packages/review/app/src/animation/animation-block.tsx +++ b/packages/review/app/src/animation/animation-block.tsx @@ -2,6 +2,8 @@ import type { BlockProps } from "@canvas/blocks"; import { CodePeekCard } from "@canvas/CodePeek"; import { useReviewDebugSettings } from "@canvas/debug-settings"; import { DiagramHeader } from "@canvas/diagram-header"; +import { diagramStyles } from "@canvas/diagram-styles"; +import { useDiagramTourShell } from "@canvas/diagram-tour"; import { drawStyles } from "@canvas/draw-styles"; import { useOptionalReviewPanel } from "@canvas/review-panel"; import { @@ -13,9 +15,13 @@ import { space, tracking, } from "@canvas/scale.stylex"; +import { shellStyles } from "@canvas/shell-styles"; import { withClass } from "@canvas/stylex-props"; +import { themeClass, themeStyles } from "@canvas/theme-styles"; import { tokens } from "@canvas/tokens.stylex"; -import { IconButton } from "@canvas/ui/button"; +import { Button, IconButton } from "@canvas/ui/button"; +import { SegmentedControl } from "@canvas/ui/segmented"; +import { textStyles } from "@canvas/ui/text"; import { useTooltip } from "@canvas/ui/tooltip"; import { anchorSelection } from "@review/lens-selection"; import { type Block, animationCodeId } from "@review/review-api/document"; @@ -30,6 +36,7 @@ import { useRef, useState, } from "react"; +import { createPortal } from "react-dom"; import { type HostMessage, @@ -55,16 +62,6 @@ const loadAnime = () => (module) => module.default, )); -let peleSource: Promise | undefined; - -// Pele's source, for a stage whose scripts draw Mermaid: fetched by the first -// one that does, as anime.js is by the first animation. -const loadPele = () => - (peleSource ??= import("pele/global?raw").then((module) => module.default)); - -/** Whether a stage's html draws Mermaid, which is all that needs Pele. */ -const drawsMermaid = (html: string) => /\.mermaid\s*\(/.test(html); - let fontRules: Promise | undefined; const fontFiles = import.meta.glob("../fonts/*.woff2", { @@ -196,6 +193,11 @@ export function AnimationBlock({ const [contentHeight, setContentHeight] = useState(120); const [slots, setSlots] = useState([]); const [error, setError] = useState(null); + // The inline figure's height while it is out fullscreen, held by a + // placeholder so the document keeps its place. + const [expanded, setExpanded] = useState(null); + const expandRef = useRef(null); + const closeRef = useRef(null); const indexRef = useRef(index); indexRef.current = index; @@ -293,6 +295,45 @@ export function AnimationBlock({ [data.anchors, node.id, openPeek], ); + // A stage script's setSequence() and the scenario picker both land here. + const selectSequence = (keys: string[]) => { + const unique = new Set(keys); + + const valid = + unique.size === keys.length && + keys.every((key) => frames.some((frame) => frame.key === key)); + + if (!valid) return; + + if ( + sequenceRef.current?.length === keys.length && + sequenceRef.current.every((key, index) => key === keys[index]) + ) + return; + + sequenceRef.current = keys; + setSequenceKeys(keys); + setPlaying(false); + setTimeUp(-1); + const currentKey = frames[indexRef.current]?.key; + + const fallback = frames.findIndex((frame) => frame.key === keys[0]); + + const next = keys.includes(currentKey) ? indexRef.current : fallback; + + if (next !== indexRef.current) setIndex(next); + + // Refresh even a preserved key: lifecycle ranges, held scene changes, + // and its timeline can differ when active order changes. + send({ + type: "frame", + index: next, + instant: true, + direction: "none", + adjacent: false, + }); + }; + const onMessage = useRef<(message: StageMessage) => void>(() => {}); onMessage.current = (message) => { @@ -322,51 +363,9 @@ export function AnimationBlock({ break; } - case "sequence": { - const unique = new Set(message.keys); - - const valid = - unique.size === message.keys.length && - message.keys.every((key) => - frames.some((frame) => frame.key === key), - ); - - if (!valid) break; - - if ( - sequenceRef.current?.length === message.keys.length && - sequenceRef.current.every((key, index) => key === message.keys[index]) - ) - break; - - sequenceRef.current = message.keys; - setSequenceKeys(message.keys); - setPlaying(false); - setTimeUp(-1); - const currentKey = frames[indexRef.current]?.key; - - const fallback = frames.findIndex( - (frame) => frame.key === message.keys[0], - ); - - const next = message.keys.includes(currentKey) - ? indexRef.current - : fallback; - - if (next !== indexRef.current) setIndex(next); - - // Refresh even a preserved key: lifecycle ranges, held scene changes, - // and its timeline can differ when active order changes. - send({ - type: "frame", - index: next, - instant: true, - direction: "none", - adjacent: false, - }); - + case "sequence": + selectSequence(message.keys); break; - } case "reveal": reveal(message.name); @@ -379,10 +378,11 @@ export function AnimationBlock({ } }; - const bootedRef = useRef(null); + const bootedRef = useRef(null); // Hand the stage its runtime and a private port when its page first loads. - // A later load is a page the stage navigated to, which gets neither. + // A later load in the same frame is a page the stage navigated to, which + // gets neither. A new frame, for an edit or for going fullscreen, boots. const boot = useCallback(() => { const target = frameRef.current?.contentWindow; const figure = figureRef.current; @@ -390,7 +390,7 @@ export function AnimationBlock({ if (!target || !figure) return; portRef.current?.close(); - if (bootedRef.current === stageKey) { + if (bootedRef.current === frameRef.current) { portRef.current = null; setSlots([]); setError("The animation left its stage."); @@ -398,7 +398,7 @@ export function AnimationBlock({ return; } - bootedRef.current = stageKey; + bootedRef.current = frameRef.current; const channel = new MessageChannel(); portRef.current = channel.port1; channel.port1.onmessage = (event: MessageEvent) => { @@ -407,8 +407,22 @@ export function AnimationBlock({ if (message.success) onMessage.current(message.data); }; - sequenceRef.current = null; - setSequenceKeys(null); + // A new stage starts in the scenario the reader had picked, or the first. + const sequence = + ( + node.scenarios?.find((scenario) => + sameKeys(scenario.frames, sequenceRef.current), + ) ?? node.scenarios?.[0] + )?.frames ?? null; + + sequenceRef.current = sequence; + setSequenceKeys(sequence); + + if (sequence && !sequence.includes(frames[indexRef.current]!.key)) { + indexRef.current = frames.findIndex((item) => item.key === sequence[0]); + setIndex(indexRef.current); + } + setSlots([]); setError(null); target.postMessage( @@ -432,22 +446,19 @@ export function AnimationBlock({ scale: SCALE, reducedMotion: reducedMotion(), frame: indexRef.current, + ...(sequence && { sequence }), }; - Promise.all([ - loadAnime(), - drawsMermaid(node.html) ? loadPele() : undefined, - loadFonts(), - ]).then( - ([anime, pele, fonts]) => + Promise.all([loadAnime(), loadFonts()]).then( + ([anime, fonts]) => channel.port1.postMessage({ ...init, - libraries: pele ? [anime, pele] : [anime], + libraries: [anime], fonts, } satisfies HostMessage), () => setError("The animation libraries or fonts could not be loaded."), ); - }, [stageKey, node.html, node.code, node.height, frames, embeddable]); + }, [node.html, node.code, node.height, node.scenarios, frames, embeddable]); useEffect(() => () => portRef.current?.close(), []); @@ -485,6 +496,22 @@ export function AnimationBlock({ if (!activeFrames.some((item) => item.key === frame.key)) go(0, true); }, [activeFrames, frame.key, go]); + const collapse = useCallback(() => setExpanded(null), []); + + const { portalTarget } = useDiagramTourShell(expanded !== null, collapse); + + // Focus follows the figure in and back out to the control that opened it. + const wasExpanded = useRef(false); + + useEffect(() => { + if (expanded !== null) closeRef.current?.focus(); + else if (wasExpanded.current) expandRef.current?.focus(); + + wasExpanded.current = expanded !== null; + }, [expanded]); + + const fullscreen = expanded !== null && portalTarget !== null; + const onKeyDown = (event: KeyboardEvent) => { if (event.key === "ArrowRight") go(activeIndex + 1, false); else if (event.key === "ArrowLeft") go(activeIndex - 1, false); @@ -493,10 +520,15 @@ export function AnimationBlock({ event.preventDefault(); }; - return ( + const figure = (
+ fullscreen + ? collapse() + : setExpanded(figureRef.current?.offsetHeight ?? 0) + } + > + {fullscreen ? "Close" : "Expand"} + + } /> + {node.scenarios && ( +
+ + + sameKeys(scenario.frames, sequenceKeys), + )?.key + } + options={node.scenarios.map(({ key, label }) => ({ + value: key, + label, + }))} + onChange={(key) => { + const keys = node.scenarios!.find( + (scenario) => scenario.key === key, + )!.frames; + + send({ type: "sequence", keys }); + selectSequence(keys); + }} + /> +
+ )}
-