refactor: Migrate editor state from svelte/store to runes

Replace the writable+derived store pipeline in state.ts with a
state.svelte.ts module: a deep $state inputState mutated only through
the exported update functions, each of which persists the snapshot and
re-validates it asynchronously into validatedState.current (with
urls.current derived from it). Store subscriptions in components become
$effect blocks, and the rough/grid toggles use function bindings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sidharth Vinod
2026-06-10 00:34:07 +05:30
co-authored by Claude Fable 5
parent ca96ca8771
commit b3c8bd6c28
23 changed files with 236 additions and 200 deletions
+15 -14
View File
@@ -11,7 +11,7 @@
import { getDomain } from '$/util/util'; import { getDomain } from '$/util/util';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { waitForRender } from '$lib/util/autoSync'; import { waitForRender } from '$lib/util/autoSync';
import { inputStateStore, stateStore, urlsStore } from '$lib/util/state'; import { inputState, updateCodeStore, urls, validatedState } from '$lib/util/state.svelte';
import { logEvent } from '$lib/util/stats'; import { logEvent } from '$lib/util/stats';
import { version as FAVersion } from '@fortawesome/fontawesome-free/package.json'; import { version as FAVersion } from '@fortawesome/fontawesome-free/package.json';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@@ -80,7 +80,7 @@
svg = getSvgElement(); svg = getSvgElement();
} }
if ($stateStore.rough) { if (validatedState.current.rough) {
fixForeignObjectClipping(svg); fixForeignObjectClipping(svg);
} }
@@ -106,7 +106,7 @@ ${svgString}`);
}; };
const exportImage = async (event: Event, exporter: Exporter) => { const exportImage = async (event: Event, exporter: Exporter) => {
$inputStateStore.panZoom = false; updateCodeStore({ panZoom: false });
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise((resolve) => setTimeout(resolve, 1000));
await waitForRender(); await waitForRender();
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
@@ -149,14 +149,14 @@ ${svgString}`);
const image = new Image(); const image = new Image();
image.addEventListener('load', () => { image.addEventListener('load', () => {
exporter(context, image)(); exporter(context, image)();
$inputStateStore.panZoom = true; updateCodeStore({ panZoom: true });
}); });
image.src = `data:image/svg+xml;base64,${getBase64SVG(svg, canvas.width, canvas.height)}`; image.src = `data:image/svg+xml;base64,${getBase64SVG(svg, canvas.width, canvas.height)}`;
// Fallback to set panZoom to true after 2 seconds // Fallback to set panZoom to true after 2 seconds
// This is a workaround for the case when the image is not loaded // This is a workaround for the case when the image is not loaded
setTimeout(() => { setTimeout(() => {
if (!$inputStateStore.panZoom) { if (!inputState.panZoom) {
$inputStateStore.panZoom = true; updateCodeStore({ panZoom: true });
} }
}, 2000); }, 2000);
event.stopPropagation(); event.stopPropagation();
@@ -222,7 +222,8 @@ ${svgString}`);
}; };
let gistURL = $state(''); let gistURL = $state('');
stateStore.subscribe(({ loader }) => { $effect(() => {
const { loader } = validatedState.current;
if (loader?.type === 'gist') { if (loader?.type === 'gist') {
gistURL = loader.config.url; gistURL = loader.config.url;
} }
@@ -287,10 +288,10 @@ ${svgString}`);
bind:value={imageSize} /> bind:value={imageSize} />
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
{@render dualActionButton('PNG', onDownloadPNG, $urlsStore.png)} {@render dualActionButton('PNG', onDownloadPNG, urls.current.png)}
{@render dualActionButton('SVG', onDownloadSVG, $urlsStore.svg)} {@render dualActionButton('SVG', onDownloadSVG, urls.current.svg)}
<ExternalLinkWrapper domain={getDomain($urlsStore.kroki)} isVisible={!!$urlsStore.kroki}> <ExternalLinkWrapper domain={getDomain(urls.current.kroki)} isVisible={!!urls.current.kroki}>
<a target="_blank" rel="noreferrer" class="flex-grow" href={$urlsStore.kroki}> <a target="_blank" rel="noreferrer" class="flex-grow" href={urls.current.kroki}>
<Button class="action-btn flex w-full items-center gap-2"> <Button class="action-btn flex w-full items-center gap-2">
<ExternalLinkIcon /> Kroki <ExternalLinkIcon /> Kroki
</Button> </Button>
@@ -303,9 +304,9 @@ ${svgString}`);
{/if} {/if}
<ExternalLinkWrapper <ExternalLinkWrapper
labelPrefix="Thumbnail generated by" labelPrefix="Thumbnail generated by"
domain={getDomain($urlsStore.png)} domain={getDomain(urls.current.png)}
isVisible={!!$urlsStore.mdCode}> isVisible={!!urls.current.mdCode}>
<CopyInput value={$urlsStore.mdCode} label="Copy Markdown" testID={TID.copyMarkdown} /> <CopyInput value={urls.current.mdCode} label="Copy Markdown" testID={TID.copyMarkdown} />
</ExternalLinkWrapper> </ExternalLinkWrapper>
<div class="flex w-full items-center gap-2"> <div class="flex w-full items-center gap-2">
<Input type="url" bind:value={gistURL} placeholder="Enter Gist URL" /> <Input type="url" bind:value={gistURL} placeholder="Enter Gist URL" />
+42 -42
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { EditorProps } from '$/types'; import type { EditorProps } from '$/types';
import { env } from '$/util/env'; import { env } from '$/util/env';
import { stateStore, urlsStore } from '$/util/state'; import { urls, validatedState } from '$/util/state.svelte';
import { logMermaidChartClick } from '$/util/stats'; import { logMermaidChartClick } from '$/util/stats';
import { AIPromptViewZoneManager } from '$lib/util/AIPromptViewZoneManager'; import { AIPromptViewZoneManager } from '$lib/util/AIPromptViewZoneManager';
import { initEditor } from '$lib/util/monacoExtra'; import { initEditor } from '$lib/util/monacoExtra';
@@ -150,7 +150,46 @@
onUpdate(currentText); onUpdate(currentText);
}); });
const unsubscribeState = stateStore.subscribe(({ errorMarkers, editorMode, code, mermaid }) => { editor.onMouseMove((e) => {
if (!editor) return;
if (showPopup) return;
if (editor.getModel()?.id !== mermaidModel.id) return;
lastMouseLine = e.target.position?.lineNumber ?? 0;
renderAIPromptGutterGlyphIcon();
});
editor.onMouseLeave(() => {
lastMouseLine = 0;
renderAIPromptGutterGlyphIcon();
});
applyEditorTheme(mode.current);
const resizeObserver = new ResizeObserver((entries) => {
editor?.layout({
height: entries[0].contentRect.height,
width: entries[0].contentRect.width
});
});
if (divElement.parentElement) {
resizeObserver.observe(divElement);
}
renderAIPromptGutterGlyphIcon();
return () => {
resizeObserver.disconnect();
jsonModel.dispose();
mermaidModel.dispose();
aiPromptManager.destroy();
editor?.dispose();
};
});
$effect(() => {
const { errorMarkers, editorMode, code, mermaid } = validatedState.current;
if (!editor) { if (!editor) {
return; return;
} }
@@ -191,45 +230,6 @@
// Display/clear errors // Display/clear errors
monaco.editor.setModelMarkers(model, 'mermaid', errorMarkers); monaco.editor.setModelMarkers(model, 'mermaid', errorMarkers);
}); });
editor.onMouseMove((e) => {
if (!editor) return;
if (showPopup) return;
if (editor.getModel()?.id !== mermaidModel.id) return;
lastMouseLine = e.target.position?.lineNumber ?? 0;
renderAIPromptGutterGlyphIcon();
});
editor.onMouseLeave(() => {
lastMouseLine = 0;
renderAIPromptGutterGlyphIcon();
});
applyEditorTheme(mode.current);
const resizeObserver = new ResizeObserver((entries) => {
editor?.layout({
height: entries[0].contentRect.height,
width: entries[0].contentRect.width
});
});
if (divElement.parentElement) {
resizeObserver.observe(divElement);
}
renderAIPromptGutterGlyphIcon();
return () => {
unsubscribeState();
resizeObserver.disconnect();
jsonModel.dispose();
mermaidModel.dispose();
aiPromptManager.destroy();
editor?.dispose();
};
});
</script> </script>
<div class="relative h-full grow overflow-hidden"> <div class="relative h-full grow overflow-hidden">
@@ -243,7 +243,7 @@
onTryFree={() => { onTryFree={() => {
logMermaidChartClick('vibeDiagramming'); logMermaidChartClick('vibeDiagramming');
window.open( window.open(
$urlsStore.mermaidChart({ medium: 'vibe_diagramming' }).save, urls.current.mermaidChart({ medium: 'vibe_diagramming' }).save,
'_blank', '_blank',
'noopener' 'noopener'
); );
@@ -4,7 +4,7 @@
import type { DocumentationConfig } from '$/types'; import type { DocumentationConfig } from '$/types';
import { env } from '$/util/env'; import { env } from '$/util/env';
import { standardizeDiagramType } from '$/util/mermaid'; import { standardizeDiagramType } from '$/util/mermaid';
import { stateStore } from '$/util/state'; import { validatedState } from '$/util/state.svelte';
import BookIcon from '~icons/material-symbols/book-2-outline-rounded'; import BookIcon from '~icons/material-symbols/book-2-outline-rounded';
const docURLBase = env.docsUrl; const docURLBase = env.docsUrl;
@@ -92,7 +92,7 @@
} as const satisfies DocumentationConfig; } as const satisfies DocumentationConfig;
const doc = $derived.by(() => { const doc = $derived.by(() => {
const { editorMode, diagramType } = $stateStore; const { editorMode, diagramType } = validatedState.current;
if (!diagramType) { if (!diagramType) {
return { key: '', url: docURLBase }; return { key: '', url: docURLBase };
} }
+8 -8
View File
@@ -6,14 +6,14 @@
import { Button } from '$/components/ui/button'; import { Button } from '$/components/ui/button';
import { TID } from '$/constants'; import { TID } from '$/constants';
import { env } from '$/util/env'; import { env } from '$/util/env';
import { stateStore, updateCode, updateConfig, urlsStore } from '$lib/util/state'; import { updateCode, updateConfig, urls, validatedState } from '$lib/util/state.svelte';
import { logMermaidChartClick } from '$lib/util/stats'; import { logMermaidChartClick } from '$lib/util/stats';
import { debounce } from 'lodash-es'; import { debounce } from 'lodash-es';
import ExclamationCircleIcon from '~icons/material-symbols/error-outline-rounded'; import ExclamationCircleIcon from '~icons/material-symbols/error-outline-rounded';
const { isMobile } = $props<{ isMobile: boolean }>(); const { isMobile } = $props<{ isMobile: boolean }>();
const onUpdate = (text: string) => { const onUpdate = (text: string) => {
if ($stateStore.editorMode === 'code') { if (validatedState.current.editorMode === 'code') {
updateCode(text); updateCode(text);
} else { } else {
updateConfig(text); updateConfig(text);
@@ -27,7 +27,7 @@
}, 3000); }, 3000);
$effect(() => { $effect(() => {
if ($stateStore.error) { if (validatedState.current.error) {
showErrorDebounced(); showErrorDebounced();
} else { } else {
showErrorDebounced.cancel(); showErrorDebounced.cancel();
@@ -46,27 +46,27 @@
{:else} {:else}
<DesktopEditor {onUpdate} /> <DesktopEditor {onUpdate} />
{/if} {/if}
{#if showError && $stateStore.error instanceof Error} {#if showError && validatedState.current.error instanceof Error}
<div class="flex flex-col text-sm" data-testid={TID.errorContainer}> <div class="flex flex-col text-sm" data-testid={TID.errorContainer}>
<div class="flex items-center justify-between gap-2 bg-slate-900 p-2 text-white"> <div class="flex items-center justify-between gap-2 bg-slate-900 p-2 text-white">
<div class="flex w-fit items-center gap-2"> <div class="flex w-fit items-center gap-2">
<ExclamationCircleIcon class="size-6 text-destructive" aria-hidden="true" /> <ExclamationCircleIcon class="size-6 text-destructive" aria-hidden="true" />
<div class="flex flex-col"> <div class="flex flex-col">
<p>Syntax error</p> <p>Syntax error</p>
{#if env.isEnabledMermaidChartLinks && $stateStore.editorMode === 'code'} {#if env.isEnabledMermaidChartLinks && validatedState.current.editorMode === 'code'}
<p class="text-xs text-white/60" data-testid={TID.aiHelpText}> <p class="text-xs text-white/60" data-testid={TID.aiHelpText}>
Create a free account to repair with AI Create a free account to repair with AI
</p> </p>
{/if} {/if}
</div> </div>
</div> </div>
{#if $stateStore.editorMode === 'code'} {#if validatedState.current.editorMode === 'code'}
<McWrapper> <McWrapper>
<Button <Button
variant="accent" variant="accent"
size="sm" size="sm"
data-testid={TID.aiRepairButton} data-testid={TID.aiRepairButton}
href={$urlsStore.mermaidChart({ medium: 'ai_repair' }).save} href={urls.current.mermaidChart({ medium: 'ai_repair' }).save}
target="_blank" target="_blank"
onclick={() => logMermaidChartClick('aiRepair')}> onclick={() => logMermaidChartClick('aiRepair')}>
<MermaidChartIcon /> <MermaidChartIcon />
@@ -76,7 +76,7 @@
{/if} {/if}
</div> </div>
<output class="max-h-32 overflow-auto bg-muted p-2" name="mermaid-error" for="editor"> <output class="max-h-32 overflow-auto bg-muted p-2" name="mermaid-error" for="editor">
<pre>{$stateStore.error?.toString()}</pre> <pre>{validatedState.current.error?.toString()}</pre>
</output> </output>
</div> </div>
{/if} {/if}
@@ -3,7 +3,7 @@
import MermaidChartIcon from '$/components/MermaidChartIcon.svelte'; import MermaidChartIcon from '$/components/MermaidChartIcon.svelte';
import { Button } from '$/components/ui/button'; import { Button } from '$/components/ui/button';
import { standardizeDiagramType } from '$/util/mermaid'; import { standardizeDiagramType } from '$/util/mermaid';
import { stateStore, urlsStore } from '$/util/state'; import { validatedState, urls } from '$/util/state.svelte';
import { logMermaidChartClick } from '$/util/stats'; import { logMermaidChartClick } from '$/util/stats';
import { quintInOut } from 'svelte/easing'; import { quintInOut } from 'svelte/easing';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
@@ -19,7 +19,7 @@
]); ]);
const diagramType = $derived.by(() => { const diagramType = $derived.by(() => {
const dt = $stateStore.diagramType; const dt = validatedState.current.diagramType;
return dt ? standardizeDiagramType(dt) : undefined; return dt ? standardizeDiagramType(dt) : undefined;
}); });
@@ -39,7 +39,7 @@
let currentActionIndex = $state(0); let currentActionIndex = $state(0);
const availableActions = $derived.by<EnhancedEditAction[]>(() => { const availableActions = $derived.by<EnhancedEditAction[]>(() => {
if (!$stateStore.diagramType) { if (!validatedState.current.diagramType) {
return []; return [];
} }
@@ -98,7 +98,7 @@
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
href={$urlsStore.mermaidChart({ href={urls.current.mermaidChart({
medium: currentAction.medium, medium: currentAction.medium,
campaign: currentAction.campaign campaign: currentAction.campaign
}).save} }).save}
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { stateStore } from '$/util/state'; import { validatedState } from '$/util/state.svelte';
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
import type { ComponentProps, Snippet } from 'svelte'; import type { ComponentProps, Snippet } from 'svelte';
import ExternalLinkIcon from '~icons/material-symbols/open-in-new-rounded'; import ExternalLinkIcon from '~icons/material-symbols/open-in-new-rounded';
@@ -25,7 +25,7 @@
} = $props(); } = $props();
let shouldDisableComponent = $derived( let shouldDisableComponent = $derived(
shouldCheckDiagramType && $stateStore.diagramType === 'zenuml' shouldCheckDiagramType && validatedState.current.diagramType === 'zenuml'
); );
</script> </script>
+3 -3
View File
@@ -3,7 +3,7 @@
import type { HistoryEntry, HistoryType, State, Tab } from '$lib/types'; import type { HistoryEntry, HistoryType, State, Tab } from '$lib/types';
import { notify, prompt } from '$lib/util/notify'; import { notify, prompt } from '$lib/util/notify';
import { serializeState } from '$lib/util/serde'; import { serializeState } from '$lib/util/serde';
import { inputStateStore } from '$lib/util/state'; import { inputState, replaceInputState } from '$lib/util/state.svelte';
import { logEvent } from '$lib/util/stats'; import { logEvent } from '$lib/util/stats';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import dayjsRelativeTime from 'dayjs/plugin/relativeTime'; import dayjsRelativeTime from 'dayjs/plugin/relativeTime';
@@ -87,7 +87,7 @@
}; };
const saveHistory = () => { const saveHistory = () => {
if (!addManualEntry($inputStateStore)) { if (!addManualEntry($state.snapshot(inputState))) {
notify('State already saved.'); notify('State already saved.');
} }
}; };
@@ -99,7 +99,7 @@
}; };
const restoreHistoryItem = (state: State): void => { const restoreHistoryItem = (state: State): void => {
inputStateStore.set({ ...state, updateDiagram: true }); replaceInputState({ ...state, updateDiagram: true });
}; };
// Absolute editor URL for an entry, so the link can be opened in a new tab or copied. // Absolute editor URL for an entry, so the link can be opened in a new tab or copied.
@@ -1,8 +1,7 @@
import type { HistoryEntry, HistoryType, Optional, State } from '$lib/types'; import type { HistoryEntry, HistoryType, Optional, State } from '$lib/types';
import { inputStateStore } from '$lib/util/state'; import { inputState } from '$lib/util/state.svelte';
import { logEvent } from '$lib/util/stats'; import { logEvent } from '$lib/util/stats';
import { generateSlug } from 'random-word-slugs'; import { generateSlug } from 'random-word-slugs';
import { get } from 'svelte/store';
import { v4 as uuidV4 } from 'uuid'; import { v4 as uuidV4 } from 'uuid';
const MAX_AUTO_HISTORY_LENGTH = 30; const MAX_AUTO_HISTORY_LENGTH = 30;
@@ -199,7 +198,10 @@ let autoSaveTimer: ReturnType<typeof setInterval> | undefined;
// Idempotent; returns the stop function for use as a lifecycle cleanup. // Idempotent; returns the stop function for use as a lifecycle cleanup.
export const startAutoSave = (): (() => void) => { export const startAutoSave = (): (() => void) => {
if (autoSaveTimer === undefined) { if (autoSaveTimer === undefined) {
autoSaveTimer = setInterval(() => addAutoEntry(get(inputStateStore)), AUTO_SAVE_INTERVAL); autoSaveTimer = setInterval(
() => addAutoEntry($state.snapshot(inputState)),
AUTO_SAVE_INTERVAL
);
} }
return stopAutoSave; return stopAutoSave;
}; };
@@ -1,5 +1,5 @@
import type { HistoryEntry } from '$lib/types'; import type { HistoryEntry } from '$lib/types';
import { defaultState, inputStateStore } from '$lib/util/state'; import { defaultState, replaceInputState } from '$lib/util/state.svelte';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { import {
addAutoEntry, addAutoEntry,
@@ -284,7 +284,7 @@ describe('auto-save lifecycle', () => {
it('records an auto entry on each interval from the current editor state', () => { it('records an auto entry on each interval from the current editor state', () => {
vi.useFakeTimers(); vi.useFakeTimers();
inputStateStore.set(codeState('graph TD\n auto-saved')); replaceInputState(codeState('graph TD\n auto-saved'));
startAutoSave(); startAutoSave();
vi.advanceTimersByTime(60_000); vi.advanceTimersByTime(60_000);
const entries = entriesFor('auto'); const entries = entriesFor('auto');
@@ -294,7 +294,7 @@ describe('auto-save lifecycle', () => {
it('is idempotent: calling startAutoSave twice does not double-record', () => { it('is idempotent: calling startAutoSave twice does not double-record', () => {
vi.useFakeTimers(); vi.useFakeTimers();
inputStateStore.set(codeState('graph TD\n once')); replaceInputState(codeState('graph TD\n once'));
startAutoSave(); startAutoSave();
startAutoSave(); startAutoSave();
vi.advanceTimersByTime(60_000); vi.advanceTimersByTime(60_000);
@@ -303,11 +303,11 @@ describe('auto-save lifecycle', () => {
it('stops recording after stopAutoSave', () => { it('stops recording after stopAutoSave', () => {
vi.useFakeTimers(); vi.useFakeTimers();
inputStateStore.set(codeState('graph TD\n stoppable')); replaceInputState(codeState('graph TD\n stoppable'));
startAutoSave(); startAutoSave();
vi.advanceTimersByTime(60_000); vi.advanceTimersByTime(60_000);
stopAutoSave(); stopAutoSave();
inputStateStore.set(codeState('graph TD\n after-stop')); replaceInputState(codeState('graph TD\n after-stop'));
vi.advanceTimersByTime(60_000); vi.advanceTimersByTime(60_000);
expect(entriesFor('auto')).toHaveLength(1); expect(entriesFor('auto')).toHaveLength(1);
}); });
+5 -5
View File
@@ -3,7 +3,7 @@
import * as Popover from '$/components/ui/popover'; import * as Popover from '$/components/ui/popover';
import { Switch } from '$/components/ui/switch'; import { Switch } from '$/components/ui/switch';
import { env } from '$/util/env'; import { env } from '$/util/env';
import { urlsStore } from '$/util/state'; import { urls } from '$/util/state.svelte';
import { logMermaidChartClick } from '$/util/stats'; import { logMermaidChartClick } from '$/util/stats';
import { cn } from '$/utils'; import { cn } from '$/utils';
import { mode, setMode } from 'mode-watcher'; import { mode, setMode } from 'mode-watcher';
@@ -32,10 +32,10 @@
} }
const menuItems: MenuItem[] = $derived([ const menuItems: MenuItem[] = $derived([
{ label: 'New', icon: AddIcon, href: $urlsStore.new, renderer: menuItem }, { label: 'New', icon: AddIcon, href: urls.current.new, renderer: menuItem },
{ label: 'Duplicate', icon: DuplicateIcon, href: window.location.href, renderer: menuItem }, { label: 'Duplicate', icon: DuplicateIcon, href: window.location.href, renderer: menuItem },
{ {
href: $urlsStore.mermaidChart({ medium: 'main_menu' }).playground, href: urls.current.mermaidChart({ medium: 'main_menu' }).playground,
icon: PlaygroundIcon, icon: PlaygroundIcon,
isSectionEnd: true, isSectionEnd: true,
label: 'Edit in Playground', label: 'Edit in Playground',
@@ -62,7 +62,7 @@
}, },
{ {
checkDiagramType: false, checkDiagramType: false,
href: $urlsStore.mermaidChart({ medium: 'main_menu' }).plugins, href: urls.current.mermaidChart({ medium: 'main_menu' }).plugins,
icon: PluginIcon, icon: PluginIcon,
label: 'Plugins', label: 'Plugins',
onclick: () => logMermaidChartClick('plugins'), onclick: () => logMermaidChartClick('plugins'),
@@ -79,7 +79,7 @@
{ {
checkDiagramType: false, checkDiagramType: false,
class: 'text-accent border-b-0', class: 'text-accent border-b-0',
href: $urlsStore.mermaidChart({ medium: 'main_menu' }).home, href: urls.current.mermaidChart({ medium: 'main_menu' }).home,
icon: MermaidChartIcon, icon: MermaidChartIcon,
label: 'Mermaid', label: 'Mermaid',
onclick: () => logMermaidChartClick('mermaidHome'), onclick: () => logMermaidChartClick('mermaidHome'),
+9 -10
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { EditorProps } from '$/types'; import type { EditorProps } from '$/types';
import { stateStore } from '$/util/state'; import { validatedState } from '$/util/state.svelte';
import { json, jsonLanguage } from '@codemirror/lang-json'; import { json, jsonLanguage } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown'; import { markdown } from '@codemirror/lang-markdown';
import { yamlFrontmatter } from '@codemirror/lang-yaml'; import { yamlFrontmatter } from '@codemirror/lang-yaml';
@@ -17,6 +17,7 @@
let editorContainer: HTMLDivElement; let editorContainer: HTMLDivElement;
let currentText = $state(''); let currentText = $state('');
const themeCompartment = new Compartment(); const themeCompartment = new Compartment();
const languageCompartment = new Compartment();
const { onUpdate }: EditorProps = $props(); const { onUpdate }: EditorProps = $props();
@@ -27,8 +28,6 @@
}); });
onMount(() => { onMount(() => {
const languageCompartment = new Compartment();
editorView = new EditorView({ editorView = new EditorView({
state: EditorState.create({ state: EditorState.create({
doc: currentText, doc: currentText,
@@ -62,7 +61,13 @@
parent: editorContainer parent: editorContainer
}); });
const unsubscribeState = stateStore.subscribe(({ editorMode, code, mermaid }) => { return () => {
editorView?.destroy();
};
});
$effect(() => {
const { editorMode, code, mermaid } = validatedState.current;
const text = editorMode === 'code' ? code : mermaid; const text = editorMode === 'code' ? code : mermaid;
if (currentText === text || !editorView) { if (currentText === text || !editorView) {
return; return;
@@ -87,12 +92,6 @@
) )
}); });
}); });
return () => {
unsubscribeState();
editorView?.destroy();
};
});
</script> </script>
<div bind:this={editorContainer} class="size-full"></div> <div bind:this={editorContainer} class="size-full"></div>
+2 -2
View File
@@ -3,7 +3,7 @@
import { Button } from '$/components/ui/button'; import { Button } from '$/components/ui/button';
import { Separator } from '$/components/ui/separator'; import { Separator } from '$/components/ui/separator';
import type { PanZoomState } from '$/util/panZoom'; import type { PanZoomState } from '$/util/panZoom';
import { urlsStore } from '$/util/state'; import { urls } from '$/util/state.svelte';
import ExpandIcon from '~icons/material-symbols/open-in-full-rounded'; import ExpandIcon from '~icons/material-symbols/open-in-full-rounded';
import ArrowsToCircleIcon from '~icons/material-symbols/screenshot-frame-2'; import ArrowsToCircleIcon from '~icons/material-symbols/screenshot-frame-2';
import MagnifyingGlassPlusIcon from '~icons/material-symbols/zoom-in'; import MagnifyingGlassPlusIcon from '~icons/material-symbols/zoom-in';
@@ -28,7 +28,7 @@
<MagnifyingGlassPlusIcon /> <MagnifyingGlassPlusIcon />
</Button> </Button>
<Separator orientation="vertical" class="hidden sm:block" /> <Separator orientation="vertical" class="hidden sm:block" />
<Button variant="ghost" size="icon" title="Full Screen" href={$urlsStore.view} target="_blank"> <Button variant="ghost" size="icon" title="Full Screen" href={urls.current.view} target="_blank">
<ExpandIcon /> <ExpandIcon />
</Button> </Button>
</FloatingToolbar> </FloatingToolbar>
+1 -1
View File
@@ -2,7 +2,7 @@
import Card from '$/components/Card/Card.svelte'; import Card from '$/components/Card/Card.svelte';
import { Button } from '$/components/ui/button'; import { Button } from '$/components/ui/button';
import { getSampleDiagrams } from '$/util/mermaid'; import { getSampleDiagrams } from '$/util/mermaid';
import { updateCode } from '$lib/util/state'; import { updateCode } from '$lib/util/state.svelte';
import { logEvent } from '$lib/util/stats'; import { logEvent } from '$lib/util/stats';
import ShapesIcon from '~icons/material-symbols/account-tree-outline-rounded'; import ShapesIcon from '~icons/material-symbols/account-tree-outline-rounded';
+2 -2
View File
@@ -3,7 +3,7 @@
import * as Dialog from '$/components/ui/dialog'; import * as Dialog from '$/components/ui/dialog';
import { Separator } from '$/components/ui/separator'; import { Separator } from '$/components/ui/separator';
import { env } from '$/util/env'; import { env } from '$/util/env';
import { urlsStore } from '$/util/state'; import { urls } from '$/util/state.svelte';
import { asset } from '$app/paths'; import { asset } from '$app/paths';
import ShareIcon from '~icons/material-symbols/share'; import ShareIcon from '~icons/material-symbols/share';
import CopyInput from './CopyInput.svelte'; import CopyInput from './CopyInput.svelte';
@@ -38,7 +38,7 @@
<MermaidChartIcon class="size-5" /> <MermaidChartIcon class="size-5" />
Mermaid Chart Playground Mermaid Chart Playground
</h2> </h2>
<CopyInput value={$urlsStore.mermaidChart({ medium: 'share' }).playground} /> <CopyInput value={urls.current.mermaidChart({ medium: 'share' }).playground} />
<Dialog.Description> <Dialog.Description>
Opens the Mermaid Chart Playground with Mermaid AI, Visual Editor, and more. Opens the Mermaid Chart Playground with Mermaid AI, Visual Editor, and more.
</Dialog.Description> </Dialog.Description>
+11 -5
View File
@@ -1,21 +1,27 @@
<script lang="ts"> <script lang="ts">
import FloatingToolbar from '$/components/FloatingToolbar.svelte'; import FloatingToolbar from '$/components/FloatingToolbar.svelte';
import { Toggle } from '$/components/ui/toggle'; import { Toggle } from '$/components/ui/toggle';
import { defaultState, inputStateStore } from '$/util/state'; import { defaultState, inputState, updateCodeStore } from '$/util/state.svelte';
import RoughIcon from '~icons/material-symbols/draw-outline-rounded'; import RoughIcon from '~icons/material-symbols/draw-outline-rounded';
import BackgroundIcon from '~icons/material-symbols/grid-4x4-rounded'; import BackgroundIcon from '~icons/material-symbols/grid-4x4-rounded';
if ($inputStateStore.grid === undefined) { if (inputState.grid === undefined) {
// Handle cases where old states were saved without grid option // Handle cases where old states were saved without grid option
$inputStateStore.grid = defaultState.grid; updateCodeStore({ grid: defaultState.grid });
} }
</script> </script>
<FloatingToolbar> <FloatingToolbar>
<Toggle bind:pressed={$inputStateStore.rough} size="sm" title="Hand-Drawn"> <Toggle
bind:pressed={() => inputState.rough, (rough) => updateCodeStore({ rough })}
size="sm"
title="Hand-Drawn">
<RoughIcon /> <RoughIcon />
</Toggle> </Toggle>
<Toggle bind:pressed={$inputStateStore.grid} size="sm" title="Background Grid"> <Toggle
bind:pressed={() => inputState.grid ?? defaultState.grid, (grid) => updateCodeStore({ grid })}
size="sm"
title="Background Grid">
<BackgroundIcon /> <BackgroundIcon />
</Toggle> </Toggle>
</FloatingToolbar> </FloatingToolbar>
+6 -4
View File
@@ -3,7 +3,7 @@
import { recordRenderTime, shouldRefreshView } from '$/util/autoSync'; import { recordRenderTime, shouldRefreshView } from '$/util/autoSync';
import { render as renderDiagram } from '$/util/mermaid'; import { render as renderDiagram } from '$/util/mermaid';
import { PanZoomState } from '$/util/panZoom'; import { PanZoomState } from '$/util/panZoom';
import { inputStateStore, stateStore, updateCodeStore } from '$/util/state'; import { updateCodeStore, validatedState } from '$/util/state.svelte';
import { saveStatistics } from '$/util/stats'; import { saveStatistics } from '$/util/stats';
import FontAwesome, { mayContainFontAwesome } from '$lib/components/FontAwesome.svelte'; import FontAwesome, { mayContainFontAwesome } from '$lib/components/FontAwesome.svelte';
import uniqueID from 'lodash-es/uniqueId'; import uniqueID from 'lodash-es/uniqueId';
@@ -133,19 +133,21 @@
const renderTime = Date.now() - startTime; const renderTime = Date.now() - startTime;
saveStatistics({ code, diagramType, isRough: state.rough, renderTime }); saveStatistics({ code, diagramType, isRough: state.rough, renderTime });
recordRenderTime(renderTime, () => { recordRenderTime(renderTime, () => {
$inputStateStore.updateDiagram = true; updateCodeStore({ updateDiagram: true });
}); });
}; };
onMount(() => { onMount(() => {
setupPanZoomObserver(); setupPanZoomObserver();
});
// Queue state changes to avoid race condition // Queue state changes to avoid race condition
let pendingStateChange = Promise.resolve(); let pendingStateChange = Promise.resolve();
stateStore.subscribe((state) => { $effect(() => {
const state = validatedState.current;
// eslint-disable-next-line @typescript-eslint/no-empty-function // eslint-disable-next-line @typescript-eslint/no-empty-function
pendingStateChange = pendingStateChange.then(() => handleStateChange(state).catch(() => {})); pendingStateChange = pendingStateChange.then(() => handleStateChange(state).catch(() => {}));
}); });
});
</script> </script>
<FontAwesome bind:waitForFontAwesomeToLoad /> <FontAwesome bind:waitForFontAwesomeToLoad />
+1 -1
View File
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { setLoaderEntries } from '$lib/components/History/historyState.svelte'; import { setLoaderEntries } from '$lib/components/History/historyState.svelte';
import type { State } from '$lib/types'; import type { State } from '$lib/types';
import { defaultState } from '$lib/util/state'; import { defaultState } from '$lib/util/state.svelte';
import { fetchJSON, fetchText } from '$lib/util/util'; import { fetchJSON, fetchText } from '$lib/util/util';
const codeFileName = 'code.mmd'; const codeFileName = 'code.mmd';
+1 -1
View File
@@ -1,5 +1,5 @@
import type { Loader, State } from '$lib/types'; import type { Loader, State } from '$lib/types';
import { defaultState, sanitizeConfig, updateCodeStore } from '$lib/util/state'; import { defaultState, sanitizeConfig, updateCodeStore } from '$lib/util/state.svelte';
import { fetchText } from '$lib/util/util'; import { fetchText } from '$lib/util/util';
import { loadGistData } from './gist'; import { loadGistData } from './gist';
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { serializeState, deserializeState, type SerdeType } from './serde'; import { serializeState, deserializeState, type SerdeType } from './serde';
import { defaultState } from './state'; import { defaultState } from './state.svelte';
import type { State } from '$lib/types'; import type { State } from '$lib/types';
const verifySerde = (state: State, serde?: SerdeType): string => { const verifySerde = (state: State, serde?: SerdeType): string => {
@@ -2,7 +2,6 @@ import type { ErrorHash, MarkerData, State, ValidatedState } from '$/types';
import { resolve } from '$app/paths'; import { resolve } from '$app/paths';
import { debounce, get as lodashGet } from 'lodash-es'; import { debounce, get as lodashGet } from 'lodash-es';
import type { MermaidConfig } from 'mermaid'; import type { MermaidConfig } from 'mermaid';
import { derived, get, writable, type Readable } from 'svelte/store';
import { env } from './env'; import { env } from './env';
import { import {
extractErrorLineText, extractErrorLineText,
@@ -10,7 +9,7 @@ import {
replaceLineNumberInErrorMessage replaceLineNumberInErrorMessage
} from './errorHandling'; } from './errorHandling';
import { defaultMermaidConfig, parse } from './mermaid'; import { defaultMermaidConfig, parse } from './mermaid';
import { localStorage, persist } from './persist'; import { readJSON, writeJSON } from './persist.svelte';
import { deserializeState, pakoSerde, serializeState } from './serde'; import { deserializeState, pakoSerde, serializeState } from './serde';
import { errorDebug, formatJSON, getUTMSource, MCBaseURL } from './util'; import { errorDebug, formatJSON, getUTMSource, MCBaseURL } from './util';
@@ -42,19 +41,21 @@ const urlParseFailedState = `flowchart TD
G --> |"No :("| H(Try using the Timeline tab in History <br/>from same browser you used to create the diagram.) G --> |"No :("| H(Try using the Timeline tab in History <br/>from same browser you used to create the diagram.)
click D href "https://github.com/mermaid-js/mermaid-live-editor/issues/new?assignees=&labels=bug&template=bug_report.md&title=Broken%20link" "Raise issue"`; click D href "https://github.com/mermaid-js/mermaid-live-editor/issues/new?assignees=&labels=bug&template=bug_report.md&title=Broken%20link" "Raise issue"`;
// inputStateStore handles all updates and is shared externally when exporting via URL, History, etc. // inputState handles all updates and is shared externally when exporting via URL, History, etc.
export const inputStateStore = persist(writable(defaultState), localStorage(), 'codeStore'); // It is reactive for reads; every write must go through the update functions
// below so each change is persisted and re-validated.
// The fallback is cloned so mutations never write through to defaultState.
export const inputState = $state<State>(readJSON('codeStore', { ...defaultState }));
export const currentState: ValidatedState = (() => { const validatedStateOf = (state: State): ValidatedState => ({
const state = get(inputStateStore);
return {
...state, ...state,
editorMode: state.editorMode ?? 'code', editorMode: state.editorMode ?? 'code',
error: undefined, error: undefined,
errorMarkers: [], errorMarkers: [],
serialized: serializeState(state) serialized: serializeState(state)
}; });
})();
let validatedCurrent = $state<ValidatedState>(validatedStateOf($state.snapshot(inputState)));
let lastDiagramType = ''; let lastDiagramType = '';
@@ -120,16 +121,31 @@ const processState = async (state: State) => {
return processed; return processed;
}; };
// All internal reads should be done via stateStore, but it should not be persisted/shared externally. // Replaces the old URL-hash store subscription; assigned by initURLSubscription.
export const stateStore: Readable<ValidatedState> = derived( let updateHash: ((serialized: string) => void) | undefined;
[inputStateStore],
([state], set) => {
void processState(state).then(set);
},
currentState
);
export const urlsStore = derived([stateStore], ([{ code, serialized }]) => { // Persist the current input state and asynchronously re-validate it,
// publishing the result to `validatedState` (and the URL hash, once
// initURLSubscription has run).
const persistAndProcess = (): void => {
const snapshot = $state.snapshot(inputState);
writeJSON('codeStore', snapshot);
void processState(snapshot).then((processed) => {
validatedCurrent = processed;
updateHash?.(processed.serialized);
});
};
// All internal reads should be done via validatedState, but it should not be
// persisted/shared externally.
export const validatedState = {
get current(): ValidatedState {
return validatedCurrent;
}
};
const urlsCurrent = $derived.by(() => {
const { code, serialized } = validatedCurrent;
const { krokiRendererUrl, rendererUrl } = env; const { krokiRendererUrl, rendererUrl } = env;
const png = rendererUrl ? `${rendererUrl}/img/${serialized}?type=png` : ''; const png = rendererUrl ? `${rendererUrl}/img/${serialized}?type=png` : '';
return { return {
@@ -170,6 +186,12 @@ export const urlsStore = derived([stateStore], ([{ code, serialized }]) => {
}; };
}); });
export const urls = {
get current() {
return urlsCurrent;
}
};
/** /**
* Gets a list of paths that contain unsafe keys which might pose security risks. * Gets a list of paths that contain unsafe keys which might pose security risks.
* *
@@ -256,7 +278,7 @@ export const loadState = (data: string): void => {
state = deserializeState(data); state = deserializeState(data);
state.mermaid = sanitizeConfig(state.mermaid || defaultState.mermaid); state.mermaid = sanitizeConfig(state.mermaid || defaultState.mermaid);
} catch (error) { } catch (error) {
state = get(inputStateStore); state = $state.snapshot(inputState);
if (data) { if (data) {
console.error('Init error', error); console.error('Init error', error);
state.code = urlParseFailedState; state.code = urlParseFailedState;
@@ -268,10 +290,9 @@ export const loadState = (data: string): void => {
let renderCount = 0; let renderCount = 0;
export const updateCodeStore = (newState: Partial<State>): void => { export const updateCodeStore = (newState: Partial<State>): void => {
inputStateStore.update((state) => {
renderCount++; renderCount++;
return { ...state, ...newState, renderCount }; Object.assign(inputState, newState, { renderCount });
}); persistAndProcess();
}; };
export const updateCode = ( export const updateCode = (
@@ -283,13 +304,13 @@ export const updateCode = (
): void => { ): void => {
errorDebug(); errorDebug();
inputStateStore.update((state) => {
if (resetPanZoom) { if (resetPanZoom) {
state.pan = undefined; inputState.pan = undefined;
state.zoom = undefined; inputState.zoom = undefined;
} }
return { ...state, code, updateDiagram }; inputState.code = code;
}); inputState.updateDiagram = updateDiagram;
persistAndProcess();
}; };
export const updateConfig = (config: string): void => { export const updateConfig = (config: string): void => {
@@ -297,29 +318,34 @@ export const updateConfig = (config: string): void => {
}; };
export const toggleDarkTheme = (dark: boolean): void => { export const toggleDarkTheme = (dark: boolean): void => {
inputStateStore.update((state) => { const config = JSON.parse(inputState.mermaid) as MermaidConfig;
const config = JSON.parse(state.mermaid) as MermaidConfig;
if (!config.theme || ['dark', 'default'].includes(config.theme)) { if (!config.theme || ['dark', 'default'].includes(config.theme)) {
config.theme = dark ? 'dark' : 'default'; config.theme = dark ? 'dark' : 'default';
} }
return { ...state, mermaid: formatJSON(config) }; inputState.mermaid = formatJSON(config);
}); persistAndProcess();
};
// Replaces the whole input state (e.g. when restoring a history entry),
// dropping keys the next state does not define.
export const replaceInputState = (next: State): void => {
for (const key of Object.keys(inputState)) {
if (!(key in next)) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- full-replace semantics
delete (inputState as unknown as Record<string, unknown>)[key];
}
}
Object.assign(inputState, next);
persistAndProcess();
}; };
export const initURLSubscription = (): void => { export const initURLSubscription = (): void => {
const updateHash = debounce((hash) => { updateHash = debounce((serialized: string) => {
history.replaceState(undefined, '', `#${hash}`); history.replaceState(undefined, '', `#${serialized}`);
}, 250); }, 250);
updateHash(validatedCurrent.serialized);
stateStore.subscribe(({ serialized }) => {
updateHash(serialized);
});
}; };
export const verifyState = (): void => { export const verifyState = (): void => {
const state = get(inputStateStore); updateCodeStore(inputState.panZoom ? {} : { panZoom: true });
if (!state.panZoom) {
state.panZoom = true;
}
updateCodeStore(state);
}; };
+1 -1
View File
@@ -4,7 +4,7 @@ import { loadDataFromUrl } from './fileLoaders/loader';
import { initLoading } from './loading'; import { initLoading } from './loading';
import { isOnMermaidAI } from './migration/domainMigration'; import { isOnMermaidAI } from './migration/domainMigration';
import { applyMigrations } from './migrations'; import { applyMigrations } from './migrations';
import { initURLSubscription, loadState, updateCodeStore, verifyState } from './state'; import { initURLSubscription, loadState, updateCodeStore, verifyState } from './state.svelte';
import { getAnalyticsSafeUrl, initAnalytics, plausible } from './stats'; import { getAnalyticsSafeUrl, initAnalytics, plausible } from './stats';
export const getDomain = (url?: string): string => { export const getDomain = (url?: string): string => {
+1 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Toaster } from '$/components/ui/sonner/index.js'; import { Toaster } from '$/components/ui/sonner/index.js';
import { loadingStateStore } from '$/util/loading'; import { loadingStateStore } from '$/util/loading';
import { toggleDarkTheme } from '$/util/state'; import { toggleDarkTheme } from '$/util/state.svelte';
import { initHandler } from '$/util/util'; import { initHandler } from '$/util/util';
import { base } from '$app/paths'; import { base } from '$app/paths';
import { mode, ModeWatcher } from 'mode-watcher'; import { mode, ModeWatcher } from 'mode-watcher';
+4 -4
View File
@@ -23,7 +23,7 @@
import type { EditorMode, Tab } from '$/types'; import type { EditorMode, Tab } from '$/types';
import { shouldShowEditorChooser } from '$/util/migration/domainMigration'; import { shouldShowEditorChooser } from '$/util/migration/domainMigration';
import { PanZoomState } from '$/util/panZoom'; import { PanZoomState } from '$/util/panZoom';
import { stateStore, updateCodeStore, urlsStore } from '$/util/state'; import { validatedState, updateCodeStore, urls } from '$/util/state.svelte';
import { logEvent, logMermaidChartClick } from '$/util/stats'; import { logEvent, logMermaidChartClick } from '$/util/stats';
import { initHandler } from '$/util/util'; import { initHandler } from '$/util/util';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@@ -99,7 +99,7 @@
<Button <Button
variant="accent" variant="accent"
size="sm" size="sm"
href={$urlsStore.mermaidChart({ medium: 'save_diagram' }).save} href={urls.current.mermaidChart({ medium: 'save_diagram' }).save}
target="_blank" target="_blank"
onclick={() => logMermaidChartClick('saveDiagram')}> onclick={() => logMermaidChartClick('saveDiagram')}>
<MermaidChartIcon /> <MermaidChartIcon />
@@ -124,7 +124,7 @@
onselect={tabSelectHandler} onselect={tabSelectHandler}
isOpen isOpen
tabs={editorTabs} tabs={editorTabs}
activeTabID={$stateStore.editorMode} activeTabID={validatedState.current.editorMode}
isClosable={false}> isClosable={false}>
{#snippet actions()} {#snippet actions()}
<DiagramDocButton /> <DiagramDocButton />
@@ -140,7 +140,7 @@
</Resizable.Pane> </Resizable.Pane>
<Resizable.Handle class="mr-1 hidden opacity-0 sm:block" /> <Resizable.Handle class="mr-1 hidden opacity-0 sm:block" />
<Resizable.Pane minSize={15} class="relative flex h-full flex-1 flex-col overflow-hidden"> <Resizable.Pane minSize={15} class="relative flex h-full flex-1 flex-col overflow-hidden">
<View {panZoomState} shouldShowGrid={$stateStore.grid} /> <View {panZoomState} shouldShowGrid={validatedState.current.grid} />
<div class="absolute top-0 left-5 hidden md:block"><EnhancedEditsButton /></div> <div class="absolute top-0 left-5 hidden md:block"><EnhancedEditsButton /></div>
<div class="absolute top-0 right-0"><PanZoomToolbar {panZoomState} /></div> <div class="absolute top-0 right-0"><PanZoomToolbar {panZoomState} /></div>
<div class="absolute right-0 bottom-0"><VersionSecurityToolbar /></div> <div class="absolute right-0 bottom-0"><VersionSecurityToolbar /></div>