Chart theming: selectable chart theme + spec↔config merge/extract

This commit is contained in:
2026-06-12 16:48:48 +03:00
parent fe9d588103
commit 44a601affd
27 changed files with 1103 additions and 121 deletions
+175
View File
@@ -0,0 +1,175 @@
/**
* Spec ↔ config editor actions (docs/chart-theming-scope.md §4.3) — the Monaco
* command-palette / context-menu pair over the portable core operations:
*
* - **Merge chart theme into spec** bakes the *currently selected* chart theme
* (the preview's Chart theme control) into the draft's `config` block, so the
* styling travels when the spec is published outside Astrolabe. The spec's
* existing `config` keys win — rendering is unchanged.
* - **Extract config from spec** removes the draft's `config` block and copies
* it to the clipboard — for cleaning baked-in styling out of a pasted spec.
* The clipboard write happens *before* the edit, so a clipboard failure
* never destroys the only copy.
*
* Both replace the document via `executeEdits`, so ⌘Z restores the previous
* text. Both no-op (with a toast naming the reason) on invalid JSON; Monaco's
* `!editorReadonly` precondition hides them on the published view.
*
* Surfacing (council: NN/g #6 recognition-over-recall, #7 accelerators; Carbon
* menu-buttons "use an overflow menu when additional options are available and
* there is a space constraint"): the visible home is the editor toolbar's
* **Config menu** (SpecEditor), which calls `runMergeChartTheme` /
* `runExtractConfig` directly; the context-menu/palette registrations here are
* the expert accelerators on the same code paths.
*/
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
import { formatJson } from '@core/json-format';
import { extractConfigFromSpec, isJsonObject, mergeConfigIntoSpec } from '@core/spec-config';
import { CHART_THEME_OPTIONS, chartConfigForSelection } from '@core/vega-themes';
import { copyText } from '../infrastructure/file-transfer';
import { useAppStore } from '../stores/AppStore';
import { notify } from '../stores/NotificationStore';
/** Parse the model's JSON, or toast (and return null) when it isn't a JSON object. */
function parseSpecObject(model: monaco.editor.ITextModel): Record<string, unknown> | null {
let parsed: unknown;
try {
parsed = JSON.parse(model.getValue());
} catch {
notify({
kind: 'error',
title: 'Spec is not valid JSON',
message: 'Fix the JSON syntax first, then try again.',
});
return null;
}
if (!isJsonObject(parsed)) {
notify({
kind: 'error',
title: 'Spec is not a JSON object',
message: 'Config actions need a top-level { … } Vega-Lite spec.',
});
return null;
}
return parsed;
}
/** Replace the whole document as one undoable edit, in the app's JSON style. */
function replaceDocument(
editor: monaco.editor.IStandaloneCodeEditor,
model: monaco.editor.ITextModel,
source: string,
next: Record<string, unknown>,
): void {
const raw = JSON.stringify(next);
const text = formatJson(raw, { indent: model.getOptions().tabSize }) ?? raw;
// Undo stops on both sides keep the replacement its own undo step — without
// the leading stop it can coalesce with the user's preceding typing, and ⌘Z
// would revert that too (Monaco's built-in actions bracket the same way).
editor.pushUndoStop();
editor.executeEdits(source, [{ range: model.getFullModelRange(), text }]);
editor.pushUndoStop();
}
/** Bake the currently selected chart theme into the draft's `config` block. */
export function runMergeChartTheme(editor: monaco.editor.IStandaloneCodeEditor): void {
const model = editor.getModel();
if (!model) return;
const spec = parseSpecObject(model);
if (!spec) return;
const { chartTheme, uiTheme } = useAppStore.getState();
// A Config is plain JSON data; the cast bridges its closed vega-lite type
// to the JsonObject the portable merge operates on.
const themeConfig = chartConfigForSelection(chartTheme, uiTheme) as Record<string, unknown>;
if (Object.keys(themeConfig).length === 0) {
notify({
kind: 'info',
title: 'Nothing to merge',
message: 'The Stock Vega-Lite chart theme injects no config.',
});
return;
}
const themeLabel = CHART_THEME_OPTIONS.find((o) => o.value === chartTheme)?.label ?? chartTheme;
replaceDocument(editor, model, 'merge-chart-theme', mergeConfigIntoSpec(spec, themeConfig));
notify({
kind: 'success',
title: 'Chart theme merged',
message: `The ${themeLabel} theme now travels in the specs config; existing keys were kept. Undo with ⌘/Ctrl+Z.`,
});
}
/** Remove the draft's `config` block, copying it to the clipboard first. */
export async function runExtractConfig(editor: monaco.editor.IStandaloneCodeEditor): Promise<void> {
const model = editor.getModel();
if (!model) return;
const spec = parseSpecObject(model);
if (!spec) return;
const { spec: rest, config } = extractConfigFromSpec(spec);
if (config === null) {
notify({
kind: 'info',
title: 'No config to extract',
message: 'This spec has no config block.',
});
return;
}
// Copy before removing — if the clipboard write fails, the spec keeps its
// config and nothing is lost.
try {
await copyText(JSON.stringify(config, null, 2));
} catch {
notify({
kind: 'error',
title: 'Could not copy the config',
message: 'Clipboard access failed, so the spec was left unchanged.',
});
return;
}
replaceDocument(editor, model, 'extract-config', rest);
notify({
kind: 'success',
title: 'Config extracted',
message: 'The config block was removed and copied to the clipboard. Undo with ⌘/Ctrl+Z.',
});
}
/**
* Register both actions on the editor (context menu + F1 palette — the expert
* accelerators; the toolbar Config menu is the discoverable home). Returns a
* disposable that detaches them (dispose on editor unmount, like the
* format-on-paste hook).
*/
export function installSpecConfigActions(
editor: monaco.editor.IStandaloneCodeEditor,
): monaco.IDisposable {
const merge = editor.addAction({
id: 'astrolabe.merge-chart-theme',
label: 'Merge Chart Theme into Spec',
contextMenuGroupId: 'astrolabe',
contextMenuOrder: 1,
precondition: '!editorReadonly',
run: () => runMergeChartTheme(editor),
});
const extract = editor.addAction({
id: 'astrolabe.extract-config',
label: 'Extract Config from Spec',
contextMenuGroupId: 'astrolabe',
contextMenuOrder: 2,
precondition: '!editorReadonly',
run: () => runExtractConfig(editor),
});
return {
dispose() {
merge.dispose();
extract.dispose();
},
};
}