mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Chart theming: selectable chart theme + spec↔config merge/extract
This commit is contained in:
@@ -10,6 +10,8 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { chartConfigForSelection } from '@core/vega-themes';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { usePreviewStore } from '../stores/PreviewStore';
|
||||
import { useSnippetStore } from '../stores/SnippetStore';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
@@ -24,10 +26,12 @@ const H = vi.hoisted(() => ({
|
||||
calls: 0,
|
||||
pending: [] as Array<() => void>,
|
||||
destroyed: [] as number[],
|
||||
configs: [] as unknown[],
|
||||
}));
|
||||
vi.mock('../services/chart-renderer', () => ({
|
||||
renderSpec: (node: HTMLElement) => {
|
||||
renderSpec: (node: HTMLElement, _spec: unknown, config: unknown) => {
|
||||
const id = ++H.calls;
|
||||
H.configs.push(config);
|
||||
return new Promise((resolve) => {
|
||||
H.pending.push(() => {
|
||||
node.replaceChildren(); // a real embed wipes then rebuilds the host
|
||||
@@ -64,6 +68,7 @@ beforeEach(() => {
|
||||
H.calls = 0;
|
||||
H.pending.length = 0;
|
||||
H.destroyed.length = 0;
|
||||
H.configs.length = 0;
|
||||
usePreviewStore.setState({ error: null, busy: false });
|
||||
useSnippetStore.getState().reset();
|
||||
useDatasetStore.getState().reset();
|
||||
@@ -82,21 +87,27 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('LivePreview busy overlay', () => {
|
||||
// The overlay is the aria-hidden element carrying the "Rendering…" label — a
|
||||
// bare [aria-hidden] query would also match decorative bits of the header
|
||||
// controls (e.g. the chart-theme select's caret).
|
||||
const overlay = () =>
|
||||
[...container.querySelectorAll('[aria-hidden="true"]')].find((el) =>
|
||||
/rendering/i.test(el.textContent ?? ''),
|
||||
) ?? null;
|
||||
|
||||
test('does not render the busy overlay when busy=false', () => {
|
||||
// The overlay element should not be in the DOM at all during normal operation.
|
||||
expect(container.querySelector('[aria-hidden="true"]')).toBeNull();
|
||||
expect(overlay()).toBeNull();
|
||||
});
|
||||
|
||||
test('renders the busy overlay when PreviewStore.busy=true', () => {
|
||||
act(() => usePreviewStore.setState({ busy: true }));
|
||||
const overlay = container.querySelector('[aria-hidden="true"]');
|
||||
expect(overlay).not.toBeNull();
|
||||
expect(overlay()).not.toBeNull();
|
||||
});
|
||||
|
||||
test('overlay carries a visible label for sighted users', () => {
|
||||
act(() => usePreviewStore.setState({ busy: true }));
|
||||
const label = container.querySelector('[aria-hidden="true"]')?.textContent;
|
||||
expect(label).toMatch(/rendering/i);
|
||||
expect(overlay()?.textContent).toMatch(/rendering/i);
|
||||
});
|
||||
|
||||
test('the preview body carries aria-busy=true when busy', () => {
|
||||
@@ -113,9 +124,9 @@ describe('LivePreview busy overlay', () => {
|
||||
|
||||
test('overlay disappears when busy returns to false', () => {
|
||||
act(() => usePreviewStore.setState({ busy: true }));
|
||||
expect(container.querySelector('[aria-hidden="true"]')).not.toBeNull();
|
||||
expect(overlay()).not.toBeNull();
|
||||
act(() => usePreviewStore.setState({ busy: false }));
|
||||
expect(container.querySelector('[aria-hidden="true"]')).toBeNull();
|
||||
expect(overlay()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -186,3 +197,32 @@ describe('LivePreview render serialization', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('LivePreview chart theme', () => {
|
||||
const tick = (ms = 6000) => act(async () => void (await vi.advanceTimersByTimeAsync(ms)));
|
||||
|
||||
test('the selected chart theme decides the config passed to renderSpec', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
act(() => {
|
||||
useAppStore.setState({ chartTheme: 'stock', uiTheme: 'dark' });
|
||||
useSnippetStore.setState({ draftText: '{"data":{"values":[]},"mark":"point"}' });
|
||||
});
|
||||
await tick();
|
||||
act(() => H.pending[0]());
|
||||
await tick(0);
|
||||
// Stock = inject nothing; vega-lite's own defaults apply.
|
||||
expect(H.configs[0]).toEqual({});
|
||||
|
||||
// Switching the theme re-renders with the new config (no text change needed).
|
||||
act(() => useAppStore.setState({ chartTheme: 'astrolabe' }));
|
||||
await tick();
|
||||
act(() => H.pending[1]());
|
||||
await tick(0);
|
||||
expect(H.configs[1]).toEqual(chartConfigForSelection('astrolabe', 'dark'));
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
act(() => useAppStore.setState({ chartTheme: 'astrolabe', uiTheme: 'light' }));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useShallow } from 'zustand/react/shallow';
|
||||
import type { VisualizationSpec } from 'vega-embed';
|
||||
import type { FitMode } from '@core/rendering';
|
||||
import { DatasetNotFoundError, prepareSpecForRender } from '@core/rendering';
|
||||
import { chartConfigFor } from '@core/vega-themes';
|
||||
import { CHART_THEME_OPTIONS, chartConfigForSelection } from '@core/vega-themes';
|
||||
import { renderSpec, type RenderHandle } from '../services/chart-renderer';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { useDatasetStore } from '../stores/DatasetStore';
|
||||
@@ -29,6 +29,7 @@ import { selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
||||
import { useUserSettingsStore } from '../stores/UserSettingsStore';
|
||||
import { ChartExport } from './ChartExport';
|
||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||
import { SelectControl } from './SelectControl';
|
||||
import { RangeControl, SettingRow, SettingsPopover } from './SettingsPopover';
|
||||
import styles from './LivePreview.module.css';
|
||||
|
||||
@@ -68,6 +69,30 @@ function FitControl() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Chart theme picker (spec §04; docs/chart-theming-scope.md §4.2) — which config
|
||||
* charts render (and export) with. A global preference, not per-snippet: a
|
||||
* snippet's own `config` still overrides it property by property. Lives in the
|
||||
* header, not inside PreviewSettings: SelectControl and SettingsPopover share
|
||||
* the one-open-popover registry, so a select nested in the popover would close
|
||||
* (and unmount) its own parent on open.
|
||||
*/
|
||||
// TODO: header placement/crowding parked for the batched council pass (docs/ux-second-pass.md).
|
||||
function ChartThemeControl() {
|
||||
const chartTheme = useAppStore((s) => s.chartTheme);
|
||||
const setChartTheme = useAppStore((s) => s.setChartTheme);
|
||||
return (
|
||||
<SelectControl
|
||||
id="preview-chart-theme"
|
||||
label="Chart theme"
|
||||
options={CHART_THEME_OPTIONS}
|
||||
value={chartTheme}
|
||||
onSelect={setChartTheme}
|
||||
triggerTitle="Chart theme — how charts are styled when rendered and exported"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** Preview settings cluster (spec §07 → Performance), disclosed beside Fit. */
|
||||
function PreviewSettings() {
|
||||
const renderDebounce = useUserSettingsStore((s) => s.saved.performance.renderDebounce);
|
||||
@@ -100,6 +125,7 @@ export function LivePreview() {
|
||||
const shownText = useSnippetStore(selectShownText);
|
||||
const fitMode = useAppStore((s) => s.previewFitMode);
|
||||
const uiTheme = useAppStore((s) => s.uiTheme);
|
||||
const chartTheme = useAppStore((s) => s.chartTheme);
|
||||
// Datasets feed reference resolution (spec §04 step 1). Re-rendering on a
|
||||
// dataset change keeps a referencing chart live as its data is edited.
|
||||
const datasets = useDatasetStore(useShallow((s) => s.datasets));
|
||||
@@ -212,7 +238,7 @@ export function LivePreview() {
|
||||
|
||||
try {
|
||||
const prepared = prepareSpecForRender(parsed, { fitMode, datasets });
|
||||
const config = chartConfigFor(uiTheme);
|
||||
const config = chartConfigForSelection(chartTheme, uiTheme);
|
||||
handleRef.current?.destroy();
|
||||
handleRef.current = null;
|
||||
const handle = await renderSpec(node, prepared as VisualizationSpec, config);
|
||||
@@ -259,6 +285,7 @@ export function LivePreview() {
|
||||
shownText,
|
||||
fitMode,
|
||||
uiTheme,
|
||||
chartTheme,
|
||||
datasets,
|
||||
setError,
|
||||
setBusy,
|
||||
@@ -321,8 +348,9 @@ export function LivePreview() {
|
||||
<div className={styles.preview}>
|
||||
<div className={styles.header}>
|
||||
<FitControl />
|
||||
{/* Right cluster: export this chart, then the preview settings gear. */}
|
||||
{/* Right cluster: chart theme, export this chart, then the settings gear. */}
|
||||
<div className={styles.headerEnd}>
|
||||
<ChartThemeControl />
|
||||
<ChartExport chartReady={chartReady} getImageUrl={getImageUrl} />
|
||||
<PreviewSettings />
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* inline near the editor (spec §03E), mirroring the preview via PreviewStore.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, type RefObject } 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
|
||||
@@ -25,6 +25,11 @@ import '../infrastructure/monaco-env'; // side-effect: wire workers before creat
|
||||
import { configureVegaLiteJson } from '../infrastructure/monaco-schema';
|
||||
import { configureJsonFormatter, installFormatOnPaste } from '../infrastructure/monaco-format';
|
||||
import { openModal } from '../modals/ModalCoordinator';
|
||||
import {
|
||||
installSpecConfigActions,
|
||||
runExtractConfig,
|
||||
runMergeChartTheme,
|
||||
} from '../services/spec-config-actions';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
import { confirm } from '../stores/ConfirmStore';
|
||||
import { hasInlineData } from '../stores/ExtractStore';
|
||||
@@ -35,6 +40,7 @@ import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores
|
||||
import { useUserSettingsStore } from '../stores/UserSettingsStore';
|
||||
import { Icon } from './Icon';
|
||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
||||
import { SelectControl } from './SelectControl';
|
||||
import {
|
||||
NumberControl,
|
||||
RangeControl,
|
||||
@@ -139,7 +145,30 @@ configureVegaLiteJson();
|
||||
// Register the compact JSON formatter once (Format Document + format-on-paste, §03A).
|
||||
configureJsonFormatter();
|
||||
|
||||
function EditorToolbar() {
|
||||
/** 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',
|
||||
},
|
||||
] as const;
|
||||
|
||||
type ConfigActionId = (typeof CONFIG_ACTIONS)[number]['value'];
|
||||
|
||||
function EditorToolbar({
|
||||
editorRef,
|
||||
}: {
|
||||
editorRef: RefObject<monaco.editor.IStandaloneCodeEditor | null>;
|
||||
}) {
|
||||
const activeId = useSnippetStore((s) => s.activeSnippetId);
|
||||
const editorView = useSnippetStore((s) => s.editorView);
|
||||
const setEditorView = useSnippetStore((s) => s.setEditorView);
|
||||
@@ -161,6 +190,13 @@ function EditorToolbar() {
|
||||
// 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 void runExtractConfig(editor);
|
||||
};
|
||||
|
||||
const handleRevert = async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Revert draft',
|
||||
@@ -207,6 +243,17 @@ function EditorToolbar() {
|
||||
<span className={styles.actionLabel}>Extract to Dataset</span>
|
||||
</button>
|
||||
)}
|
||||
<SelectControl
|
||||
id="editor-config-actions"
|
||||
label="Spec config actions"
|
||||
heading="Spec config"
|
||||
options={CONFIG_ACTIONS}
|
||||
onSelect={handleConfigAction}
|
||||
triggerClassName={styles.action}
|
||||
triggerContent="Config"
|
||||
triggerTitle="Spec config actions — merge the chart theme in, or extract the config out"
|
||||
disabled={activeId === null || editorView === 'published'}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={`${styles.action} ${styles.collapsible}`}
|
||||
@@ -286,6 +333,11 @@ export function SpecEditor() {
|
||||
// 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);
|
||||
|
||||
// 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
|
||||
@@ -294,6 +346,7 @@ export function SpecEditor() {
|
||||
return () => {
|
||||
sub.dispose();
|
||||
pasteSub.dispose();
|
||||
configActionsSub.dispose();
|
||||
editor.dispose();
|
||||
editorRef.current = null;
|
||||
};
|
||||
@@ -336,7 +389,7 @@ export function SpecEditor() {
|
||||
|
||||
return (
|
||||
<div className={styles.editorPane}>
|
||||
<EditorToolbar />
|
||||
<EditorToolbar editorRef={editorRef} />
|
||||
<div className={styles.editorWrap}>
|
||||
{activeId === null && <div className={styles.placeholder}>Select or create a snippet</div>}
|
||||
<div className={styles.editor} ref={hostRef} />
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { defaultSettings } from '@core/settings';
|
||||
import {
|
||||
loadChartTheme,
|
||||
loadPreviewFitMode,
|
||||
loadUiTheme,
|
||||
loadUserSettings,
|
||||
saveChartTheme,
|
||||
saveManagedSettings,
|
||||
savePreviewFitMode,
|
||||
saveUiTheme,
|
||||
@@ -119,6 +121,34 @@ describe('settings-store · ui.previewFitMode', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings-store · ui.chartTheme', () => {
|
||||
beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub()));
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('defaults to astrolabe when nothing is stored', () => {
|
||||
expect(loadChartTheme()).toBe('astrolabe');
|
||||
});
|
||||
|
||||
it('returns a stored valid chart theme', () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ ui: { chartTheme: 'stock' } }));
|
||||
expect(loadChartTheme()).toBe('stock');
|
||||
});
|
||||
|
||||
it('falls back to astrolabe for an unrecognized id', () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ ui: { chartTheme: 'neon' } }));
|
||||
expect(loadChartTheme()).toBe('astrolabe');
|
||||
});
|
||||
|
||||
it('round-trips and preserves the other ui slices', () => {
|
||||
saveUiTheme('dark');
|
||||
savePreviewFitMode('height');
|
||||
saveChartTheme('powerbi');
|
||||
expect(loadChartTheme()).toBe('powerbi');
|
||||
expect(loadUiTheme()).toBe('dark');
|
||||
expect(loadPreviewFitMode()).toBe('height');
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings-store · full UserSettings record', () => {
|
||||
beforeEach(() => vi.stubGlobal('localStorage', makeStorageStub()));
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import type { FitMode } from '@core/rendering';
|
||||
import { loadSettings, type UserSettings } from '@core/settings';
|
||||
import type { UiTheme } from '@core/theme';
|
||||
import { isChartThemeId, type ChartThemeId } from '@core/vega-themes';
|
||||
|
||||
const KEY = 'astrolabe:settings';
|
||||
|
||||
@@ -28,12 +29,15 @@ const DEFAULT_THEME: UiTheme = 'light';
|
||||
/** Spec §04 — the Fit control defaults to Original. */
|
||||
const DEFAULT_FIT_MODE: FitMode = 'default';
|
||||
|
||||
/** Spec §04 — the Chart theme picker defaults to the house style. */
|
||||
const DEFAULT_CHART_THEME: ChartThemeId = 'astrolabe';
|
||||
|
||||
/** The managed slice the per-pane settings clusters own (theme + fit live in their own slices). */
|
||||
export type ManagedSettings = Pick<UserSettings, 'editor' | 'performance' | 'formatting'>;
|
||||
|
||||
/** Loose view of the stored record for the per-slice write-through merges. */
|
||||
interface StoredSettings {
|
||||
ui?: { theme?: unknown; previewFitMode?: unknown; [k: string]: unknown };
|
||||
ui?: { theme?: unknown; previewFitMode?: unknown; chartTheme?: unknown; [k: string]: unknown };
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
@@ -118,6 +122,18 @@ export function loadPreviewFitMode(): FitMode {
|
||||
return isFitMode(stored) ? stored : DEFAULT_FIT_MODE;
|
||||
}
|
||||
|
||||
/** The persisted chart theme, or the default — unknown ids fall back. */
|
||||
export function loadChartTheme(): ChartThemeId {
|
||||
const stored = readRaw().ui?.chartTheme;
|
||||
return isChartThemeId(stored) ? stored : DEFAULT_CHART_THEME;
|
||||
}
|
||||
|
||||
/** Persist the chart theme, preserving every other key already in the record. */
|
||||
export function saveChartTheme(chartTheme: ChartThemeId): void {
|
||||
const current = readRaw();
|
||||
writeRaw({ ...current, ui: { ...current.ui, chartTheme } });
|
||||
}
|
||||
|
||||
/** Persist the preview fit mode, preserving every other key already in the record. */
|
||||
export function savePreviewFitMode(fitMode: FitMode): void {
|
||||
const current = readRaw();
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
/**
|
||||
* Preference orchestration — bridges the (browser-free) AppStore to the settings
|
||||
* adapter for the small UI preferences pulled forward ahead of the M5 Settings
|
||||
* modal. Same store↔adapter pattern as theme orchestration; currently the only
|
||||
* such preference is the Live Preview fit mode (spec §04, `previewFitMode`).
|
||||
* modal. Same store↔adapter pattern as theme orchestration: the Live Preview
|
||||
* fit mode (spec §04, `previewFitMode`) and the chart theme (`chartTheme`).
|
||||
*
|
||||
* Unlike theme there is no FOUC concern (the preview renders after hydration
|
||||
* anyway), but hydrating early keeps the store the single source of truth from
|
||||
* the first render.
|
||||
*/
|
||||
|
||||
import { loadPreviewFitMode, savePreviewFitMode } from '../infrastructure/settings-store';
|
||||
import {
|
||||
loadChartTheme,
|
||||
loadPreviewFitMode,
|
||||
saveChartTheme,
|
||||
savePreviewFitMode,
|
||||
} from '../infrastructure/settings-store';
|
||||
import { useAppStore } from '../stores/AppStore';
|
||||
|
||||
/** Hydrate the persisted fit mode into the store. Call before render. */
|
||||
@@ -24,3 +29,16 @@ export function wirePreviewFitMode(): () => void {
|
||||
savePreviewFitMode(state.previewFitMode);
|
||||
});
|
||||
}
|
||||
|
||||
/** Hydrate the persisted chart theme into the store. Call before render. */
|
||||
export function initChartTheme(): void {
|
||||
useAppStore.getState().setChartTheme(loadChartTheme());
|
||||
}
|
||||
|
||||
/** Persist the chart theme on change. Returns a teardown that detaches the subscriber. */
|
||||
export function wireChartTheme(): () => void {
|
||||
return useAppStore.subscribe((state, prev) => {
|
||||
if (state.chartTheme === prev.chartTheme) return;
|
||||
saveChartTheme(state.chartTheme);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { create } from 'zustand';
|
||||
import type { FitMode } from '@core/rendering';
|
||||
import type { UiTheme } from '@core/theme';
|
||||
import type { ChartThemeId } from '@core/vega-themes';
|
||||
import type { ModalName } from '../modals/types';
|
||||
|
||||
/**
|
||||
@@ -23,6 +24,8 @@ export interface AppState {
|
||||
uiTheme: UiTheme;
|
||||
/** Preview sizing mode (spec §04); persisted to Settings as `previewFitMode`. */
|
||||
previewFitMode: FitMode;
|
||||
/** Chart theme selection (spec §04); persisted to Settings as `chartTheme`. */
|
||||
chartTheme: ChartThemeId;
|
||||
/** The currently open modal, or null. */
|
||||
activeModal: ModalName | null;
|
||||
|
||||
@@ -31,6 +34,8 @@ export interface AppState {
|
||||
toggleTheme: () => void;
|
||||
/** Set the preview fit mode — the Live Preview Fit control's action. */
|
||||
setPreviewFitMode: (mode: FitMode) => void;
|
||||
/** Set the chart theme — the Live Preview settings cluster's action. */
|
||||
setChartTheme: (theme: ChartThemeId) => void;
|
||||
/**
|
||||
* Low-level modal setter — the single primitive that mutates `activeModal`.
|
||||
* High-level open/close (snapshot for unsaved-change detection, URL sync,
|
||||
@@ -43,10 +48,12 @@ export interface AppState {
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
uiTheme: 'light',
|
||||
previewFitMode: 'default',
|
||||
chartTheme: 'astrolabe',
|
||||
activeModal: null,
|
||||
|
||||
setTheme: (uiTheme) => set({ uiTheme }),
|
||||
toggleTheme: () => set((s) => ({ uiTheme: s.uiTheme === 'dark' ? 'light' : 'dark' })),
|
||||
setPreviewFitMode: (previewFitMode) => set({ previewFitMode }),
|
||||
setChartTheme: (chartTheme) => set({ chartTheme }),
|
||||
setActiveModal: (activeModal) => set({ activeModal }),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user