mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 10:12:34 +00:00
Chart theming: selectable chart theme + spec↔config merge/extract
This commit is contained in:
@@ -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 spec’s 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user