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

/**
 * A controlled CodeMirror 6 editor for DPL boxes (the prompt, the negative prompt, the wrapper
 * Start/End). It behaves like a `<textarea>` — `value` + `onChange(next)` so the parent owns
 * state — but adds DPL syntax highlighting and a brace-aware autocomplete dropdown of every
 * list / generator token. Line wrapping is on; height is driven by CSS (`.dpl-editor`).
 *
 * The catalog is read lazily per-completion (so browser-local custom blocks show up without a
 * remount), and edits made while the parent re-renders don't echo back into the document (the
 * latest `value` is tracked in a ref and compared before re-dispatching).
 * @module gui/components/DplEditor
 */
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
import { useIntl } from "react-intl";
import { Compartment, EditorState } from "@codemirror/state";
import { EditorView, keymap, placeholder as placeholderExt, drawSelection, lineNumbers, highlightActiveLine, highlightActiveLineGutter } from "@codemirror/view";
import { history, defaultKeymap, historyKeymap, insertNewlineAndIndent } from "@codemirror/commands";
import { autocompletion, completionKeymap, snippet, startCompletion } from "@codemirror/autocomplete";
import { linter } from "@codemirror/lint";
import { dplLanguage, dplCompletionSource, dplKindBadge, inFrontMatter } from "../lib/dpl/dplLanguage.js";
import { dplDials } from "../lib/dpl/dplDials.js";
import { dplLineActions } from "../lib/dpl/dplLineActions.js";
import { buildInteractiveLabels } from "../lib/dpl/dplInteractiveMessages.js";
import { getDplCompletions, expandPrompt } from "../lib/promptEngine.js";
import { validateDpl } from "../lib/dpl/validateDpl.js";
import { editorChromeTheme } from "../lib/editorChrome.js";

// CodeMirror linter fed by the shared DPL validator — underlines bad spots and shows the message on
// hover; the gutter marks each line with an issue. The same validator backs the editors' status icon.
// A factory so the linter can read the live `intl` (via a ref) for localized diagnostics.
import { jsxDEV as _jsxDEV } from "react/jsx-dev-runtime";
const makeDplLinter = intlRef => linter(view => {
  const text = view.state.doc.toString();
  const len = text.length;
  return validateDpl(text, intlRef.current).map(d => ({
    from: Math.min(d.from, len),
    to: Math.min(Math.max(d.to, d.from + 1), len),
    severity: d.severity,
    message: d.message
  }));
});

// Render a token into a concrete example for the autocomplete info panel (no auto-FX/auto-artist
// noise, mirroring the eye-icon live preview).
const expandExample = (token, settings) => expandPrompt(token, {
  ...settings,
  autoAddFx: false,
  autoAddArtists: false
});

/**
 * @param {object} props
 * @param {string} props.value The DPL text (controlled).
 * @param {Function} props.onChange `(next)` — called on user edits.
 * @param {string} [props.placeholder] Placeholder shown when empty (may change over time).
 * @param {string} [props.className] Extra class on the wrapper (e.g. `prompt-input`).
 * @param {string} [props.ariaLabel] Accessible label for the editor.
 * @param {object} [props.settings] Generation settings — used to roll the live example in the
 *   autocomplete info panel with the correct SFW/NSFW gating. Read lazily, so updates don't remount.
 * @param {import("react").Ref} ref Imperative handle: `{ insertSnippet(template, opts), focus() }`.
 * @returns {JSX.Element}
 */
function DplEditor({
  value,
  onChange,
  placeholder = "",
  className = "",
  ariaLabel,
  settings
}, ref) {
  const intl = useIntl();
  const hostRef = useRef(null);
  const viewRef = useRef(null);
  const valueRef = useRef(value);
  const onChangeRef = useRef(onChange);
  onChangeRef.current = onChange;
  const intlRef = useRef(intl);
  intlRef.current = intl;
  const settingsRef = useRef(settings);
  settingsRef.current = settings;
  const placeholderCompartment = useRef(new Compartment());
  const labelCompartment = useRef(new Compartment());

  // Imperative handle for the DPL insert toolbar: drop a snippet at the cursor (or wrap the
  // selection), with CodeMirror's `${…}` tab stops so the user can tab through the blanks.
  useImperativeHandle(ref, () => ({
    focus: () => viewRef.current?.focus(),
    insertSnippet: (template, opts = {}) => {
      const view = viewRef.current;
      if (!view || !template) return;
      const sel = view.state.selection.main;
      let tpl = template;
      if (opts.wrap) {
        const selText = view.state.sliceDoc(sel.from, sel.to);
        tpl = tpl.replace("${sel}", selText ? `\${1:${selText}}` : "${1}");
      }
      if (opts.line) {
        // Line-leading constructs need their own line — prepend a newline if the cursor's line
        // already has content before it.
        const line = view.state.doc.lineAt(sel.from);
        if (view.state.sliceDoc(line.from, sel.from).trim() !== "") tpl = `\n${tpl}`;
      }
      view.focus();
      snippet(tpl)(view, null, sel.from, sel.to);
    }
  }), []);

  // Build the editor once (mount). The completion source reads the catalog lazily each time.
  useEffect(() => {
    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 interactiveLabels = buildInteractiveLabels(intlRef.current);
    const state = EditorState.create({
      doc: value ?? "",
      extensions: [history(), drawSelection(), EditorView.lineWrapping, lineNumbers(), highlightActiveLineGutter(), highlightActiveLine(), dplLineActions(interactiveLabels), dplDials(interactiveLabels), dplLanguage(), makeDplLinter(intlRef), autocompletion({
        override: [dplCompletionSource(getDplCompletions, {
          expand: expandExample,
          getSettings: () => settingsRef.current || {}
        })],
        icons: false,
        addToOptions: [{
          render: dplKindBadge,
          position: 70
        }]
      }), keymap.of([...completionKeymap,
      // Inside the leading `---` front-matter block, pressing Enter drops to a fresh line and
      // immediately re-opens the key suggestions, so the front-matter keys are discoverable
      // one after another. (Placed after completionKeymap so Enter still *accepts* an open
      // completion; only a plain Enter falls through to here.)
      {
        key: "Enter",
        run: view => {
          if (!inFrontMatter(view.state, view.state.selection.main.head)) return false;
          const handled = insertNewlineAndIndent(view);
          if (inFrontMatter(view.state, view.state.selection.main.head)) startCompletion(view);
          return handled;
        }
      }, ...historyKeymap, ...defaultKeymap]), placeholderCompartment.current.of(placeholderExt(placeholder)), labelCompartment.current.of(ariaLabel ? EditorView.contentAttributes.of({
        "aria-label": ariaLabel
      }) : []), EditorView.theme({
        "&": {
          height: "100%"
        }
      }), editorChromeTheme, updateListener]
    });
    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 (Random / Clear / preset) 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]);

  // Reconfigure the (possibly rotating) placeholder without rebuilding the editor.
  useEffect(() => {
    viewRef.current?.dispatch({
      effects: placeholderCompartment.current.reconfigure(placeholderExt(placeholder))
    });
  }, [placeholder]);
  return /*#__PURE__*/_jsxDEV("div", {
    ref: hostRef,
    className: `dpl-editor ${className}`.trim()
  }, void 0, false);
}
export default /*#__PURE__*/forwardRef(DplEditor);