Node source (Python) and the JSON dialog share one CodeMirror editor with find and replace, autocomplete, folding, indent guides, comment toggle and a JSON lint, themed from the CSS tokens so light and dark follow the .dark class without a second palette. Programmatic value changes no longer fire onChange, blur ignores focus moving into the find bar, readOnly keeps selection and copy. The editor chunk is 470 kB (157 kB gzipped) against 2.7 MB (704 kB) for Monaco, which also pulled in a 1.2 MB JSON mode, two workers and a font. At 4x CPU on slow 4G a node panel is ready in 5.7 s instead of 10.8 s, the JSON dialog in 1.5 s instead of 6.7 s. Typing without a key delay keeps every character. monaco-editor, @monaco-editor/react and the dompurify override are removed. Specs: typed code reaches the node source, a JSON setting saves only text that parses. Docs name Tab and Ctrl-M.
218 lines
6.4 KiB
TypeScript
218 lines
6.4 KiB
TypeScript
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<boolean>()
|
|
|
|
// 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<HTMLDivElement>(null)
|
|
const view = useRef<EditorView | null>(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<string[]>([])
|
|
|
|
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 (
|
|
<div
|
|
ref={host}
|
|
data-testid="code-editor"
|
|
data-language={language}
|
|
className="h-full w-full text-base md:text-sm"
|
|
/>
|
|
)
|
|
}
|