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:
co-authored by
Claude Fable 5
parent
ca96ca8771
commit
b3c8bd6c28
@@ -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" />
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,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,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,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);
|
|
||||||
};
|
};
|
||||||
@@ -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,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';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user