mirror of
https://github.com/mermaid-js/mermaid-live-editor.git
synced 2026-11-04 08:59:08 +00:00
feat: keep the diagram theme in step with the diagram type and site mode
Instead of leaving the config empty, the editor now manages the theme: after every validation and on site light/dark changes, syncManagedTheme sets it to the diagram type's own mermaid default (the config section's theme, e.g. redux-color for flowcharts, else the global default) or, in dark mode, the redux -dark variant (redux-dark-color) or dark. Only editor-set themes are replaced: a missing theme, the global default, a section default or any of their dark variants (isManagedTheme). A theme the user chose, such as forest, is never touched. Detector ids that do not match their config section (flowchart-v2, flowchart-elk, classDiagram, stateDiagram, xychart, railroadAbnf/Ebnf/Peg) are aliased in mermaid.ts; ids without a section (wardley, zenuml) use the global default. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
18d612f42b
commit
f87e6429dd
8 files changed
+262
-40
No files matched your search
@@ -29,6 +29,7 @@ SvelteKit 2 + Svelte 5 (runes) SPA using `@sveltejs/adapter-static`. **`docs/` i
|
||||
|
||||
- `inputState` — a `$state` rune holding `State` (code, mermaid config JSON, pan/zoom, etc.), initialized from localStorage key `codeStore`. **All writes must go through the exported update functions** (`updateCode`, `updateCodeStore`, `updateConfig`, `replaceInputState`, ...), which call `persistAndProcess()`: persist to localStorage → async re-validate via `mermaid.parse` → publish to `validatedState`. Reads inside update functions are wrapped in `untrack` so calling effects don't depend on the whole input state.
|
||||
- `validatedState` — read-only validated snapshot (adds `error`, `errorMarkers`, `serialized`, `diagramType`). Internal reads should use this, but it is never persisted or shared externally.
|
||||
- The diagram `theme` in the config is editor-managed: after every validation and on site light/dark changes, `syncManagedTheme` sets it to the diagram type's mermaid default (its config section's `theme`, e.g. `redux-color` for flowcharts) or the redux `-dark` variant / `dark` in dark mode. A theme outside that family (`isManagedTheme` in `mermaid.ts`) is the user's choice and is never touched. The default config is therefore `{}` until the first validation fills the theme in.
|
||||
- The serialized state is mirrored into the URL hash (debounced) — the URL **is** the sharing mechanism. `src/lib/util/serde.ts` encodes state as `pako:<base64 deflate>` (legacy plain-base64 supported). `mermaid.ink` PNG/SVG links, Kroki, and Mermaid Chart URLs are derived from it in `urls`.
|
||||
- App startup goes through `initHandler()` in `src/lib/util/util.ts`: migrations → load state from URL hash → optional gist/URL loading (`fileLoaders/`) → start URL-hash subscription → analytics.
|
||||
|
||||
|
||||
@@ -1,5 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getSampleDiagrams } from './mermaid';
|
||||
import { darkVariantOf, getDefaultTheme, getSampleDiagrams, isManagedTheme } from './mermaid';
|
||||
|
||||
describe('getDefaultTheme', () => {
|
||||
it.each([
|
||||
['flowchart-v2', 'redux-color'],
|
||||
['flowchart-elk', 'redux-color'],
|
||||
['classDiagram', 'redux-color'],
|
||||
['stateDiagram', 'redux-color'],
|
||||
['sequence', 'redux-color'],
|
||||
['pie', 'default'],
|
||||
['xychart', 'default'],
|
||||
['railroadAbnf', 'default'],
|
||||
['wardley', 'default']
|
||||
])('resolves %s to its config section default (%s)', (diagramType, theme) => {
|
||||
expect(getDefaultTheme(diagramType)).toBe(theme);
|
||||
});
|
||||
});
|
||||
|
||||
describe('darkVariantOf', () => {
|
||||
it.each([
|
||||
['redux-color', 'redux-dark-color'],
|
||||
['redux', 'redux-dark'],
|
||||
['redux-dark-color', 'redux-dark-color'],
|
||||
['default', 'dark'],
|
||||
['forest', 'dark']
|
||||
])('%s → %s', (theme, dark) => {
|
||||
expect(darkVariantOf(theme)).toBe(dark);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isManagedTheme', () => {
|
||||
it('treats a missing theme and every derivable default as editor-managed', () => {
|
||||
for (const theme of [undefined, 'default', 'dark', 'redux-color', 'redux-dark-color']) {
|
||||
expect(isManagedTheme(theme), String(theme)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('treats any other theme as the user’s choice', () => {
|
||||
for (const theme of ['forest', 'neutral', 'neo', 'base', 'redux', 42]) {
|
||||
expect(isManagedTheme(theme), String(theme)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSampleDiagrams', () => {
|
||||
const samples = getSampleDiagrams();
|
||||
|
||||
@@ -29,6 +29,64 @@ export const parse = async (code: string) => {
|
||||
*/
|
||||
export const defaultMermaidConfig = mermaid.mermaidAPI.defaultConfig ?? {};
|
||||
|
||||
// Detector ids (what `mermaid.parse` reports as diagramType) whose config
|
||||
// section is named differently. Every other id shares its section's name, and
|
||||
// ids without a section at all (wardley, zenuml) use the global defaults.
|
||||
const CONFIG_SECTION_ALIASES: Record<string, string> = {
|
||||
classDiagram: 'class',
|
||||
'flowchart-elk': 'flowchart',
|
||||
'flowchart-v2': 'flowchart',
|
||||
railroadAbnf: 'railroad',
|
||||
railroadEbnf: 'railroad',
|
||||
railroadPeg: 'railroad',
|
||||
stateDiagram: 'state',
|
||||
xychart: 'xyChart'
|
||||
};
|
||||
|
||||
const themeOfSection = (section: unknown): string | undefined => {
|
||||
if (section && typeof section === 'object' && 'theme' in section) {
|
||||
const { theme } = section as { theme?: unknown };
|
||||
if (typeof theme === 'string') {
|
||||
return theme;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const globalDefaultTheme: string = defaultMermaidConfig.theme ?? 'default';
|
||||
|
||||
/** Mermaid's own default theme for a diagram type: its config section's, else the global one. */
|
||||
export const getDefaultTheme = (diagramType: string): string => {
|
||||
const key = CONFIG_SECTION_ALIASES[diagramType] ?? diagramType;
|
||||
const section = (defaultMermaidConfig as Record<string, unknown>)[key];
|
||||
return themeOfSection(section) ?? globalDefaultTheme;
|
||||
};
|
||||
|
||||
/** Dark counterpart of a default theme: redux themes have -dark variants, anything else uses `dark`. */
|
||||
export const darkVariantOf = (theme: string): string => {
|
||||
if (theme.includes('dark')) {
|
||||
return theme;
|
||||
}
|
||||
return theme.startsWith('redux') ? theme.replace('redux', 'redux-dark') : 'dark';
|
||||
};
|
||||
|
||||
// Every theme the editor may set on its own: the global default, each config
|
||||
// section's default and their dark variants. Anything else is the user's choice.
|
||||
const managedThemes = new Set<string>();
|
||||
for (const theme of [
|
||||
globalDefaultTheme,
|
||||
...Object.values(defaultMermaidConfig).map(themeOfSection)
|
||||
]) {
|
||||
if (theme) {
|
||||
managedThemes.add(theme);
|
||||
managedThemes.add(darkVariantOf(theme));
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the editor may replace this theme (a missing theme counts as managed). */
|
||||
export const isManagedTheme = (theme: unknown): boolean =>
|
||||
theme === undefined || (typeof theme === 'string' && managedThemes.has(theme));
|
||||
|
||||
export const standardizeDiagramType = (diagramType: string) => {
|
||||
switch (diagramType) {
|
||||
case 'class':
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { State } from '$lib/types';
|
||||
import { flushSync } from 'svelte';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
clearDefaultThemeConfig,
|
||||
defaultState,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
updateCode,
|
||||
updateCodeStore,
|
||||
updateConfig,
|
||||
validatedState,
|
||||
verifyState
|
||||
} from './state.svelte';
|
||||
|
||||
@@ -94,44 +95,107 @@ describe('default config', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleDarkTheme', () => {
|
||||
it('sets the dark theme when the config has no theme', () => {
|
||||
// Validation (mermaid.parse) is asynchronous; these helpers wait for it.
|
||||
const themeOf = () => (JSON.parse(inputState.mermaid) as { theme?: string }).theme;
|
||||
const waitForTheme = (theme: string | undefined) =>
|
||||
vi.waitFor(() => expect(themeOf()).toBe(theme), { timeout: 10_000 });
|
||||
const settled = () =>
|
||||
vi.waitFor(
|
||||
() => {
|
||||
expect(validatedState.current.code).toBe(inputState.code);
|
||||
expect(validatedState.current.mermaid).toBe(inputState.mermaid);
|
||||
},
|
||||
{ timeout: 10_000 }
|
||||
);
|
||||
|
||||
const flowchart = 'graph TD\n A[Managed] --> B[Theme]';
|
||||
const pie = 'pie\n "A": 1\n "B": 2';
|
||||
const slow = { timeout: 15_000 };
|
||||
|
||||
describe('managed theme', () => {
|
||||
it('derives the diagram type default once the code validates', slow, async () => {
|
||||
toggleDarkTheme(false);
|
||||
updateConfig('{}');
|
||||
updateCode(flowchart);
|
||||
await waitForTheme('redux-color');
|
||||
expect(readStoredState().mermaid).toBe('{\n "theme": "redux-color"\n}');
|
||||
});
|
||||
|
||||
it('follows the diagram type when the code changes', slow, async () => {
|
||||
toggleDarkTheme(false);
|
||||
updateConfig('{}');
|
||||
updateCode(flowchart);
|
||||
await waitForTheme('redux-color');
|
||||
updateCode(pie);
|
||||
await waitForTheme('default');
|
||||
});
|
||||
|
||||
it('uses the redux dark variant in dark mode and switches back', slow, async () => {
|
||||
updateConfig('{}');
|
||||
updateCode(flowchart);
|
||||
toggleDarkTheme(true);
|
||||
expect(JSON.parse(inputState.mermaid)).toEqual({ theme: 'dark' });
|
||||
});
|
||||
|
||||
it('removes the theme again in light mode instead of pinning "default"', () => {
|
||||
updateConfig('{\n "theme": "dark"\n}');
|
||||
await waitForTheme('redux-dark-color');
|
||||
toggleDarkTheme(false);
|
||||
expect(inputState.mermaid).toBe('{}');
|
||||
expect(readStoredState().mermaid).toBe('{}');
|
||||
await waitForTheme('redux-color');
|
||||
});
|
||||
|
||||
it('drops a pinned "default" theme in light mode', () => {
|
||||
it('falls back to the dark theme for diagrams without a redux default', slow, async () => {
|
||||
updateConfig('{}');
|
||||
updateCode(pie);
|
||||
toggleDarkTheme(true);
|
||||
await waitForTheme('dark');
|
||||
toggleDarkTheme(false);
|
||||
await waitForTheme('default');
|
||||
});
|
||||
|
||||
it('replaces the legacy pinned "default" theme', slow, async () => {
|
||||
toggleDarkTheme(false);
|
||||
updateCode(flowchart);
|
||||
updateConfig('{\n "theme": "default"\n}');
|
||||
toggleDarkTheme(false);
|
||||
expect(inputState.mermaid).toBe('{}');
|
||||
await waitForTheme('redux-color');
|
||||
});
|
||||
|
||||
it('leaves a user-chosen theme alone in both modes', () => {
|
||||
it('leaves a user-chosen theme alone', slow, async () => {
|
||||
updateCode(flowchart);
|
||||
updateConfig('{\n "theme": "forest"\n}');
|
||||
toggleDarkTheme(true);
|
||||
expect(JSON.parse(inputState.mermaid)).toEqual({ theme: 'forest' });
|
||||
await settled();
|
||||
expect(themeOf()).toBe('forest');
|
||||
updateCode(pie);
|
||||
await settled();
|
||||
expect(themeOf()).toBe('forest');
|
||||
toggleDarkTheme(false);
|
||||
expect(JSON.parse(inputState.mermaid)).toEqual({ theme: 'forest' });
|
||||
await settled();
|
||||
expect(themeOf()).toBe('forest');
|
||||
});
|
||||
|
||||
it('keeps the other config keys when changing the theme', () => {
|
||||
updateConfig('{\n "theme": "default",\n "look": "neo"\n}');
|
||||
toggleDarkTheme(true);
|
||||
expect(JSON.parse(inputState.mermaid)).toEqual({ look: 'neo', theme: 'dark' });
|
||||
it('keeps the other config keys', slow, async () => {
|
||||
toggleDarkTheme(false);
|
||||
updateCode(flowchart);
|
||||
updateConfig('{\n "look": "classic"\n}');
|
||||
await waitForTheme('redux-color');
|
||||
expect(JSON.parse(inputState.mermaid)).toEqual({ look: 'classic', theme: 'redux-color' });
|
||||
});
|
||||
|
||||
it('does nothing while the config is not valid JSON', slow, async () => {
|
||||
updateCode(flowchart);
|
||||
updateConfig('{ "theme": ');
|
||||
expect(() => toggleDarkTheme(true)).not.toThrow();
|
||||
await settled();
|
||||
expect(inputState.mermaid).toBe('{ "theme": ');
|
||||
toggleDarkTheme(false);
|
||||
expect(JSON.parse(inputState.mermaid)).toEqual({ look: 'neo' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearDefaultThemeConfig migration', () => {
|
||||
// Code that does not parse has no diagram type, which keeps the managed
|
||||
// theme sync out of the way so the migration is observed on its own.
|
||||
beforeEach(async () => {
|
||||
toggleDarkTheme(false);
|
||||
updateCode('not a diagram');
|
||||
await settled();
|
||||
});
|
||||
|
||||
it('clears a config that only pins the "default" theme', () => {
|
||||
updateConfig('{\n "theme": "default"\n}');
|
||||
clearDefaultThemeConfig();
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
findMostRelevantLineNumber,
|
||||
replaceLineNumberInErrorMessage
|
||||
} from './errorHandling';
|
||||
import { parse } from './mermaid';
|
||||
import { darkVariantOf, getDefaultTheme, isManagedTheme, parse } from './mermaid';
|
||||
import { readJSON, writeJSON } from './persist.svelte';
|
||||
import { findUnsafeConfigPaths, stripConfigPaths } from './sanitize';
|
||||
import { deserializeState, pakoSerde, serializeState } from './serde';
|
||||
@@ -125,6 +125,7 @@ const persistAndProcess = (): void => {
|
||||
void processState(snapshot).then((processed) => {
|
||||
validatedCurrent = processed;
|
||||
updateHash?.(processed.serialized);
|
||||
syncManagedTheme(processed.diagramType);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -277,25 +278,46 @@ export const updateConfig = (config: string): void => {
|
||||
updateCodeStore({ mermaid: config });
|
||||
};
|
||||
|
||||
// The site's light/dark mode only manages a theme it set itself: none, `dark`,
|
||||
// or the `default` older versions seeded. Anything else is the user's choice.
|
||||
// Light mode removes the theme instead of pinning `default`, so mermaid's own
|
||||
// per-diagram defaults apply.
|
||||
export const toggleDarkTheme = (dark: boolean): void => {
|
||||
update((state) => {
|
||||
const config = JSON.parse(state.mermaid) as MermaidConfig;
|
||||
if (config.theme && !['dark', 'default'].includes(config.theme)) {
|
||||
let siteDark = false;
|
||||
|
||||
// The editor manages the diagram theme unless the user picked one of their
|
||||
// own. A missing theme, mermaid's global default, a config section's default
|
||||
// or any of their dark variants (see isManagedTheme) is replaced by the
|
||||
// current diagram type's default, or its dark counterpart while the site is
|
||||
// dark. Runs after every validation, since the diagram type may have changed,
|
||||
// and whenever the site mode changes. Converges after one update: the next
|
||||
// validation finds the theme already in place. Reads are untracked so an
|
||||
// effect calling toggleDarkTheme does not subscribe to the input state.
|
||||
const syncManagedTheme = (diagramType: string | undefined): void => {
|
||||
if (!diagramType) {
|
||||
return;
|
||||
}
|
||||
untrack(() => {
|
||||
let config: MermaidConfig;
|
||||
try {
|
||||
config = JSON.parse(input.mermaid) as MermaidConfig;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (dark) {
|
||||
config.theme = 'dark';
|
||||
} else {
|
||||
delete config.theme;
|
||||
if (!isManagedTheme(config.theme)) {
|
||||
return;
|
||||
}
|
||||
state.mermaid = formatJSON(config);
|
||||
const defaultTheme = getDefaultTheme(diagramType);
|
||||
const theme = siteDark ? darkVariantOf(defaultTheme) : defaultTheme;
|
||||
if (config.theme === theme) {
|
||||
return;
|
||||
}
|
||||
update((state) => {
|
||||
state.mermaid = formatJSON({ ...config, theme });
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
export const toggleDarkTheme = (dark: boolean): void => {
|
||||
siteDark = dark;
|
||||
syncManagedTheme(untrack(() => validatedCurrent.diagramType));
|
||||
};
|
||||
|
||||
const isOnlyDefaultTheme = (config: string): boolean => {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(config);
|
||||
|
||||
@@ -43,16 +43,17 @@ const migrationVersion = (page: Page) =>
|
||||
);
|
||||
|
||||
test.describe('Default theme config migration', () => {
|
||||
test('clears a legacy config that only pins the default theme', async ({ page }) => {
|
||||
test('replaces a legacy default-theme config with the diagram default', async ({ page }) => {
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
await seedLegacyInstall(page);
|
||||
await openEditor(page);
|
||||
await expect(page.locator('#view')).toContainText('Legacy');
|
||||
await expect.poll(() => migrationVersion(page)).toBe(1);
|
||||
await expect.poll(() => storedConfig(page)).toBe('{}');
|
||||
// A flowchart: mermaid 12's own default for it is redux-color.
|
||||
await expect.poll(() => storedConfig(page)).toBe('{\n "theme": "redux-color"\n}');
|
||||
});
|
||||
|
||||
test('switches the cleared config to the dark theme when the site is dark', async ({ page }) => {
|
||||
test('uses the redux dark variant when the site is dark', async ({ page }) => {
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
await seedLegacyInstall(page);
|
||||
await openEditor(page);
|
||||
@@ -60,6 +61,6 @@ test.describe('Default theme config migration', () => {
|
||||
await expect.poll(() => migrationVersion(page)).toBe(1);
|
||||
await expect
|
||||
.poll(async () => JSON.parse((await storedConfig(page)) ?? '{}') as unknown)
|
||||
.toEqual({ theme: 'dark' });
|
||||
.toEqual({ theme: 'redux-dark-color' });
|
||||
});
|
||||
});
|
||||
@@ -100,6 +100,8 @@ test.describe('Site Loads', () => {
|
||||
const parsedConfig = JSON.parse(parsedStore.mermaid) as Record<string, unknown>;
|
||||
expect(parsedConfig).toEqual({
|
||||
someOtherSetting: 'Test value',
|
||||
// The editor fills in the diagram type's default theme (a flowchart here).
|
||||
theme: 'redux-color',
|
||||
themeVariables: {}
|
||||
});
|
||||
// should scrub unsafe securityLevel but keep other settings
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import { expect, test } from './test';
|
||||
|
||||
const storedTheme = (page: Page) =>
|
||||
page.evaluate(() => {
|
||||
const { mermaid } = JSON.parse(localStorage.getItem('codeStore') ?? '{}') as {
|
||||
mermaid?: string;
|
||||
};
|
||||
return (JSON.parse(mermaid ?? '{}') as { theme?: string }).theme;
|
||||
});
|
||||
|
||||
test.describe('Managed theme', () => {
|
||||
test.beforeEach(async ({ editPage }) => {
|
||||
await editPage.toggleSampleDiagrams();
|
||||
});
|
||||
|
||||
test('follows the diagram type', async ({ editPage, page }) => {
|
||||
await editPage.loadSampleDiagram('Pie');
|
||||
await expect.poll(() => storedTheme(page)).toBe('default');
|
||||
await editPage.loadSampleDiagram('Flowchart');
|
||||
await expect.poll(() => storedTheme(page)).toBe('redux-color');
|
||||
});
|
||||
|
||||
test('switches to the dark variant with the site theme', async ({ editPage, page }) => {
|
||||
await editPage.loadSampleDiagram('Flowchart');
|
||||
await expect.poll(() => storedTheme(page)).toBe('redux-color');
|
||||
await editPage.toggleTheme();
|
||||
await expect.poll(() => storedTheme(page)).toBe('redux-dark-color');
|
||||
await editPage.toggleTheme();
|
||||
await expect.poll(() => storedTheme(page)).toBe('redux-color');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user