/** * src/book/components/Run.tsx * * Editable NeedleScript code cell with a live hoop preview. * The book's primary teaching component. * * 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 } 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; /** Canvas height in pixels. Default 280. */ canvasHeight?: number; /** Auto-run on mount without requiring a manual Run click. Default true. */ autoRun?: boolean; } function extractCode(children: ReactNode): string { // MDX passes code content as a string child (possibly nested in a React element). // Trim surrounding newlines that MDX often adds. if (typeof children === 'string') return children.trim(); // React element wrapping (e.g. from remark-code processing) — extract text 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(); } /** Minimal book-themed stats row */ function StatsRow({ stats, error }: { stats: DesignStats | null; error: string | null }) { if (error) { return (
{error}
); } if (!stats) return null; return (
{stats.stitches.toLocaleString()} stitches {stats.width.toFixed(1)} × {stats.height.toFixed(1)} mm {stats.colorsUsed > 1 && {stats.colorsUsed} colours} {stats.planMode && stats.travelBeforeMm !== undefined && stats.travelAfterMm !== undefined && ( plan {stats.travelBeforeMm.toFixed(1)} → {stats.travelAfterMm.toFixed(1)} mm )}
); } export default function Run({ children, canvasHeight = 280, autoRun = true }: Props) { const initialCode = extractCode(children); const [source, setSource] = useState(initialCode); const sourceRef = useRef(source); // Keep the ref current after every render (without reading it 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); const { compile } = useCompiler(); const theme = useBookTheme(); const monacoTheme = theme === 'dark' ? 'needlescript-dark' : 'needlescript-light'; const run = useCallback(async () => { setIsRunning(true); setError(null); const response = await compile(sourceRef.current); setIsRunning(false); if (response === null) return; // superseded if (!response.ok) { setError(response.message); setResult(null); setStats(null); } else { setResult(response.result); setStats(response.stats); } }, [compile]); const reset = useCallback(() => { setSource(initialCode); setError(null); }, [initialCode]); // Auto-run on mount — deferred via queueMicrotask so the effect completes // before any setState calls happen (satisfies react-hooks/set-state-in-effect). useEffect(() => { if (autoRun) queueMicrotask(run); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const handleBeforeMount: BeforeMount = useCallback((monaco) => { registerNeedlescript(monaco); }, []); const handleMount: OnMount = useCallback( (ed: editor.IStandaloneCodeEditor, monaco) => { scheduleNeedlescriptProviders(monaco); // Cmd/Ctrl+Enter → Run ed.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => { run(); }); }, [run], ); const lineCount = source.split('\n').length; // Monaco line height (px) — 20px matches the IDE setting in EditorPane const editorHeight = Math.max(3, lineCount) * 20 + 16; // +16 for top/bottom padding return (
{/* Toolbar */}
NeedleScript
{/* Monaco 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 */} {/* Stats / error */}
); }