Add draft/published editor workflow with publish, revert, and dirty indicator

This commit is contained in:
2026-06-05 10:45:09 +03:00
parent eec2986921
commit f4253f50ca
7 changed files with 506 additions and 43 deletions
+120 -17
View File
@@ -1,13 +1,16 @@
/**
* Spec Editor — the center pane (spec §03).
*
* M1 scope: a Monaco JSON editor bound to the active snippet's draft. It runs
* uncontrolled (raw `monaco-editor`, per docs/architecture/08): created once,
* keystrokes push into the store's `draftText` buffer, and the buffer is pulled
* back in only when the active snippet *changes* (select/create/delete) — never
* keystroke-by-keystroke, which would fight the cursor. Schema validation/
* autocomplete, the draft/published toggle, and the inline error surface arrive
* in M2.
* 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';
@@ -21,24 +24,106 @@ 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 { useSnippetStore } from '../stores/SnippetStore';
import { confirm } from '../stores/ConfirmStore';
import { usePreviewStore } from '../stores/PreviewStore';
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
import styles from './SpecEditor.module.css';
// 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 (
<div className={styles.toolbar}>
<div className={styles.viewToggle} role="group" aria-label="Editor view">
<button
type="button"
className={`${styles.viewOption} ${editorView === 'draft' ? styles.viewActive : ''}`}
aria-pressed={editorView === 'draft'}
onClick={() => setEditorView('draft')}
>
Draft
</button>
<button
type="button"
className={`${styles.viewOption} ${editorView === 'published' ? styles.viewActive : ''}`}
aria-pressed={editorView === 'published'}
onClick={() => setEditorView('published')}
>
Published
</button>
</div>
<span className={styles.spacer} />
<button
type="button"
className={styles.action}
onClick={() => void handleRevert()}
disabled={activeId === null || !dirty}
>
Revert
</button>
<button
type="button"
className={`${styles.action} ${styles.publish}`}
onClick={handlePublish}
disabled={activeId === null}
title="Publish (⌘/Ctrl+S)"
>
Publish
</button>
</div>
);
}
export function SpecEditor() {
const hostRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor | null>(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: useSnippetStore.getState().draftText,
value: selectShownText(useSnippetStore.getState()),
language: 'json',
automaticLayout: true,
minimap: { enabled: false },
@@ -59,8 +144,18 @@ export function SpecEditor() {
});
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(() => {
useSnippetStore.getState().updateDraft(editor.getValue());
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 () => {
@@ -70,24 +165,32 @@ export function SpecEditor() {
};
}, []);
// Replace the buffer when the active snippet changes (not while typing).
// 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 text = useSnippetStore.getState().draftText;
const state = useSnippetStore.getState();
const text = selectShownText(state);
if (editor.getValue() !== text) editor.setValue(text);
editor.updateOptions({ readOnly: activeId === null });
}, [activeId]);
editor.updateOptions({
readOnly: state.activeSnippetId === null || state.editorView === 'published',
});
}, [bufferEpoch, editorView, activeId]);
// Editor theme follows the UI theme (M1: light/dark stock themes).
// Editor theme follows the UI theme.
useEffect(() => {
monaco.editor.setTheme(uiTheme === 'dark' ? 'vs-dark' : 'vs');
}, [uiTheme]);
return (
<div className={styles.editorPane}>
{activeId === null && <div className={styles.placeholder}>Select or create a snippet</div>}
<div className={styles.editor} ref={hostRef} />
<EditorToolbar />
<div className={styles.editorWrap}>
{activeId === null && <div className={styles.placeholder}>Select or create a snippet</div>}
<div className={styles.editor} ref={hostRef} />
</div>
{error !== null && <pre className={styles.error}>{error}</pre>}
</div>
);
}