mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
807d3c8e3c
One module owns the document keydown listener and dispatches the shortcut map (Cmd/Ctrl+Shift+N / +K / +S / +, / Escape), platform-aware, via the single-source focus-utils interactive-context gate. Escape and Cmd/Ctrl+S run before the gate so save works while editing; the rest are suppressed mid-typing. Publish + its toast move into services/snippet-actions so the button and Cmd/Ctrl+S behave identically. Removes the ad-hoc keydown handler from App and Monaco's Cmd+S command.
335 lines
12 KiB
TypeScript
335 lines
12 KiB
TypeScript
/**
|
|
* 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 { 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 { 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<SegmentedOption<EditorView>> = [
|
|
{ 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<SegmentedOption<string>> = [
|
|
{ value: 'auto', label: 'Auto' },
|
|
{ value: 'light', label: 'Light' },
|
|
{ value: 'dark', label: 'Dark' },
|
|
];
|
|
|
|
const ONOFF_OPTIONS: ReadonlyArray<SegmentedOption<'on' | 'off'>> = [
|
|
{ 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 (
|
|
<SettingsPopover id="editor-settings" label="Editor settings" title="Editor">
|
|
<SettingRow label="Font size" htmlFor="set-fontsize">
|
|
<RangeControl
|
|
id="set-fontsize"
|
|
min={10}
|
|
max={18}
|
|
value={editor.fontSize}
|
|
suffix="px"
|
|
onChange={(fontSize) => setEditor({ fontSize })}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow label="Theme">
|
|
<SegmentedControl
|
|
label="Editor theme"
|
|
options={EDITOR_THEME_OPTIONS}
|
|
value={editor.theme}
|
|
onChange={(theme) => setEditor({ theme })}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow label="Minimap">
|
|
<SegmentedControl
|
|
label="Minimap"
|
|
options={ONOFF_OPTIONS}
|
|
value={editor.minimap ? 'on' : 'off'}
|
|
onChange={(v) => setEditor({ minimap: v === 'on' })}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow label="Word wrap">
|
|
<SegmentedControl
|
|
label="Word wrap"
|
|
options={ONOFF_OPTIONS}
|
|
value={editor.wordWrap}
|
|
onChange={(wordWrap) => setEditor({ wordWrap })}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow label="Line numbers">
|
|
<SegmentedControl
|
|
label="Line numbers"
|
|
options={ONOFF_OPTIONS}
|
|
value={editor.lineNumbers}
|
|
onChange={(lineNumbers) => setEditor({ lineNumbers })}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow label="Tab size" htmlFor="set-tabsize">
|
|
<NumberControl
|
|
id="set-tabsize"
|
|
min={1}
|
|
max={8}
|
|
value={editor.tabSize}
|
|
onChange={(tabSize) => setEditor({ tabSize })}
|
|
/>
|
|
</SettingRow>
|
|
<SettingFooter>
|
|
<ResetButton onClick={resetEditor}>Reset editor defaults</ResetButton>
|
|
</SettingFooter>
|
|
</SettingsPopover>
|
|
);
|
|
}
|
|
|
|
// 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;
|
|
});
|
|
// 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 (
|
|
<div className={styles.toolbar}>
|
|
<SegmentedControl
|
|
label="Editor view"
|
|
options={VIEW_OPTIONS}
|
|
value={editorView}
|
|
onChange={setEditorView}
|
|
/>
|
|
|
|
<span className={styles.spacer} />
|
|
|
|
{canExtract && (
|
|
<button
|
|
type="button"
|
|
className={styles.action}
|
|
onClick={() => openModal('extract')}
|
|
title="Extract inline data into a reusable dataset"
|
|
>
|
|
Extract to Dataset
|
|
</button>
|
|
)}
|
|
<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)"
|
|
aria-keyshortcuts="Meta+S Control+S"
|
|
>
|
|
Publish
|
|
</button>
|
|
<EditorSettings />
|
|
</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);
|
|
// 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());
|
|
}
|
|
});
|
|
|
|
// 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();
|
|
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 (
|
|
<div className={styles.editorPane}>
|
|
<EditorToolbar />
|
|
<div className={styles.editorWrap}>
|
|
{activeId === null && <div className={styles.placeholder}>Select or create a snippet</div>}
|
|
<div className={styles.editor} ref={hostRef} />
|
|
</div>
|
|
{/* 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 && (
|
|
<pre className={styles.error} role="alert">
|
|
{error}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|