import { autocompletion, closeBrackets, closeBracketsKeymap, completionKeymap, } from "@codemirror/autocomplete" import { defaultKeymap, history, historyKeymap, indentWithTab, } from "@codemirror/commands" import { json, jsonParseLinter } from "@codemirror/lang-json" import { python } from "@codemirror/lang-python" import { bracketMatching, foldGutter, foldKeymap, indentOnInput, indentUnit, } from "@codemirror/language" import { linter, lintGutter } from "@codemirror/lint" import { highlightSelectionMatches, searchKeymap } from "@codemirror/search" import { Annotation, Compartment, EditorState, type Extension, } from "@codemirror/state" import { drawSelection, EditorView, highlightActiveLine, highlightActiveLineGutter, highlightSpecialChars, keymap, lineNumbers, } from "@codemirror/view" import { useEffect, useRef } from "react" import { useTheme } from "@/components/theme-provider" import { codeHighlight, codeTheme, indentGuides } from "./codeTheme" /** Marks a change that came in as the `value` prop, so it is not typed text. */ const FromProp = Annotation.define() // One identity per setting, shared by every editor: each state keeps its own // content for it. const readOnlySlot = new Compartment() const darkSlot = new Compartment() /** Typed text is the editor's own to select and copy, just not to change. */ const readOnlyMode = (readOnly: boolean): Extension => [ EditorState.readOnly.of(readOnly), EditorView.contentAttributes.of({ "aria-readonly": String(readOnly) }), ] const HINT = "Tab indents. Press Ctrl+M (Shift+Alt+M on a Mac) to make Tab move focus instead." /** What Python gets that JSON does not, and the other way round. */ const languageSupport = (language: "python" | "json"): Extension => language === "json" ? [json(), linter(jsonParseLinter()), lintGutter()] : python() /** Typed values the parent has not handed back yet, kept to a few. */ const PENDING = 50 /** * The embedded code editor, Python unless asked for JSON. Highlighting and * bracket handling only: what the code actually does is checked by the engine, * which reports load and run errors back onto the node. */ export default function NodeEditor({ value, onChange, onBlur, language = "python", readOnly = false, autoFocus = false, }: { value: string onChange: (next: string) => void /** Focus left the editor, its find bar and suggestions included. */ onBlur?: () => void language?: "python" | "json" /** Generated code: readable, and not this panel's to change. */ readOnly?: boolean /** Take the caret once mounted, as an editor opened on purpose should. */ autoFocus?: boolean }) { const { resolvedTheme } = useTheme() const dark = resolvedTheme === "dark" const host = useRef(null) const view = useRef(null) // What a long-lived editor must read at the time it fires, not at mount. const latest = useRef({ value, onChange, onBlur, readOnly, dark, autoFocus }) latest.current = { value, onChange, onBlur, readOnly, dark, autoFocus } // The parent hands typed text back as `value` a render or two later, by which // time the editor may be a keystroke ahead. An echo is not a new value. const typed = useRef([]) useEffect(() => { const parent = host.current if (!parent) return const now = latest.current const editor = new EditorView({ parent, state: EditorState.create({ doc: now.value, extensions: [ lineNumbers(), foldGutter(), highlightActiveLineGutter(), highlightActiveLine(), highlightSpecialChars(), history(), drawSelection(), indentOnInput(), bracketMatching(), closeBrackets(), autocompletion(), highlightSelectionMatches(), indentGuides, indentUnit.of(" "), EditorState.tabSize.of(4), keymap.of([ ...closeBracketsKeymap, ...defaultKeymap, ...searchKeymap, ...historyKeymap, ...foldKeymap, ...completionKeymap, indentWithTab, ]), languageSupport(language), codeTheme, codeHighlight, EditorView.contentAttributes.of({ "aria-label": language === "json" ? "JSON" : "Python code", "aria-description": HINT, }), readOnlySlot.of(readOnlyMode(now.readOnly)), darkSlot.of(EditorView.darkTheme.of(now.dark)), EditorView.updateListener.of((update) => { if ( !update.docChanged || update.transactions.some((tr) => tr.annotation(FromProp)) ) return const text = update.state.doc.toString() typed.current = [...typed.current, text].slice(-PENDING) latest.current.onChange(text) }), ], }), }) view.current = editor // Leaving the editor means focus went somewhere outside it. Moving into // the find bar or a suggestion list is still inside. const leave = (event: FocusEvent) => { if (editor.dom.contains(event.relatedTarget as Node | null)) return latest.current.onBlur?.() } editor.dom.addEventListener("focusout", leave) if (now.autoFocus) editor.focus() return () => { editor.dom.removeEventListener("focusout", leave) editor.destroy() view.current = null typed.current = [] } }, [language]) useEffect(() => { const editor = view.current if (!editor) return const echo = typed.current.indexOf(value) if (echo >= 0) { typed.current = typed.current.slice(echo + 1) return } if (editor.state.doc.toString() === value) return typed.current = [] editor.dispatch({ changes: { from: 0, to: editor.state.doc.length, insert: value }, selection: { anchor: Math.min(editor.state.selection.main.head, value.length), }, annotations: FromProp.of(true), }) }, [value]) useEffect(() => { view.current?.dispatch({ effects: readOnlySlot.reconfigure(readOnlyMode(readOnly)), }) }, [readOnly]) useEffect(() => { view.current?.dispatch({ effects: darkSlot.reconfigure(EditorView.darkTheme.of(dark)), }) }, [dark]) return (
) }