/** * React wrapper around CodeMirror 6 for the CalcPad editor. * * Integrates the CalcPad language mode, error display, * and debounced evaluation via the WASM engine Web Worker. */ import { useRef, useEffect, useCallback } from 'react' import { EditorState } from '@codemirror/state' import { EditorView, lineNumbers, drawSelection, highlightActiveLine, keymap, } from '@codemirror/view' import { syntaxHighlighting, bracketMatching, indentOnInput, HighlightStyle, } from '@codemirror/language' import { tags } from '@lezer/highlight' import { defaultKeymap, history, historyKeymap } from '@codemirror/commands' import { calcpadLanguage } from './calcpad-language.ts' import { errorDisplayExtension, setErrorsEffect, type LineError } from './error-display.ts' import { stripedLinesExtension } from './inline-results.ts' import { formatPreviewExtension, formatPreviewCompartment, formatPreviewEnabled } from './format-preview.ts' import type { EngineLineResult } from '../engine/types.ts' export interface CalcEditorProps { /** Initial document content */ initialDoc?: string /** Called when the document text changes (debounced internally) */ onDocChange?: (lines: string[]) => void /** Engine evaluation results to display as errors */ results?: EngineLineResult[] /** Debounce delay in ms before triggering onDocChange */ debounceMs?: number /** Called with the EditorView once created (null on cleanup) */ onViewReady?: (view: EditorView | null) => void /** Enable live preview formatting */ formatPreview?: boolean } /** * CalcPad editor component built on CodeMirror 6. * Handles syntax highlighting, line numbers, and error underlines. */ export function CalcEditor({ initialDoc = '', onDocChange, results, debounceMs = 50, onViewReady, formatPreview = true, }: CalcEditorProps) { const containerRef = useRef(null) const viewRef = useRef(null) const timerRef = useRef | null>(null) // Stable callback ref for doc changes const onDocChangeRef = useRef(onDocChange) onDocChangeRef.current = onDocChange const scheduleEval = useCallback((view: EditorView) => { if (timerRef.current) clearTimeout(timerRef.current) timerRef.current = setTimeout(() => { timerRef.current = null const doc = view.state.doc.toString() const lines = doc.split('\n') onDocChangeRef.current?.(lines) }, debounceMs) }, [debounceMs]) // Create editor on mount useEffect(() => { if (!containerRef.current) return const updateListener = EditorView.updateListener.of((update) => { if (update.docChanged && viewRef.current) { scheduleEval(viewRef.current) } }) const state = EditorState.create({ doc: initialDoc, extensions: [ lineNumbers(), drawSelection(), highlightActiveLine(), bracketMatching(), indentOnInput(), history(), keymap.of([...defaultKeymap, ...historyKeymap]), syntaxHighlighting(calcpadHighlight), calcpadLanguage(), errorDisplayExtension(), stripedLinesExtension(), formatPreviewExtension(formatPreview), updateListener, calcpadEditorTheme, ], }) const view = new EditorView({ state, parent: containerRef.current, }) viewRef.current = view onViewReady?.(view) // Trigger initial evaluation const doc = view.state.doc.toString() onDocChangeRef.current?.(doc.split('\n')) return () => { if (timerRef.current) clearTimeout(timerRef.current) view.destroy() viewRef.current = null onViewReady?.(null) } // initialDoc intentionally excluded — we only set it once on mount // eslint-disable-next-line react-hooks/exhaustive-deps }, [scheduleEval]) // Toggle format preview mode useEffect(() => { const view = viewRef.current if (!view) return view.dispatch({ effects: formatPreviewCompartment.reconfigure( formatPreview ? formatPreviewEnabled : [], ), }) }, [formatPreview]) // Push engine results into the error display useEffect(() => { const view = viewRef.current if (!view || !results) return const errors: LineError[] = [] for (let i = 0; i < results.length; i++) { const lineNum = i + 1 const result = results[i] if (result.type === 'error' && result.error) { // Map to document positions if (lineNum <= view.state.doc.lines) { const docLine = view.state.doc.line(lineNum) errors.push({ from: docLine.from, to: docLine.to, message: result.error, }) } } } view.dispatch({ effects: [ setErrorsEffect.of(errors), ], }) }, [results]) return
} /** * Syntax highlighting using CSS variables for theme integration. */ const calcpadHighlight = HighlightStyle.define([ { tag: tags.number, color: 'var(--syntax-number)' }, { tag: tags.operator, color: 'var(--syntax-operator)' }, { tag: tags.variableName, color: 'var(--syntax-variable)' }, { tag: tags.function(tags.variableName), color: 'var(--syntax-function)' }, { tag: tags.keyword, color: 'var(--syntax-keyword)' }, { tag: tags.lineComment, color: 'var(--syntax-comment)', fontStyle: 'italic' }, { tag: tags.heading, color: 'var(--syntax-heading)', fontWeight: '700' }, { tag: tags.definitionOperator, color: 'var(--syntax-operator)' }, { tag: tags.special(tags.variableName), color: 'var(--syntax-function)' }, { tag: tags.constant(tags.variableName), color: 'var(--syntax-number)', fontWeight: '600' }, { tag: tags.paren, color: 'var(--syntax-operator)' }, { tag: tags.punctuation, color: 'var(--syntax-operator)' }, ]) /** * Base theme for the CalcPad editor. */ const calcpadEditorTheme = EditorView.baseTheme({ '&': { height: '100%', fontSize: 'var(--editor-font-size, 15px)', fontFamily: 'var(--mono, ui-monospace, Consolas, "Courier New", monospace)', }, '.cm-scroller': { overflow: 'auto', }, '.cm-content': { padding: '8px 0', minHeight: '100%', }, '.cm-line': { padding: '0 12px', lineHeight: '1.6', position: 'relative', textAlign: 'var(--cm-text-align, left)', }, '.cm-gutters': { backgroundColor: 'transparent', borderRight: 'none', }, '.cm-lineNumbers .cm-gutterElement': { padding: '0 6px 0 12px', color: 'var(--text, #9ca3af)', opacity: '0.4', fontSize: 'calc(var(--editor-font-size, 15px) - 2px)', minWidth: '32px', }, '.cm-activeLineGutter .cm-gutterElement': { opacity: '1', fontWeight: '600', }, '.cm-stripe': { backgroundColor: 'var(--stripe, rgba(0, 0, 0, 0.02))', }, '.cm-activeLine': { backgroundColor: 'var(--accent-bg, rgba(99, 102, 241, 0.04))', }, '.cm-selectionBackground': { backgroundColor: 'var(--accent-bg, rgba(99, 102, 241, 0.15)) !important', }, '.cm-focused': { outline: 'none', }, })