/** * Spec Editor — the center pane (spec §03). * * A Monaco JSON editor bound to the active snippet, running uncontrolled (raw * `monaco-editor`, per docs/architecture/08): created once, keystrokes push into * the store's `draftText` buffer. The buffer is reloaded only when the store * signals a programmatic load (`bufferEpoch`) or the view toggles — never * keystroke-by-keystroke, which would fight the cursor. * * The pane header carries the Draft/Published toggle plus Publish and Revert * (spec §03D). The published view is read-only — it shows the last published * spec for reference; all editing happens on the draft. Render problems surface * inline near the editor (spec §03E), mirroring the preview via PreviewStore. */ import { useEffect, useRef } from 'react'; // `edcore.main` is the full standalone editor — every feature contribution // (folding, suggest widget, word operations like Cmd+Backspace, find, bracket // colorization, multi-cursor, …) — but WITHOUT the `monaco-editor` barrel's // basic-languages (sql, abap, solidity, …) we never use. We then add only the // JSON language service. Full editor UX, JSON-only weight. import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main'; import 'monaco-editor/esm/vs/language/json/monaco.contribution'; import '../infrastructure/monaco-env'; // side-effect: wire workers before create import { configureVegaLiteJson } from '../infrastructure/monaco-schema'; import { useAppStore } from '../stores/AppStore'; import { confirm } from '../stores/ConfirmStore'; import { usePreviewStore } from '../stores/PreviewStore'; import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore'; import { SegmentedControl, type SegmentedOption } from './SegmentedControl'; import type { EditorView } from '../stores/SnippetStore'; import styles from './SpecEditor.module.css'; /** The two editor views as a single-select set (spec §03D). */ const VIEW_OPTIONS: ReadonlyArray> = [ { value: 'draft', label: 'Draft' }, { value: 'published', label: 'Published' }, ]; // Register the bundled Vega-Lite schema once: resolves `$schema` locally (no // network warning) and powers validation, autocomplete, and hover docs. configureVegaLiteJson(); function EditorToolbar() { const activeId = useSnippetStore((s) => s.activeSnippetId); const editorView = useSnippetStore((s) => s.editorView); const setEditorView = useSnippetStore((s) => s.setEditorView); // Are there draft changes to revert? Use the live buffer in the draft view so // the control responds before the auto-save debounce commits (spec §03D). const dirty = useSnippetStore((s) => { const active = selectActiveSnippet(s); if (!active) return false; const draft = s.editorView === 'draft' ? s.draftText : active.draftSpec; return draft !== active.spec; }); const handlePublish = () => { if (!useSnippetStore.getState().activeSnippetId) return; useSnippetStore.getState().publish(); // TODO: success toast "Snippet published" once the toast system lands (M6, spec §03D). }; const handleRevert = async () => { const ok = await confirm({ title: 'Revert draft', message: 'Discard all draft changes and restore the last published version? This cannot be undone.', confirmLabel: 'Revert', danger: true, }); if (ok) { useSnippetStore.getState().revert(); // TODO: success toast "Draft reverted" once the toast system lands (M6, spec §03D). } }; return (
); } export function SpecEditor() { const hostRef = useRef(null); const editorRef = useRef(null); const activeId = useSnippetStore((s) => s.activeSnippetId); const editorView = useSnippetStore((s) => s.editorView); const bufferEpoch = useSnippetStore((s) => s.bufferEpoch); const uiTheme = useAppStore((s) => s.uiTheme); const error = usePreviewStore((s) => s.error); // Create the editor once, on mount. useEffect(() => { if (!hostRef.current) return; const editor = monaco.editor.create(hostRef.current, { value: selectShownText(useSnippetStore.getState()), language: 'json', automaticLayout: true, minimap: { enabled: false }, // The editor is a Plex Mono surface per the design language (doc §3.1). // Monaco needs an explicit family string — it can't read the CSS token. fontFamily: "'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace", fontSize: 13, tabSize: 2, wordWrap: 'on', folding: true, showFoldingControls: 'always', // keep fold arrows visible, not only on hover scrollBeyondLastLine: false, // Vega-Lite enum values ("bar", "quantitative", …) live inside JSON // strings, where Monaco disables auto-suggest by default — turn it on so // those keywords are hinted as you type, not just on Ctrl+Space. quickSuggestions: { other: true, comments: false, strings: true }, suggestOnTriggerCharacters: true, }); editorRef.current = editor; // Keystrokes feed the draft buffer — but only on the editable draft view. // (Programmatic setValue while showing the read-only published spec must not // overwrite the draft.) const sub = editor.onDidChangeModelContent(() => { if (useSnippetStore.getState().editorView === 'draft') { useSnippetStore.getState().updateDraft(editor.getValue()); } }); // Cmd/Ctrl+S publishes the current draft (spec §03D → Publish). editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { if (useSnippetStore.getState().activeSnippetId) useSnippetStore.getState().publish(); }); return () => { sub.dispose(); editor.dispose(); editorRef.current = null; }; }, []); // Reload the buffer on a programmatic load (select/create/delete/revert → // bufferEpoch) or a view toggle. Not on keystrokes: typing changes neither dep. useEffect(() => { const editor = editorRef.current; if (!editor) return; const state = useSnippetStore.getState(); const text = selectShownText(state); if (editor.getValue() !== text) editor.setValue(text); editor.updateOptions({ readOnly: state.activeSnippetId === null || state.editorView === 'published', }); }, [bufferEpoch, editorView, activeId]); // Editor theme follows the UI theme. useEffect(() => { monaco.editor.setTheme(uiTheme === 'dark' ? 'vs-dark' : 'vs'); }, [uiTheme]); return (
{activeId === null &&
Select or create a snippet
}
{/* The single live region for render/parse errors: assertive, since the user just caused it. The preview shows the same text visually but is not a live region, so the message is announced once (doc §10.1). */} {error !== null && (
          {error}
        
)}
); }