/** * 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, useMemo, useRef, type RefObject } from 'react'; import { useShallow } from 'zustand/react/shallow'; // `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 { parseChartSpecText } from '@core/chart-builder'; import { openChartBuilderForEdit } from '../modals/ModalCoordinator'; import { installSpecConfigActions, runExtractConfig, runExtractConfigToTheme, runMergeChartTheme, } from '../services/spec-config-actions'; import { configureSpecTransformCodeActions, installSpecTransformActions, installSpecTransformCodeLens, runUnwrap, runWrap, } from '../services/spec-transform-actions'; import { configureSpecDatasetHints } from '../services/spec-dataset-hints'; import { runExtract } from '../services/extract-action'; import { useAppStore } from '../stores/AppStore'; import { confirm } from '../stores/ConfirmStore'; import { useDatasetStore } from '../stores/DatasetStore'; import { hasExtractableData } 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 { Button } from './Button'; import { SegmentedControl, type SegmentedOption } from './SegmentedControl'; import { SelectControl } from './SelectControl'; 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(); // Register the structural-transform refactors (the lightbulb) once, globally for // JSON — like the schema/formatter, not per editor (docs/architecture/08). configureSpecTransformCodeActions(); // Register the dataset-aware completion/hover/inlay providers once (docs/architecture/08). configureSpecDatasetHints(); /** The two spec↔config operations, surfaced as an overflow menu (council: * Carbon menu-buttons — overflow for additional options under space * constraint; NN/g #6 — a visible home, with the Monaco context menu and F1 * palette as the #7 accelerators on the same code paths). */ const CONFIG_ACTIONS = [ { value: 'merge', label: 'Merge chart theme into spec', detail: 'Write the active chart theme into the config block', }, { value: 'extract', label: 'Extract config from spec', detail: 'Remove the config block and copy it to the clipboard', }, { value: 'extract-theme', label: 'Extract config to new theme', detail: 'Save the config block as a custom chart theme and select it', }, ] as const; type ConfigActionId = (typeof CONFIG_ACTIONS)[number]['value']; /** Structural transforms, surfaced as a sibling menu to Config (the discoverable * home; the lightbulb and F1 palette are the accelerators — see * services/spec-transform-actions). They act on the selection, else the whole * spec. */ const TRANSFORM_ACTIONS = [ { value: 'layer', label: 'Wrap in layer', detail: 'Overlay marks on shared scales' }, { value: 'hconcat', label: 'Wrap in horizontal concat', detail: 'Place views side by side' }, { value: 'vconcat', label: 'Wrap in vertical concat', detail: 'Stack views top to bottom' }, { value: 'facet', label: 'Wrap in facet', detail: 'Small multiples across a field' }, { value: 'repeat', label: 'Wrap in repeat', detail: 'Repeat the chart across fields' }, { value: 'simplify', label: 'Simplify composition', detail: 'Collapse a single-child layer/concat back to a unit', }, ] as const; type TransformActionId = (typeof TRANSFORM_ACTIONS)[number]['value']; function EditorToolbar({ editorRef, }: { editorRef: RefObject; }) { 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 data to lift out — inline // `values` in any view, or a reference to a self-defined `datasets` entry (spec // §03F → hidden when there is nothing extractable). const canExtract = useSnippetStore( (s) => s.activeSnippetId !== null && hasExtractableData(s.draftText), ); // Offer "Open in builder" only when the active snippet's published spec is // losslessly representable in the builder dialect AND its referenced dataset // exists — the gate the builder's openForEdit enforces (spec §06 → Open in // builder). Content-gated like Extract, so it is **hidden** when inapplicable // (the toolbar's convention for content-gated actions — vs Revert/Config, which // disable because they are merely state-gated; arch 10 §5). `datasetNames` is a // shallow-stable string[] so the memo doesn't churn (MEMORY → stable selectors). const activeSpec = useSnippetStore((s) => selectActiveSnippet(s)?.spec ?? null); const datasetNames = useDatasetStore(useShallow((s) => s.datasets.map((d) => d.name))); const canOpenInBuilder = useMemo(() => { if (activeSpec === null) return false; const config = parseChartSpecText(activeSpec); return config !== null && datasetNames.includes(config.datasetName); }, [activeSpec, datasetNames]); const handleOpenInBuilder = () => { const snippet = selectActiveSnippet(useSnippetStore.getState()); if (snippet) openChartBuilderForEdit(snippet); }; // Extract is scoped to the view at the cursor (services/extract-action), so it // goes through the editor handle like the wrap/config actions, not a bare // openModal — the service captures the focused binding before opening the modal. const handleExtract = () => { const editor = editorRef.current; if (editor) runExtract(editor); }; // 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 handleConfigAction = (action: ConfigActionId) => { const editor = editorRef.current; if (!editor) return; if (action === 'merge') runMergeChartTheme(editor); else if (action === 'extract') void runExtractConfig(editor); else runExtractConfigToTheme(editor); }; const handleTransformAction = (action: TransformActionId) => { const editor = editorRef.current; if (!editor) return; if (action === 'simplify') runUnwrap(editor); else runWrap(editor, action); }; 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. */} {canOpenInBuilder && ( )} {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); // Merge-chart-theme / extract-config actions (context menu + F1 palette, // docs/chart-theming-scope.md §4.3). Edits land via onDidChangeModelContent // above, so the draft buffer stays in sync like any other edit. const configActionsSub = installSpecConfigActions(editor); // Structural-transform actions in the F1 palette (the lightbulb is registered // once, globally, above; the toolbar Transform menu is the home). Per editor, // disposed below like the config actions. const transformActionsSub = installSpecTransformActions(editor); // "+ Add view" CodeLens over composition arrays — per editor, because its // command needs this editor's handle to apply the edit. const codeLensSub = installSpecTransformCodeLens(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(); configActionsSub.dispose(); transformActionsSub.dispose(); codeLensSub.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}
        
)}
); }