tmp/webapp-docs/frontend/components/CodeEditor.js

/**
 * A small controlled CodeMirror 6 editor for plain text / code — used by Manage for JS sidecars and
 * raw list editing (where the DPL-specific `DplEditor` would be wrong). It behaves like a textarea
 * (`value` + `onChange`), takes an optional language extension, and is built for large documents
 * (CodeMirror only renders the viewport, so multi-thousand-line lists stay smooth).
 * @module gui/components/CodeEditor
 */
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
import { EditorState, Compartment } from "@codemirror/state";
import { EditorView, keymap, placeholder as placeholderExt, drawSelection, lineNumbers } from "@codemirror/view";
import { history, defaultKeymap, historyKeymap, indentWithTab } from "@codemirror/commands";
import { bracketMatching, indentOnInput, syntaxHighlighting, HighlightStyle } from "@codemirror/language";
import { closeBrackets, closeBracketsKeymap } from "@codemirror/autocomplete";
import { tags as t } from "@lezer/highlight";
import { editorChromeTheme } from "../lib/editorChrome.js";

// Token → CSS class. Colors are set theme-aware in styles.css (`.cm-tok-*`), reusing the same
// palette as the DPL highlighter so code reads consistently in both light and dark themes.
import { jsxDEV as _jsxDEV } from "react/jsx-dev-runtime";
const codeHighlightStyle = HighlightStyle.define([{
  tag: [t.keyword, t.moduleKeyword, t.controlKeyword, t.operatorKeyword, t.definitionKeyword],
  class: "cm-tok-keyword"
}, {
  tag: [t.string, t.special(t.string), t.regexp],
  class: "cm-tok-string"
}, {
  tag: [t.number, t.bool, t.null, t.atom],
  class: "cm-tok-number"
}, {
  tag: [t.lineComment, t.blockComment, t.comment, t.docComment],
  class: "cm-tok-comment"
}, {
  tag: [t.function(t.variableName), t.function(t.propertyName), t.labelName],
  class: "cm-tok-func"
}, {
  tag: [t.propertyName],
  class: "cm-tok-prop"
}, {
  tag: [t.typeName, t.className, t.namespace],
  class: "cm-tok-type"
}, {
  tag: [t.definition(t.variableName)],
  class: "cm-tok-def"
}, {
  tag: [t.operator, t.derefOperator],
  class: "cm-tok-operator"
}]);

// Editor niceties enabled only when a language is supplied (i.e. editing real code, not a raw
// word list): syntax highlighting, bracket matching, auto-close brackets, and indent-on-input.
const codeBasics = [syntaxHighlighting(codeHighlightStyle), bracketMatching(), closeBrackets(), indentOnInput()];

/**
 * @param {object} props
 * @param {string} props.value Controlled text.
 * @param {Function} props.onChange `(next)` on edits.
 * @param {Function} [props.language] A CodeMirror language extension factory (e.g. `javascript`).
 * @param {boolean} [props.lineNumbers] Show a line-number gutter.
 * @param {boolean} [props.wrap] Soft-wrap long lines.
 * @param {string} [props.placeholder] Placeholder when empty.
 * @param {string} [props.className] Extra wrapper class.
 * @param {string} [props.ariaLabel] Accessible label.
 * @param {import("react").Ref} ref Imperative handle: `{ focus() }`.
 * @returns {JSX.Element}
 */
function CodeEditor({
  value,
  onChange,
  language,
  lineNumbers: showGutter = true,
  wrap = false,
  placeholder = "",
  className = "",
  ariaLabel
}, ref) {
  const hostRef = useRef(null);
  const viewRef = useRef(null);
  const valueRef = useRef(value);
  const onChangeRef = useRef(onChange);
  onChangeRef.current = onChange;
  useImperativeHandle(ref, () => ({
    focus: () => viewRef.current?.focus()
  }), []);
  useEffect(() => {
    const langCompartment = new Compartment();
    const updateListener = EditorView.updateListener.of(update => {
      if (!update.docChanged) return;
      const doc = update.state.doc.toString();
      if (doc !== valueRef.current) {
        valueRef.current = doc;
        onChangeRef.current?.(doc);
      }
    });
    const extensions = [history(), drawSelection(), ...(showGutter ? [lineNumbers()] : []), ...(wrap ? [EditorView.lineWrapping] : []), langCompartment.of(language ? language() : []),
    // Code affordances (highlighting, bracket matching, …) only when a language is set.
    ...(language ? codeBasics : []),
    // Bracket-close + Tab-indent keys only in code mode; plain-text lists keep normal Tab nav.
    keymap.of(language ? [...closeBracketsKeymap, ...historyKeymap, ...defaultKeymap, indentWithTab] : [...historyKeymap, ...defaultKeymap]), placeholderExt(placeholder), ariaLabel ? EditorView.contentAttributes.of({
      "aria-label": ariaLabel
    }) : [], EditorView.theme({
      "&": {
        height: "100%"
      },
      ".cm-scroller": {
        overflow: "auto"
      }
    }), editorChromeTheme, updateListener];
    const state = EditorState.create({
      doc: value ?? "",
      extensions
    });
    const view = new EditorView({
      state,
      parent: hostRef.current
    });
    viewRef.current = view;
    valueRef.current = value ?? "";
    return () => {
      view.destroy();
      viewRef.current = null;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // Sync external value changes (load / tab switch / revert) into the document.
  useEffect(() => {
    const view = viewRef.current;
    if (!view) return;
    const cur = view.state.doc.toString();
    if ((value ?? "") !== cur) {
      valueRef.current = value ?? "";
      view.dispatch({
        changes: {
          from: 0,
          to: cur.length,
          insert: value ?? ""
        }
      });
    }
  }, [value]);
  return /*#__PURE__*/_jsxDEV("div", {
    ref: hostRef,
    className: `code-editor ${className}`.trim()
  }, void 0, false);
}
export default /*#__PURE__*/forwardRef(CodeEditor);