/** * 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 { configureJsonFormatter, installFormatOnPaste } from '../infrastructure/monaco-format'; import { openModal } from '../modals/ModalCoordinator'; import { useAppStore } from '../stores/AppStore'; import { confirm } from '../stores/ConfirmStore'; import { hasInlineData } from '../stores/ExtractStore'; import { publishActiveSnippet } from '../services/snippet-actions'; import { notify } from '../stores/NotificationStore'; import { usePreviewStore } from '../stores/PreviewStore'; import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore'; import { useUserSettingsStore } from '../stores/UserSettingsStore'; import { Icon } from './Icon'; import { SegmentedControl, type SegmentedOption } from './SegmentedControl'; import { NumberControl, RangeControl, ResetButton, SettingFooter, SettingRow, SettingsPopover, } from './SettingsPopover'; 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' }, ]; /** Editor syntax theme: Auto follows the app theme; overrides force one (spec §07). */ const EDITOR_THEME_OPTIONS: ReadonlyArray> = [ { value: 'auto', label: 'Auto' }, { value: 'light', label: 'Light' }, { value: 'dark', label: 'Dark' }, ]; const ONOFF_OPTIONS: ReadonlyArray> = [ { value: 'on', label: 'On' }, { value: 'off', label: 'Off' }, ]; /** * Editor settings cluster (spec §07 → Editor), disclosed from the editor toolbar * and applied live. The id matches the Cmd/Ctrl+, shortcut target (App.tsx). */ function EditorSettings() { const editor = useUserSettingsStore((s) => s.saved.editor); const setEditor = useUserSettingsStore((s) => s.setEditor); const resetEditor = useUserSettingsStore((s) => s.resetEditor); return ( setEditor({ fontSize })} /> setEditor({ theme })} /> setEditor({ minimap: v === 'on' })} /> setEditor({ wordWrap })} /> setEditor({ lineNumbers })} /> setEditor({ tabSize })} /> Reset editor defaults ); } // Register the bundled Vega-Lite schema once: resolves `$schema` locally (no // network warning) and powers validation, autocomplete, and hover docs. configureVegaLiteJson(); // Register the compact JSON formatter once (Format Document + format-on-paste, §03A). configureJsonFormatter(); 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; }); // Offer Extract only when the live draft carries top-level inline data to lift // out (spec §03F → hidden when the spec has no inline data). const canExtract = useSnippetStore( (s) => s.activeSnippetId !== null && hasInlineData(s.draftText), ); // Publish + its success toast live in one place (services/snippet-actions) so // the button and the Cmd/Ctrl+S shortcut (EventRouter) behave identically. const handlePublish = publishActiveSnippet; 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(); notify({ kind: 'success', title: 'Draft reverted', message: 'The editor was restored to the last published version.', }); } }; return (
{/* Secondary actions collapse to icon-only when the editor pane is narrow (a @container query in the CSS), so "Extract to Dataset" no longer wraps to two lines and Revert/Publish stay on one row. The label rides in `title` (and the accessible name) when only the icon shows. Publish — the one primary action — keeps its label at every width. */} {canExtract && ( )}
); } 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); // Editor preferences (spec §07 → Editor); applied live below as they change. const editorPrefs = useUserSettingsStore((s) => s.saved.editor); // Create the editor once, on mount. useEffect(() => { if (!hostRef.current) return; // Seed from the user's current editor settings so the first paint matches. const ed = useUserSettingsStore.getState().saved.editor; const editor = monaco.editor.create(hostRef.current, { value: selectShownText(useSnippetStore.getState()), language: 'json', automaticLayout: true, minimap: { enabled: ed.minimap }, // 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: ed.fontSize, tabSize: ed.tabSize, wordWrap: ed.wordWrap, lineNumbers: ed.lineNumbers, 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()); } }); // Pasting reindents the whole spec to keep it consistently formatted (§03A). // The reformat fires onDidChangeModelContent above, so the draft buffer picks // up the formatted text. No-op on the read-only published view / invalid JSON. const pasteSub = installFormatOnPaste(editor); // Cmd/Ctrl+S is owned globally by the EventRouter (docs/architecture/04 → // "bind listeners in exactly one place"), which publishes before the // interactive-context gate so it works while the editor has focus. Monaco // binds no default for Cmd+S, so the keystroke bubbles to that one handler. return () => { sub.dispose(); pasteSub.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]); // Apply editor preferences live as they change (spec §07 Apply → takes effect // immediately). tabSize is a model option, so it's set on the model. useEffect(() => { const editor = editorRef.current; if (!editor) return; editor.updateOptions({ fontSize: editorPrefs.fontSize, wordWrap: editorPrefs.wordWrap, lineNumbers: editorPrefs.lineNumbers, minimap: { enabled: editorPrefs.minimap }, }); editor.getModel()?.updateOptions({ tabSize: editorPrefs.tabSize }); }, [editorPrefs]); // Editor theme: Auto follows the app UI theme; an explicit override forces one // (spec §07 → Editor theme, provisional). useEffect(() => { const pref = editorPrefs.theme; const effective = pref === 'auto' ? uiTheme : pref; monaco.editor.setTheme(effective === 'dark' ? 'vs-dark' : 'vs'); }, [uiTheme, editorPrefs.theme]); 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}
        
)}
); }