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 { browser } from '$app/environment';
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 { version as FAVersion } from '@fortawesome/fontawesome-free/package.json';
import dayjs from 'dayjs';
@@ -80,7 +80,7 @@
svg = getSvgElement();
}
if ($stateStore.rough) {
if (validatedState.current.rough) {
fixForeignObjectClipping(svg);
}
@@ -106,7 +106,7 @@ ${svgString}`);
};
const exportImage = async (event: Event, exporter: Exporter) => {
$inputStateStore.panZoom = false;
updateCodeStore({ panZoom: false });
await new Promise((resolve) => setTimeout(resolve, 1000));
await waitForRender();
const canvas = document.createElement('canvas');
@@ -149,14 +149,14 @@ ${svgString}`);
const image = new Image();
image.addEventListener('load', () => {
exporter(context, image)();
$inputStateStore.panZoom = true;
updateCodeStore({ panZoom: true });
});
image.src = `data:image/svg+xml;base64,${getBase64SVG(svg, canvas.width, canvas.height)}`;
// Fallback to set panZoom to true after 2 seconds
// This is a workaround for the case when the image is not loaded
setTimeout(() => {
if (!$inputStateStore.panZoom) {
$inputStateStore.panZoom = true;
if (!inputState.panZoom) {
updateCodeStore({ panZoom: true });
}
}, 2000);
event.stopPropagation();
@@ -222,7 +222,8 @@ ${svgString}`);
};
let gistURL = $state('');
stateStore.subscribe(({ loader }) => {
$effect(() => {
const { loader } = validatedState.current;
if (loader?.type === 'gist') {
gistURL = loader.config.url;
}
@@ -287,10 +288,10 @@ ${svgString}`);
bind:value={imageSize} />
</div>
<div class="flex gap-2">
{@render dualActionButton('PNG', onDownloadPNG, $urlsStore.png)}
{@render dualActionButton('SVG', onDownloadSVG, $urlsStore.svg)}
<ExternalLinkWrapper domain={getDomain($urlsStore.kroki)} isVisible={!!$urlsStore.kroki}>
<a target="_blank" rel="noreferrer" class="flex-grow" href={$urlsStore.kroki}>
{@render dualActionButton('PNG', onDownloadPNG, urls.current.png)}
{@render dualActionButton('SVG', onDownloadSVG, urls.current.svg)}
<ExternalLinkWrapper domain={getDomain(urls.current.kroki)} isVisible={!!urls.current.kroki}>
<a target="_blank" rel="noreferrer" class="flex-grow" href={urls.current.kroki}>
<Button class="action-btn flex w-full items-center gap-2">
<ExternalLinkIcon /> Kroki
</Button>
@@ -303,9 +304,9 @@ ${svgString}`);
{/if}
<ExternalLinkWrapper
labelPrefix="Thumbnail generated by"
domain={getDomain($urlsStore.png)}
isVisible={!!$urlsStore.mdCode}>
<CopyInput value={$urlsStore.mdCode} label="Copy Markdown" testID={TID.copyMarkdown} />
domain={getDomain(urls.current.png)}
isVisible={!!urls.current.mdCode}>
<CopyInput value={urls.current.mdCode} label="Copy Markdown" testID={TID.copyMarkdown} />
</ExternalLinkWrapper>
<div class="flex w-full items-center gap-2">
<Input type="url" bind:value={gistURL} placeholder="Enter Gist URL" />
+45 -45
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import type { EditorProps } from '$/types';
import { env } from '$/util/env';
import { stateStore, urlsStore } from '$/util/state';
import { urls, validatedState } from '$/util/state.svelte';
import { logMermaidChartClick } from '$/util/stats';
import { AIPromptViewZoneManager } from '$lib/util/AIPromptViewZoneManager';
import { initEditor } from '$lib/util/monacoExtra';
@@ -150,48 +150,6 @@
onUpdate(currentText);
});
const unsubscribeState = stateStore.subscribe(({ errorMarkers, editorMode, code, mermaid }) => {
if (!editor) {
return;
}
const model = editorMode === 'code' ? mermaidModel : jsonModel;
if (editor.getModel()?.id !== model.id) {
editor.setModel(model);
renderAIPromptGutterGlyphIcon();
}
// Clear decorations if not in 'code' mode, or if the model changes
if (editorMode !== 'code' || editor.getModel()?.id !== mermaidModel.id) {
decorationsCollection?.clear();
}
// Update editor text if it's different
const newText = editorMode === 'code' ? code : mermaid;
if (newText !== currentText) {
isUpdatingFromState = true;
try {
editor.setScrollTop(0);
editor.pushUndoStop();
editor.executeEdits('updateCode', [
{
range: model.getFullModelRange(),
text: newText
}
]);
editor.pushUndoStop();
currentText = newText;
} finally {
isUpdatingFromState = false;
}
renderAIPromptGutterGlyphIcon();
}
// Display/clear errors
monaco.editor.setModelMarkers(model, 'mermaid', errorMarkers);
});
editor.onMouseMove((e) => {
if (!editor) return;
if (showPopup) return;
@@ -222,7 +180,6 @@
renderAIPromptGutterGlyphIcon();
return () => {
unsubscribeState();
resizeObserver.disconnect();
jsonModel.dispose();
mermaidModel.dispose();
@@ -230,6 +187,49 @@
editor?.dispose();
};
});
$effect(() => {
const { errorMarkers, editorMode, code, mermaid } = validatedState.current;
if (!editor) {
return;
}
const model = editorMode === 'code' ? mermaidModel : jsonModel;
if (editor.getModel()?.id !== model.id) {
editor.setModel(model);
renderAIPromptGutterGlyphIcon();
}
// Clear decorations if not in 'code' mode, or if the model changes
if (editorMode !== 'code' || editor.getModel()?.id !== mermaidModel.id) {
decorationsCollection?.clear();
}
// Update editor text if it's different
const newText = editorMode === 'code' ? code : mermaid;
if (newText !== currentText) {
isUpdatingFromState = true;
try {
editor.setScrollTop(0);
editor.pushUndoStop();
editor.executeEdits('updateCode', [
{
range: model.getFullModelRange(),
text: newText
}
]);
editor.pushUndoStop();
currentText = newText;
} finally {
isUpdatingFromState = false;
}
renderAIPromptGutterGlyphIcon();
}
// Display/clear errors
monaco.editor.setModelMarkers(model, 'mermaid', errorMarkers);
});
</script>
<div class="relative h-full grow overflow-hidden">
@@ -243,7 +243,7 @@
onTryFree={() => {
logMermaidChartClick('vibeDiagramming');
window.open(
$urlsStore.mermaidChart({ medium: 'vibe_diagramming' }).save,
urls.current.mermaidChart({ medium: 'vibe_diagramming' }).save,
'_blank',
'noopener'
);
@@ -4,7 +4,7 @@
import type { DocumentationConfig } from '$/types';
import { env } from '$/util/env';
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';
const docURLBase = env.docsUrl;
@@ -92,7 +92,7 @@
} as const satisfies DocumentationConfig;
const doc = $derived.by(() => {
const { editorMode, diagramType } = $stateStore;
const { editorMode, diagramType } = validatedState.current;
if (!diagramType) {
return { key: '', url: docURLBase };
}
+8 -8
View File
@@ -6,14 +6,14 @@
import { Button } from '$/components/ui/button';
import { TID } from '$/constants';
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 { debounce } from 'lodash-es';
import ExclamationCircleIcon from '~icons/material-symbols/error-outline-rounded';
const { isMobile } = $props<{ isMobile: boolean }>();
const onUpdate = (text: string) => {
if ($stateStore.editorMode === 'code') {
if (validatedState.current.editorMode === 'code') {
updateCode(text);
} else {
updateConfig(text);
@@ -27,7 +27,7 @@
}, 3000);
$effect(() => {
if ($stateStore.error) {
if (validatedState.current.error) {
showErrorDebounced();
} else {
showErrorDebounced.cancel();
@@ -46,27 +46,27 @@
{:else}
<DesktopEditor {onUpdate} />
{/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 items-center justify-between gap-2 bg-slate-900 p-2 text-white">
<div class="flex w-fit items-center gap-2">
<ExclamationCircleIcon class="size-6 text-destructive" aria-hidden="true" />
<div class="flex flex-col">
<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}>
Create a free account to repair with AI
</p>
{/if}
</div>
</div>
{#if $stateStore.editorMode === 'code'}
{#if validatedState.current.editorMode === 'code'}
<McWrapper>
<Button
variant="accent"
size="sm"
data-testid={TID.aiRepairButton}
href={$urlsStore.mermaidChart({ medium: 'ai_repair' }).save}
href={urls.current.mermaidChart({ medium: 'ai_repair' }).save}
target="_blank"
onclick={() => logMermaidChartClick('aiRepair')}>
<MermaidChartIcon />
@@ -76,7 +76,7 @@
{/if}
</div>
<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>
</div>
{/if}
@@ -3,7 +3,7 @@
import MermaidChartIcon from '$/components/MermaidChartIcon.svelte';
import { Button } from '$/components/ui/button';
import { standardizeDiagramType } from '$/util/mermaid';
import { stateStore, urlsStore } from '$/util/state';
import { validatedState, urls } from '$/util/state.svelte';
import { logMermaidChartClick } from '$/util/stats';
import { quintInOut } from 'svelte/easing';
import { slide } from 'svelte/transition';
@@ -19,7 +19,7 @@
]);
const diagramType = $derived.by(() => {
const dt = $stateStore.diagramType;
const dt = validatedState.current.diagramType;
return dt ? standardizeDiagramType(dt) : undefined;
});
@@ -39,7 +39,7 @@
let currentActionIndex = $state(0);
const availableActions = $derived.by<EnhancedEditAction[]>(() => {
if (!$stateStore.diagramType) {
if (!validatedState.current.diagramType) {
return [];
}
@@ -98,7 +98,7 @@
<Button
variant="secondary"
size="sm"
href={$urlsStore.mermaidChart({
href={urls.current.mermaidChart({
medium: currentAction.medium,
campaign: currentAction.campaign
}).save}
@@ -1,5 +1,5 @@
<script lang="ts">
import { stateStore } from '$/util/state';
import { validatedState } from '$/util/state.svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import type { ComponentProps, Snippet } from 'svelte';
import ExternalLinkIcon from '~icons/material-symbols/open-in-new-rounded';
@@ -25,7 +25,7 @@
} = $props();
let shouldDisableComponent = $derived(
shouldCheckDiagramType && $stateStore.diagramType === 'zenuml'
shouldCheckDiagramType && validatedState.current.diagramType === 'zenuml'
);
</script>
+3 -3
View File
@@ -3,7 +3,7 @@
import type { HistoryEntry, HistoryType, State, Tab } from '$lib/types';
import { notify, prompt } from '$lib/util/notify';
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 dayjs from 'dayjs';
import dayjsRelativeTime from 'dayjs/plugin/relativeTime';
@@ -87,7 +87,7 @@
};
const saveHistory = () => {
if (!addManualEntry($inputStateStore)) {
if (!addManualEntry($state.snapshot(inputState))) {
notify('State already saved.');
}
};
@@ -99,7 +99,7 @@
};
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.
@@ -1,8 +1,7 @@
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 { generateSlug } from 'random-word-slugs';
import { get } from 'svelte/store';
import { v4 as uuidV4 } from 'uuid';
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.
export const startAutoSave = (): (() => void) => {
if (autoSaveTimer === undefined) {
autoSaveTimer = setInterval(() => addAutoEntry(get(inputStateStore)), AUTO_SAVE_INTERVAL);
autoSaveTimer = setInterval(
() => addAutoEntry($state.snapshot(inputState)),
AUTO_SAVE_INTERVAL
);
}
return stopAutoSave;
};
@@ -1,5 +1,5 @@
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 {
addAutoEntry,
@@ -284,7 +284,7 @@ describe('auto-save lifecycle', () => {
it('records an auto entry on each interval from the current editor state', () => {
vi.useFakeTimers();
inputStateStore.set(codeState('graph TD\n auto-saved'));
replaceInputState(codeState('graph TD\n auto-saved'));
startAutoSave();
vi.advanceTimersByTime(60_000);
const entries = entriesFor('auto');
@@ -294,7 +294,7 @@ describe('auto-save lifecycle', () => {
it('is idempotent: calling startAutoSave twice does not double-record', () => {
vi.useFakeTimers();
inputStateStore.set(codeState('graph TD\n once'));
replaceInputState(codeState('graph TD\n once'));
startAutoSave();
startAutoSave();
vi.advanceTimersByTime(60_000);
@@ -303,11 +303,11 @@ describe('auto-save lifecycle', () => {
it('stops recording after stopAutoSave', () => {
vi.useFakeTimers();
inputStateStore.set(codeState('graph TD\n stoppable'));
replaceInputState(codeState('graph TD\n stoppable'));
startAutoSave();
vi.advanceTimersByTime(60_000);
stopAutoSave();
inputStateStore.set(codeState('graph TD\n after-stop'));
replaceInputState(codeState('graph TD\n after-stop'));
vi.advanceTimersByTime(60_000);
expect(entriesFor('auto')).toHaveLength(1);
});
+5 -5
View File
@@ -3,7 +3,7 @@
import * as Popover from '$/components/ui/popover';
import { Switch } from '$/components/ui/switch';
import { env } from '$/util/env';
import { urlsStore } from '$/util/state';
import { urls } from '$/util/state.svelte';
import { logMermaidChartClick } from '$/util/stats';
import { cn } from '$/utils';
import { mode, setMode } from 'mode-watcher';
@@ -32,10 +32,10 @@
}
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 },
{
href: $urlsStore.mermaidChart({ medium: 'main_menu' }).playground,
href: urls.current.mermaidChart({ medium: 'main_menu' }).playground,
icon: PlaygroundIcon,
isSectionEnd: true,
label: 'Edit in Playground',
@@ -62,7 +62,7 @@
},
{
checkDiagramType: false,
href: $urlsStore.mermaidChart({ medium: 'main_menu' }).plugins,
href: urls.current.mermaidChart({ medium: 'main_menu' }).plugins,
icon: PluginIcon,
label: 'Plugins',
onclick: () => logMermaidChartClick('plugins'),
@@ -79,7 +79,7 @@
{
checkDiagramType: false,
class: 'text-accent border-b-0',
href: $urlsStore.mermaidChart({ medium: 'main_menu' }).home,
href: urls.current.mermaidChart({ medium: 'main_menu' }).home,
icon: MermaidChartIcon,
label: 'Mermaid',
onclick: () => logMermaidChartClick('mermaidHome'),
+29 -30
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import type { EditorProps } from '$/types';
import { stateStore } from '$/util/state';
import { validatedState } from '$/util/state.svelte';
import { json, jsonLanguage } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
import { yamlFrontmatter } from '@codemirror/lang-yaml';
@@ -17,6 +17,7 @@
let editorContainer: HTMLDivElement;
let currentText = $state('');
const themeCompartment = new Compartment();
const languageCompartment = new Compartment();
const { onUpdate }: EditorProps = $props();
@@ -27,8 +28,6 @@
});
onMount(() => {
const languageCompartment = new Compartment();
editorView = new EditorView({
state: EditorState.create({
doc: currentText,
@@ -62,37 +61,37 @@
parent: editorContainer
});
const unsubscribeState = stateStore.subscribe(({ editorMode, code, mermaid }) => {
const text = editorMode === 'code' ? code : mermaid;
if (currentText === text || !editorView) {
return;
}
currentText = text;
editorView.dispatch({
changes: {
from: 0,
to: editorView.state.doc.length,
insert: text
}
});
const stateLanguage = editorView.state.facet(language);
const isStateJson = stateLanguage === jsonLanguage;
const isCodeJson = editorMode === 'config';
if (stateLanguage && isStateJson === isCodeJson) {
return;
}
editorView.dispatch({
effects: languageCompartment.reconfigure(
isCodeJson ? json() : yamlFrontmatter({ content: markdown() })
)
});
});
return () => {
unsubscribeState();
editorView?.destroy();
};
});
$effect(() => {
const { editorMode, code, mermaid } = validatedState.current;
const text = editorMode === 'code' ? code : mermaid;
if (currentText === text || !editorView) {
return;
}
currentText = text;
editorView.dispatch({
changes: {
from: 0,
to: editorView.state.doc.length,
insert: text
}
});
const stateLanguage = editorView.state.facet(language);
const isStateJson = stateLanguage === jsonLanguage;
const isCodeJson = editorMode === 'config';
if (stateLanguage && isStateJson === isCodeJson) {
return;
}
editorView.dispatch({
effects: languageCompartment.reconfigure(
isCodeJson ? json() : yamlFrontmatter({ content: markdown() })
)
});
});
</script>
<div bind:this={editorContainer} class="size-full"></div>
+2 -2
View File
@@ -3,7 +3,7 @@
import { Button } from '$/components/ui/button';
import { Separator } from '$/components/ui/separator';
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 ArrowsToCircleIcon from '~icons/material-symbols/screenshot-frame-2';
import MagnifyingGlassPlusIcon from '~icons/material-symbols/zoom-in';
@@ -28,7 +28,7 @@
<MagnifyingGlassPlusIcon />
</Button>
<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 />
</Button>
</FloatingToolbar>
+1 -1
View File
@@ -2,7 +2,7 @@
import Card from '$/components/Card/Card.svelte';
import { Button } from '$/components/ui/button';
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 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 { Separator } from '$/components/ui/separator';
import { env } from '$/util/env';
import { urlsStore } from '$/util/state';
import { urls } from '$/util/state.svelte';
import { asset } from '$app/paths';
import ShareIcon from '~icons/material-symbols/share';
import CopyInput from './CopyInput.svelte';
@@ -38,7 +38,7 @@
<MermaidChartIcon class="size-5" />
Mermaid Chart Playground
</h2>
<CopyInput value={$urlsStore.mermaidChart({ medium: 'share' }).playground} />
<CopyInput value={urls.current.mermaidChart({ medium: 'share' }).playground} />
<Dialog.Description>
Opens the Mermaid Chart Playground with Mermaid AI, Visual Editor, and more.
</Dialog.Description>
+11 -5
View File
@@ -1,21 +1,27 @@
<script lang="ts">
import FloatingToolbar from '$/components/FloatingToolbar.svelte';
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 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
$inputStateStore.grid = defaultState.grid;
updateCodeStore({ grid: defaultState.grid });
}
</script>
<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 />
</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 />
</Toggle>
</FloatingToolbar>
+10 -8
View File
@@ -3,7 +3,7 @@
import { recordRenderTime, shouldRefreshView } from '$/util/autoSync';
import { render as renderDiagram } from '$/util/mermaid';
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 FontAwesome, { mayContainFontAwesome } from '$lib/components/FontAwesome.svelte';
import uniqueID from 'lodash-es/uniqueId';
@@ -133,18 +133,20 @@
const renderTime = Date.now() - startTime;
saveStatistics({ code, diagramType, isRough: state.rough, renderTime });
recordRenderTime(renderTime, () => {
$inputStateStore.updateDiagram = true;
updateCodeStore({ updateDiagram: true });
});
};
onMount(() => {
setupPanZoomObserver();
// Queue state changes to avoid race condition
let pendingStateChange = Promise.resolve();
stateStore.subscribe((state) => {
// eslint-disable-next-line @typescript-eslint/no-empty-function
pendingStateChange = pendingStateChange.then(() => handleStateChange(state).catch(() => {}));
});
});
// Queue state changes to avoid race condition
let pendingStateChange = Promise.resolve();
$effect(() => {
const state = validatedState.current;
// eslint-disable-next-line @typescript-eslint/no-empty-function
pendingStateChange = pendingStateChange.then(() => handleStateChange(state).catch(() => {}));
});
</script>
+1 -1
View File
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { setLoaderEntries } from '$lib/components/History/historyState.svelte';
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';
const codeFileName = 'code.mmd';
+1 -1
View File
@@ -1,5 +1,5 @@
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 { loadGistData } from './gist';
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { serializeState, deserializeState, type SerdeType } from './serde';
import { defaultState } from './state';
import { defaultState } from './state.svelte';
import type { State } from '$lib/types';
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 { debounce, get as lodashGet } from 'lodash-es';
import type { MermaidConfig } from 'mermaid';
import { derived, get, writable, type Readable } from 'svelte/store';
import { env } from './env';
import {
extractErrorLineText,
@@ -10,7 +9,7 @@ import {
replaceLineNumberInErrorMessage
} from './errorHandling';
import { defaultMermaidConfig, parse } from './mermaid';
import { localStorage, persist } from './persist';
import { readJSON, writeJSON } from './persist.svelte';
import { deserializeState, pakoSerde, serializeState } from './serde';
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.)
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.
export const inputStateStore = persist(writable(defaultState), localStorage(), 'codeStore');
// inputState handles all updates and is shared externally when exporting via URL, History, etc.
// 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 state = get(inputStateStore);
return {
...state,
editorMode: state.editorMode ?? 'code',
error: undefined,
errorMarkers: [],
serialized: serializeState(state)
};
})();
const validatedStateOf = (state: State): ValidatedState => ({
...state,
editorMode: state.editorMode ?? 'code',
error: undefined,
errorMarkers: [],
serialized: serializeState(state)
});
let validatedCurrent = $state<ValidatedState>(validatedStateOf($state.snapshot(inputState)));
let lastDiagramType = '';
@@ -120,16 +121,31 @@ const processState = async (state: State) => {
return processed;
};
// All internal reads should be done via stateStore, but it should not be persisted/shared externally.
export const stateStore: Readable<ValidatedState> = derived(
[inputStateStore],
([state], set) => {
void processState(state).then(set);
},
currentState
);
// Replaces the old URL-hash store subscription; assigned by initURLSubscription.
let updateHash: ((serialized: string) => void) | undefined;
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 png = rendererUrl ? `${rendererUrl}/img/${serialized}?type=png` : '';
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.
*
@@ -256,7 +278,7 @@ export const loadState = (data: string): void => {
state = deserializeState(data);
state.mermaid = sanitizeConfig(state.mermaid || defaultState.mermaid);
} catch (error) {
state = get(inputStateStore);
state = $state.snapshot(inputState);
if (data) {
console.error('Init error', error);
state.code = urlParseFailedState;
@@ -268,10 +290,9 @@ export const loadState = (data: string): void => {
let renderCount = 0;
export const updateCodeStore = (newState: Partial<State>): void => {
inputStateStore.update((state) => {
renderCount++;
return { ...state, ...newState, renderCount };
});
renderCount++;
Object.assign(inputState, newState, { renderCount });
persistAndProcess();
};
export const updateCode = (
@@ -283,13 +304,13 @@ export const updateCode = (
): void => {
errorDebug();
inputStateStore.update((state) => {
if (resetPanZoom) {
state.pan = undefined;
state.zoom = undefined;
}
return { ...state, code, updateDiagram };
});
if (resetPanZoom) {
inputState.pan = undefined;
inputState.zoom = undefined;
}
inputState.code = code;
inputState.updateDiagram = updateDiagram;
persistAndProcess();
};
export const updateConfig = (config: string): void => {
@@ -297,29 +318,34 @@ export const updateConfig = (config: string): void => {
};
export const toggleDarkTheme = (dark: boolean): void => {
inputStateStore.update((state) => {
const config = JSON.parse(state.mermaid) as MermaidConfig;
if (!config.theme || ['dark', 'default'].includes(config.theme)) {
config.theme = dark ? 'dark' : 'default';
const config = JSON.parse(inputState.mermaid) as MermaidConfig;
if (!config.theme || ['dark', 'default'].includes(config.theme)) {
config.theme = dark ? 'dark' : 'default';
}
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];
}
return { ...state, mermaid: formatJSON(config) };
});
}
Object.assign(inputState, next);
persistAndProcess();
};
export const initURLSubscription = (): void => {
const updateHash = debounce((hash) => {
history.replaceState(undefined, '', `#${hash}`);
updateHash = debounce((serialized: string) => {
history.replaceState(undefined, '', `#${serialized}`);
}, 250);
stateStore.subscribe(({ serialized }) => {
updateHash(serialized);
});
updateHash(validatedCurrent.serialized);
};
export const verifyState = (): void => {
const state = get(inputStateStore);
if (!state.panZoom) {
state.panZoom = true;
}
updateCodeStore(state);
updateCodeStore(inputState.panZoom ? {} : { panZoom: true });
};
+1 -1
View File
@@ -4,7 +4,7 @@ import { loadDataFromUrl } from './fileLoaders/loader';
import { initLoading } from './loading';
import { isOnMermaidAI } from './migration/domainMigration';
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';
export const getDomain = (url?: string): string => {
+1 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { Toaster } from '$/components/ui/sonner/index.js';
import { loadingStateStore } from '$/util/loading';
import { toggleDarkTheme } from '$/util/state';
import { toggleDarkTheme } from '$/util/state.svelte';
import { initHandler } from '$/util/util';
import { base } from '$app/paths';
import { mode, ModeWatcher } from 'mode-watcher';
+4 -4
View File
@@ -23,7 +23,7 @@
import type { EditorMode, Tab } from '$/types';
import { shouldShowEditorChooser } from '$/util/migration/domainMigration';
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 { initHandler } from '$/util/util';
import { onMount } from 'svelte';
@@ -99,7 +99,7 @@
<Button
variant="accent"
size="sm"
href={$urlsStore.mermaidChart({ medium: 'save_diagram' }).save}
href={urls.current.mermaidChart({ medium: 'save_diagram' }).save}
target="_blank"
onclick={() => logMermaidChartClick('saveDiagram')}>
<MermaidChartIcon />
@@ -124,7 +124,7 @@
onselect={tabSelectHandler}
isOpen
tabs={editorTabs}
activeTabID={$stateStore.editorMode}
activeTabID={validatedState.current.editorMode}
isClosable={false}>
{#snippet actions()}
<DiagramDocButton />
@@ -140,7 +140,7 @@
</Resizable.Pane>
<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">
<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 right-0"><PanZoomToolbar {panZoomState} /></div>
<div class="absolute right-0 bottom-0"><VersionSecurityToolbar /></div>