/** * src/book/components/Scrub.tsx * * A Run cell with a stitch-by-stitch playback scrubber docked below the canvas. * The book's most important teaching widget — shows *which line made which stitch*. * * Usage in MDX: * * {`repeat 6 [ fd 20 rt 60 ]`} * */ import { useState, useCallback, useEffect, useLayoutEffect, useRef, type ReactNode } from 'react'; import Editor from '@monaco-editor/react'; import type { BeforeMount, OnMount } from '@monaco-editor/react'; import type { editor } from 'monaco-editor'; import type { RunResult, DesignStats, StitchEvent } from '../../lib/core/types.ts'; import { useCompiler } from '../../hooks/useCompiler.ts'; import { registerNeedlescript, scheduleNeedlescriptProviders } from '../../lib/editor/monaco.ts'; import BookCanvas from './BookCanvas.tsx'; import { useBookTheme } from '../lib/useBookTheme.ts'; interface Props { children: ReactNode; canvasHeight?: number; } function extractCode(children: ReactNode): string { if (typeof children === 'string') return children.trim(); const el = children as React.ReactElement<{ children: ReactNode }>; if (el && typeof el === 'object' && 'props' in el) return extractCode(el.props.children); return String(children ?? '').trim(); } /** Determine the source line for a given stitch index by scanning backwards. */ function lineAtStitchIndex(pts: StitchEvent[], idx: number): number | null { for (let i = Math.min(idx - 1, pts.length - 1); i >= 0; i--) { if (pts[i].line !== undefined) return pts[i].line ?? null; } return null; } export default function Scrub({ children, canvasHeight = 280 }: Props) { const initialCode = extractCode(children); const [source, setSource] = useState(initialCode); const sourceRef = useRef(source); // Keep the ref current after every render without reading during render useLayoutEffect(() => { sourceRef.current = source; }); const [result, setResult] = useState(null); const [stats, setStats] = useState(null); const [error, setError] = useState(null); const [isRunning, setIsRunning] = useState(false); // pts = stitch + jump events only (parallel array to full events) const [pts, setPts] = useState([]); const [scrubPos, setScrubPos] = useState(0); const [playing, setPlaying] = useState(false); const rafRef = useRef(null); const { compile } = useCompiler(); const theme = useBookTheme(); const monacoTheme = theme === 'dark' ? 'needlescript-dark' : 'needlescript-light'; const editorRef = useRef(null); const decorationsRef = useRef(null); const run = useCallback(async () => { setIsRunning(true); setError(null); setPlaying(false); if (rafRef.current) cancelAnimationFrame(rafRef.current); const response = await compile(sourceRef.current); setIsRunning(false); if (response === null) return; if (!response.ok) { setError(response.message); setResult(null); setStats(null); setPts([]); setScrubPos(0); return; } const nextPts = response.result.events.filter((e) => e.t === 'stitch' || e.t === 'jump'); setResult(response.result); setStats(response.stats); setPts(nextPts); setScrubPos(nextPts.length); // show full design initially }, [compile]); const reset = useCallback(() => { setSource(initialCode); setError(null); }, [initialCode]); useEffect(() => { queueMicrotask(run); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // ── Playback animation ──────────────────────────────────────────────────── useEffect(() => { if (!playing) { if (rafRef.current) cancelAnimationFrame(rafRef.current); return; } const total = pts.length; if (total === 0) return; let pos = scrubPos >= total ? 0 : scrubPos; const perFrame = Math.max(1, Math.round(total / 420)); function step() { pos += perFrame; if (pos >= total) { setScrubPos(total); setPlaying(false); return; } setScrubPos(pos); rafRef.current = requestAnimationFrame(step); } rafRef.current = requestAnimationFrame(step); return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }; // scrubPos is intentionally read once when play starts (the starting position); // it must not be in the dep array or the effect would restart on every frame. // eslint-disable-next-line react-hooks/exhaustive-deps }, [playing, pts.length]); // ── Source-line highlight ───────────────────────────────────────────────── useEffect(() => { const ed = editorRef.current; if (!ed) return; const activeLine = lineAtStitchIndex(pts, scrubPos); if (!decorationsRef.current) { decorationsRef.current = ed.createDecorationsCollection(); } if (activeLine == null) { decorationsRef.current.clear(); return; } decorationsRef.current.set([ { range: { startLineNumber: activeLine, endLineNumber: activeLine, startColumn: 1, endColumn: 1, }, options: { isWholeLine: true, className: 'ns-playback-line', linesDecorationsClassName: 'ns-playback-line-gutter', }, }, ]); ed.revealLineInCenterIfOutsideViewport(activeLine); }, [scrubPos, pts]); const lineCount = source.split('\n').length; const editorHeight = Math.max(3, lineCount) * 20 + 16; const total = pts.length; const handleBeforeMount: BeforeMount = useCallback((monaco) => { registerNeedlescript(monaco); }, []); const handleMount: OnMount = useCallback( (ed, monaco) => { scheduleNeedlescriptProviders(monaco); editorRef.current = ed; ed.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => run()); }, [run], ); return (
{/* Toolbar */}
NeedleScript — scrubber
{/* Editor */}
setSource(v ?? '')} beforeMount={handleBeforeMount} onMount={handleMount} options={{ minimap: { enabled: false }, lineNumbers: 'on', lineDecorationsWidth: 4, lineNumbersMinChars: 2, glyphMargin: false, folding: false, scrollBeyondLastLine: false, wordWrap: 'off', scrollbar: { vertical: 'hidden', horizontal: 'auto' }, overviewRulerLanes: 0, renderLineHighlight: 'line', fontFamily: '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', fontSize: 13, lineHeight: 20, padding: { top: 8, bottom: 8 }, quickSuggestions: true, suggest: { showWords: false }, contextmenu: false, }} />
{/* Canvas */} {/* Scrubber controls */} {total > 0 && !error && (
{ setPlaying(false); setScrubPos(Number(e.target.value)); }} aria-label="Stitch playback position" style={{ flex: 1, accentColor: 'var(--bk-run)' }} /> {scrubPos.toLocaleString()} / {total.toLocaleString()}
)} {/* Error */} {error && (
{error}
)}
); }