Merge pull request #1982 from mermaid-js/sidv/rewrite-timeline-history

fix: Rewrite Timeline/Saved history state and UI handling
This commit is contained in:
Sidharth Vinod
2026-06-08 15:01:44 +00:00
committed by GitHub
27 changed files with 856 additions and 450 deletions
+3
View File
@@ -39,5 +39,8 @@ jobs:
- name: Lint
run: pnpm lint
- name: Type check
run: pnpm check
- name: Run unit tests
run: pnpm test:unit
+1
View File
@@ -15,3 +15,4 @@
/playwright-report/
/blob-report/
/playwright/.cache/
.playwright-mcp/
+3
View File
@@ -9,6 +9,8 @@
"dev:test": "pnpm dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check --cache . && eslint .",
"lint:fix": "prettier --write --cache . && eslint --fix .",
"format": "prettier --write --cache .",
@@ -70,6 +72,7 @@
"prettier-plugin-svelte": "^4.1.0",
"prettier-plugin-tailwindcss": "^0.8.0",
"svelte": "^5.56.3",
"svelte-check": "^4.6.0",
"svelte-preprocess": "^6.0.5",
"svelte-sonner": "^1.1.1",
"tailwind-merge": "^3.6.0",
+77
View File
@@ -231,6 +231,9 @@ importers:
svelte:
specifier: ^5.56.3
version: 5.56.3(@typescript-eslint/types@8.60.1)
svelte-check:
specifier: ^4.6.0
version: 4.6.0(picomatch@4.0.4)(svelte@5.56.3(@typescript-eslint/types@8.60.1))(typescript@6.0.3)
svelte-preprocess:
specifier: ^6.0.5
version: 6.0.5(postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.15)(yaml@2.9.0))(postcss@8.5.15)(svelte@5.56.3(@typescript-eslint/types@8.60.1))(typescript@6.0.3)
@@ -687,30 +690,35 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-arm64-musl@0.1.100':
resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-linux-riscv64-gnu@0.1.100':
resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-gnu@0.1.100':
resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@napi-rs/canvas-linux-x64-musl@0.1.100':
resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@napi-rs/canvas-win32-arm64-msvc@0.1.100':
resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==}
@@ -813,36 +821,42 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.3':
resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.3':
resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.3':
resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.3':
resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.3':
resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.3':
resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==}
@@ -899,6 +913,10 @@ packages:
typescript:
optional: true
'@sveltejs/load-config@0.1.1':
resolution: {integrity: sha512-BXXm+VOH/9X4N7Dd1iZ2MqA1h7M+9i2noI8QYuLDY8QcN2WHYn7D/VK/+IJNfcAmRw7ACNJ538UT9GXIhnBTiA==}
engines: {node: '>= 18.0.0'}
'@sveltejs/vite-plugin-svelte@7.1.2':
resolution: {integrity: sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==}
engines: {node: ^20.19 || ^22.12 || >=24}
@@ -947,24 +965,28 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.3.0':
resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.3.0':
resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.3.0':
resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.3.0':
resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==}
@@ -1463,6 +1485,10 @@ packages:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
chokidar@4.0.3:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
ci-info@4.4.0:
resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==}
engines: {node: '>=8'}
@@ -2381,24 +2407,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -2545,6 +2575,10 @@ packages:
monaco-editor@0.55.1:
resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==}
mri@1.2.0:
resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
engines: {node: '>=4'}
mrmime@2.0.1:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'}
@@ -3074,6 +3108,10 @@ packages:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
real-require@0.2.0:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
@@ -3164,6 +3202,10 @@ packages:
rw@1.3.3:
resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==}
sade@1.8.1:
resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==}
engines: {node: '>=6'}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
@@ -3300,6 +3342,14 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
svelte-check@4.6.0:
resolution: {integrity: sha512-KhVnDFDSid57mmZtHz8gfW8AAGylOZ0vPnOIzVmAL+urzwK8sBYXRss953gD8T0OdgAQ11mdWhE6uadmtOz8TQ==}
engines: {node: '>= 18.0.0'}
hasBin: true
peerDependencies:
svelte: ^4.0.0 || ^5.0.0-next.0
typescript: '>=5.0.0'
svelte-eslint-parser@1.8.0:
resolution: {integrity: sha512-mikR1qwIVy3t5WthUoAXkMwxkXvabZP9FJgdx35Ei7EbGWmctva1Pih16Koeor/bdNNq8NXHlwKGS6NkYTawLg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0, pnpm: 10.34.1}
@@ -4405,6 +4455,8 @@ snapshots:
optionalDependencies:
typescript: 6.0.3
'@sveltejs/load-config@0.1.1': {}
'@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.56.3(@typescript-eslint/types@8.60.1))(vite@8.0.16(@types/node@24.13.1)(jiti@1.21.7)(yaml@2.9.0))':
dependencies:
deepmerge: 4.3.1
@@ -5061,6 +5113,10 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
chokidar@4.0.3:
dependencies:
readdirp: 4.1.2
ci-info@4.4.0: {}
class-variance-authority@0.7.1:
@@ -6123,6 +6179,8 @@ snapshots:
dompurify: 3.2.7
marked: 14.0.0
mri@1.2.0: {}
mrmime@2.0.1: {}
ms@2.1.3: {}
@@ -6564,6 +6622,8 @@ snapshots:
dependencies:
picomatch: 2.3.2
readdirp@4.1.2: {}
real-require@0.2.0: {}
redent@4.0.0:
@@ -6664,6 +6724,10 @@ snapshots:
rw@1.3.3: {}
sade@1.8.1:
dependencies:
mri: 1.2.0
safe-buffer@5.2.1: {}
safe-stable-stringify@2.5.0: {}
@@ -6785,6 +6849,19 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {}
svelte-check@4.6.0(picomatch@4.0.4)(svelte@5.56.3(@typescript-eslint/types@8.60.1))(typescript@6.0.3):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
'@sveltejs/load-config': 0.1.1
chokidar: 4.0.3
fdir: 6.5.0(picomatch@4.0.4)
picocolors: 1.1.1
sade: 1.8.1
svelte: 5.56.3(@typescript-eslint/types@8.60.1)
typescript: 6.0.3
transitivePeerDependencies:
- picomatch
svelte-eslint-parser@1.8.0(svelte@5.56.3(@typescript-eslint/types@8.60.1)):
dependencies:
eslint-scope: 8.4.0
+4 -1
View File
@@ -199,7 +199,10 @@ ${svgString}`);
};
};
const onCopyClipboard = async (event: Event) => {
const onCopyClipboard = async (event?: Event) => {
if (!event) {
return;
}
await exportImage(event, clipboardCopy);
logEvent('copyClipboard');
};
+3 -4
View File
@@ -14,9 +14,8 @@
onselect?: (tab: Tab) => void;
} = $props();
if (!activeTabID && tabs.length > 0) {
activeTabID = tabs[0].id;
}
// Derive (don't mutate the prop) so the highlight tracks a bound activeTabID.
const effectiveTabID = $derived(activeTabID || tabs[0]?.id);
const toggleTabs = (tab: Tab) => {
return (event: Event) => {
@@ -34,7 +33,7 @@
variant="ghost"
class={[
'px-2',
activeTabID === tab.id && 'rounded-b-none border-b-2 border-b-primary-foreground/50'
effectiveTabID === tab.id && 'rounded-b-none border-b-2 border-b-primary-foreground/50'
]}
onclick={toggleTabs(tab)}
onkeypress={toggleTabs(tab)}>
+1 -1
View File
@@ -115,7 +115,7 @@
throw new Error('divEl is undefined');
}
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
monaco.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
enableSchemaRequest: true,
schemas: [
@@ -97,7 +97,9 @@
return { key: '', url: docURLBase };
}
const key = standardizeDiagramType(diagramType);
const docConfig = docMap[key] ?? { code: '' };
const docConfig: { code: string; config?: string } = docMap[key as keyof typeof docMap] ?? {
code: ''
};
const url = docURLBase + (docConfig[editorMode] ?? docConfig.code ?? '');
return { key, url };
});
+75 -78
View File
@@ -2,12 +2,11 @@
import Card from '$lib/components/Card/Card.svelte';
import type { HistoryEntry, HistoryType, State, Tab } from '$lib/types';
import { notify, prompt } from '$lib/util/notify';
import { getStateString, inputStateStore } from '$lib/util/state';
import { serializeState } from '$lib/util/serde';
import { inputStateStore } from '$lib/util/state';
import { logEvent } from '$lib/util/stats';
import dayjs from 'dayjs';
import dayjsRelativeTime from 'dayjs/plugin/relativeTime';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import BookmarkIcon from '~icons/material-symbols/bookmark-outline-rounded';
import TrashAltIcon from '~icons/material-symbols/delete-outline-rounded';
import DownloadIcon from '~icons/material-symbols/download-rounded';
@@ -16,41 +15,51 @@
import UploadIcon from '~icons/material-symbols/upload-rounded';
import HistoryIcon from '~icons/mdi/clock-outline';
import GitAltIcon from '~icons/mdi/git';
import OpenInNewIcon from '~icons/material-symbols/open-in-new-rounded';
import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import {
addHistoryEntry,
clearHistoryData,
getPreviousState,
historyModeStore,
historyStore,
loaderHistoryStore,
restoreHistory
} from './history';
addManualEntry,
clearActive,
historyState,
removeEntry,
restoreEntries,
setMode
} from './historyState.svelte';
dayjs.extend(dayjsRelativeTime);
const HISTORY_SAVE_INTERVAL = 60_000;
const baseTabs: Tab[] = [
{ id: 'manual', title: 'Saved', icon: BookmarkIcon },
{ id: 'auto', title: 'Timeline', icon: HistoryIcon }
];
const loaderTab: Tab = { id: 'loader', title: 'Revisions', icon: GitAltIcon };
const tabs = $derived(
historyState.loaderEntries.length > 0 ? [loaderTab, ...baseTabs] : baseTabs
);
// Surface revisions once when they first appear; the user can switch away after.
let revisionsShown = false;
$effect(() => {
if (historyState.loaderEntries.length > 0 && !revisionsShown) {
revisionsShown = true;
setMode('loader');
}
});
const emptyMessage = $derived(
historyState.mode === 'auto'
? 'No timeline snapshots yet.\nThe Timeline is saved automatically every minute.'
: 'No saved states yet.\nClick the Save button to bookmark the current diagram and restore it later.'
);
const tabSelectHandler = (tab: Tab) => {
historyModeStore.set(tab.id as HistoryType);
setMode(tab.id as HistoryType);
};
let tabs: Tab[] = $state([
{
id: 'manual',
title: 'Saved',
icon: BookmarkIcon
},
{
id: 'auto',
title: 'Timeline',
icon: HistoryIcon
}
]);
const downloadHistory = () => {
const data = get(historyStore);
const data = historyState.entries;
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -58,9 +67,7 @@
a.download = `mermaid-history-${dayjs().format('YYYY-MM-DD-HHmmss')}.json`;
a.click();
URL.revokeObjectURL(url);
logEvent('history', {
action: 'download'
});
logEvent('history', { action: 'download' });
};
const uploadHistory = () => {
@@ -73,59 +80,39 @@
return;
}
const data: HistoryEntry[] = JSON.parse(await file.text());
restoreHistory(data);
const { restored, invalid, duplicates } = restoreEntries(data);
notify(`${restored} restored, ${duplicates} duplicate, ${invalid} invalid.`);
});
input.click();
};
const saveHistory = (auto = false) => {
const currentState: string = getStateString();
const previousState: string = getPreviousState(auto);
if (previousState !== currentState) {
addHistoryEntry({
state: $inputStateStore,
time: Date.now(),
type: auto ? 'auto' : 'manual'
});
} else if (!auto) {
const saveHistory = () => {
if (!addManualEntry($inputStateStore)) {
notify('State already saved.');
}
};
const clearHistory = (id?: string): void => {
if (!id && !prompt('Clear all saved items?')) {
return;
const clearAll = () => {
if (prompt('Clear all saved items?')) {
clearActive();
}
clearHistoryData(id);
};
const restoreHistoryItem = (state: State): void => {
inputStateStore.set({ ...state, updateDiagram: true });
};
onMount(() => {
historyModeStore.set('manual');
setInterval(() => {
saveHistory(true);
}, HISTORY_SAVE_INTERVAL);
});
// Absolute editor URL for an entry, so the link can be opened in a new tab or copied.
const entryUrl = (state: State): string =>
`${window.location.origin}${window.location.pathname}#${serializeState(state)}`;
loaderHistoryStore.subscribe((entries) => {
if (entries.length > 0 && tabs.length === 2) {
tabs = [
{
id: 'loader',
title: 'Revisions',
icon: GitAltIcon
},
...tabs
];
historyModeStore.set('loader');
}
});
// Serialize each entry's URL once per change rather than per row on every render.
const entriesWithUrl = $derived(
historyState.entries.map((entry) => ({ ...entry, openUrl: entryUrl(entry.state) }))
);
</script>
<Card onselect={tabSelectHandler} isOpen isClosable={false} {tabs}>
<Card onselect={tabSelectHandler} isOpen isClosable={false} {tabs} activeTabID={historyState.mode}>
{#snippet actions()}
<div class="flex items-center gap-2">
<Button
@@ -134,7 +121,7 @@
id="uploadHistory"
onclick={uploadHistory}
title="Upload history"><UploadIcon /></Button>
{#if $historyStore.length > 0}
{#if historyState.entries.length > 0}
<Button
id="downloadHistory"
size="icon"
@@ -147,22 +134,22 @@
id="saveHistory"
size="icon"
variant="ghost"
onclick={() => saveHistory()}
onclick={saveHistory}
title="Save current state"><SaveIcon /></Button>
{#if $historyModeStore !== 'loader'}
{#if historyState.mode !== 'loader'}
<Button
id="clearHistory"
size="icon"
variant="ghost"
class="hover:text-destructive"
onclick={() => clearHistory()}
onclick={clearAll}
title="Delete all saved states"><TrashAltIcon /></Button>
{/if}
</div>
{/snippet}
<ul class="flex h-full min-w-fit flex-col gap-2 overflow-auto p-2" id="historyList">
{#if $historyStore.length > 0}
{#each $historyStore as { id, state, time, name, url, type } (id)}
{#if entriesWithUrl.length > 0}
{#each entriesWithUrl as { id, state, time, name, url, type, openUrl } (id)}
<li class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<div class="flex flex-col">
@@ -184,7 +171,20 @@
<span class="text-sm whitespace-nowrap text-primary-foreground/50">
{dayjs(time).fromNow()}
</span>
<Button size="icon" variant="ghost" onclick={() => restoreHistoryItem(state)}>
<Button
href={openUrl}
target="_blank"
rel="noopener"
size="icon"
variant="ghost"
title="Open in new tab">
<OpenInNewIcon />
</Button>
<Button
size="icon"
variant="ghost"
title="Restore this version"
onclick={() => restoreHistoryItem(state)}>
<UndoIcon />
</Button>
{#if type !== 'loader'}
@@ -192,7 +192,8 @@
size="icon"
variant="ghost"
class="hover:text-destructive"
onclick={() => clearHistory(id)}>
title="Delete this version"
onclick={() => removeEntry(id)}>
<TrashAltIcon />
</Button>
{/if}
@@ -202,11 +203,7 @@
</li>
{/each}
{:else}
<div class="m-2 text-center">
No items in History<br />
Click the Save button to save current state and restore it later.<br />
Timeline will automatically be saved every minute.
</div>
<div class="m-2 text-center whitespace-pre-line">{emptyMessage}</div>
{/if}
</ul>
</Card>
-126
View File
@@ -1,126 +0,0 @@
import type { HistoryEntry } from '$lib/types';
import { defaultState } from '$lib/util/state';
import { get } from 'svelte/store';
import { describe, expect, it } from 'vitest';
import {
addHistoryEntry,
clearHistoryData,
historyModeStore,
historyStore,
injectHistoryIDs
} from './history';
describe('history', () => {
it('should handle saving individual history entry', () => {
expect(window.localStorage.getItem('manualHistoryStore')).toBe('[]');
expect(window.localStorage.getItem('autoHistoryStore')).toBe('[]');
addHistoryEntry({
state: defaultState,
time: 12_345,
type: 'manual'
});
const [manualEntry] = JSON.parse(
window.localStorage.getItem('manualHistoryStore') ?? '[]'
) as HistoryEntry[];
expect(manualEntry.time).toBe(12_345);
expect(manualEntry.type).toBe('manual');
expect(manualEntry.name).not.toBeNull();
expect(manualEntry.state).not.toBeNull();
addHistoryEntry({
state: defaultState,
time: 54_321,
type: 'auto'
});
const [autoEntry] = JSON.parse(
window.localStorage.getItem('autoHistoryStore') ?? '[]'
) as HistoryEntry[];
expect(autoEntry.time).toBe(54_321);
expect(autoEntry.type).toBe('auto');
expect(autoEntry.name).not.toBeNull();
expect(autoEntry.state).not.toBeNull();
historyModeStore.set('manual');
clearHistoryData();
historyModeStore.set('auto');
clearHistoryData();
expect(window.localStorage.getItem('manualHistoryStore')).toBe('[]');
expect(window.localStorage.getItem('autoHistoryStore')).toBe('[]');
});
it('should clear history entries', () => {
addHistoryEntry({
state: defaultState,
time: 12_345,
type: 'manual'
});
addHistoryEntry({
state: { ...defaultState, code: 'graph TD\\n A[Christmas] -->|Get money| B(Go shopping)' },
time: 123_456,
type: 'manual'
});
historyModeStore.set('manual');
const store: HistoryEntry[] = get(historyStore);
expect(store.length).toBe(2);
clearHistoryData(store[1].id);
expect(get(historyStore).length).toBe(1);
clearHistoryData();
expect(get(historyStore).length).toBe(0);
historyModeStore.set('auto');
addHistoryEntry({
state: defaultState,
time: 54_321,
type: 'auto'
});
addHistoryEntry({
state: { ...defaultState, code: 'graph TD\\n A[Christmas] -->|Get money| B(Go shopping)' },
time: 654_321,
type: 'auto'
});
expect(get(historyStore).length).toBe(2);
clearHistoryData();
expect(get(historyStore).length).toBe(0);
// Test calling when history is empty
clearHistoryData();
expect(get(historyStore).length).toBe(0);
});
});
describe('history migration', () => {
it('should inject history IDs as migration', () => {
window.localStorage.setItem(
'manualHistoryStore',
'[{"state":{"code":"graph TD\\n A[Halloween] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":false},"time":0,"type":"manual","name":"hollow-art"},{"state":{"code":"graph TD\\n A[Christmas] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":true},"time":0,"type":"manual","name":"helpful-ocean"}]'
);
window.localStorage.setItem(
'autoHistoryStore',
'[{"state":{"code":"graph TD\\n A[New Year] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":false},"time":0,"type":"auto","name":"barking-dog"},{"state":{"code":"graph TD\\n A[Christmas] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":true},"time":0,"type":"manual","name":"needy-mosquito"}]'
);
let manualHistoryStore = JSON.parse(
window.localStorage.getItem('manualHistoryStore') ?? '[]'
) as HistoryEntry[],
autoHistoryStore = JSON.parse(
window.localStorage.getItem('autoHistoryStore') ?? '[]'
) as HistoryEntry[];
expect(manualHistoryStore.every(({ id }) => id !== undefined)).toBe(false);
expect(autoHistoryStore.every(({ id }) => id !== undefined)).toBe(false);
injectHistoryIDs();
manualHistoryStore = JSON.parse(
window.localStorage.getItem('manualHistoryStore') ?? '[]'
) as HistoryEntry[];
autoHistoryStore = JSON.parse(
window.localStorage.getItem('autoHistoryStore') ?? '[]'
) as HistoryEntry[];
expect(manualHistoryStore.every(({ id }) => id !== undefined)).toBe(true);
expect(autoHistoryStore.every(({ id }) => id !== undefined)).toBe(true);
});
});
-151
View File
@@ -1,151 +0,0 @@
import type { HistoryEntry, HistoryType, Optional } from '$lib/types';
import { localStorage, persist } from '$lib/util/persist';
import { logEvent } from '$lib/util/stats';
import { generateSlug } from 'random-word-slugs';
import type { Readable, Writable } from 'svelte/store';
import { derived, get, writable } from 'svelte/store';
import { v4 as uuidV4 } from 'uuid';
const MAX_AUTO_HISTORY_LENGTH = 30;
export const historyModeStore: Writable<HistoryType> = persist(
writable('manual'),
localStorage(),
'autoHistoryMode'
);
const autoHistoryStore: Writable<HistoryEntry[]> = persist(
writable([]),
localStorage(),
'autoHistoryStore'
);
const manualHistoryStore: Writable<HistoryEntry[]> = persist(
writable([]),
localStorage(),
'manualHistoryStore'
);
export const loaderHistoryStore: Writable<HistoryEntry[]> = writable([]);
export const historyStore: Readable<HistoryEntry[]> = derived(
[historyModeStore, autoHistoryStore, manualHistoryStore, loaderHistoryStore],
([historyMode, autoHistories, manualHistories, loadedHistories], set) => {
switch (historyMode) {
case 'auto': {
set(autoHistories);
break;
}
case 'manual': {
set(manualHistories);
break;
}
case 'loader': {
set(loadedHistories);
break;
}
default: {
set(autoHistories);
}
}
}
);
export const addHistoryEntry = (entryToAdd: Optional<HistoryEntry, 'id'>): void => {
const entry: HistoryEntry = {
...entryToAdd,
id: uuidV4()
};
if (entry.type === 'loader') {
loaderHistoryStore.update((entries) => [entry, ...entries]);
return;
}
if (!entry.name) {
entry.name = generateSlug(2);
}
if (entry.type === 'auto') {
autoHistoryStore.update((entries) => {
if (entries.length >= MAX_AUTO_HISTORY_LENGTH) {
entries = entries.slice(0, MAX_AUTO_HISTORY_LENGTH - 1);
}
return [entry, ...entries];
});
}
manualHistoryStore.update((entries) => [entry, ...entries]);
logEvent('history', { action: 'save' });
};
export const clearHistoryData = (idToClear?: string): void => {
(get(historyModeStore) === 'auto' ? autoHistoryStore : manualHistoryStore).update((entries) => {
if (get(historyModeStore) !== 'loader') {
entries = entries.filter(({ id }) => idToClear && id != idToClear);
logEvent('history', { action: 'clear', type: idToClear ? 'single' : 'all' });
}
return entries;
});
};
export const getPreviousState = (auto: boolean): string => {
const entries = get(auto ? autoHistoryStore : manualHistoryStore);
if (entries.length > 0) {
return JSON.stringify(entries[0].state);
}
return '';
};
export const restoreHistory = (data: HistoryEntry[]) => {
const entries = data.filter((element) => validateEntry(element));
const invalidEntryCount = data.length - entries.length;
if (invalidEntryCount > 0) {
console.error(`${invalidEntryCount} invalid history entries were removed.`);
console.error(data);
}
if (entries.length > 0) {
let entryCount = 0;
(entries[0].type === 'auto' ? autoHistoryStore : manualHistoryStore).update((existing) => {
const existingIDs = new Set(existing.map(({ id }) => id));
const newEntries = entries.filter(({ id }) => !existingIDs.has(id));
entryCount = newEntries.length;
const combined = [...existing, ...newEntries];
combined.sort((a, b) => b.time - a.time);
return combined;
});
alert(
`${entryCount} entries restored. ${invalidEntryCount} invalid, ${
entries.length - entryCount
} duplicates.`
);
logEvent('history', {
action: 'restore',
success: entryCount,
invalid: invalidEntryCount,
duplicates: entries.length - entryCount
});
} else {
alert('No valid entries found.');
}
};
const setIDs = (entries: HistoryEntry[]) => {
for (const entry of entries) {
if (!entry.id) {
entry.id = uuidV4();
}
}
return entries;
};
export const injectHistoryIDs = (): void => {
autoHistoryStore.update(setIDs);
manualHistoryStore.update(setIDs);
};
const validateEntry = (entry: HistoryEntry): boolean => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
return entry.type && entry.state && entry.time && true;
};
@@ -0,0 +1,212 @@
import type { HistoryEntry, HistoryType, Optional, State } from '$lib/types';
import { inputStateStore } from '$lib/util/state';
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;
const AUTO_SAVE_INTERVAL = 60_000;
const hasStorage = (): boolean => typeof window !== 'undefined' && !!window.localStorage;
const readJSON = <T>(key: string, fallback: T): T => {
if (!hasStorage()) {
return fallback;
}
try {
const raw = window.localStorage.getItem(key);
return raw === null ? fallback : (JSON.parse(raw) as T);
} catch {
return fallback;
}
};
const writeJSON = (key: string, value: unknown): void => {
if (hasStorage()) {
window.localStorage.setItem(key, JSON.stringify(value));
}
};
interface Persisted<T> {
value: T;
}
// A localStorage-backed reactive value. Reads on init, writes on every set.
const persisted = <T>(key: string, initial: T): Persisted<T> => {
let value = $state<T>(readJSON(key, initial));
return {
get value() {
return value;
},
set value(next: T) {
value = next;
writeJSON(key, next);
}
};
};
const auto = persisted<HistoryEntry[]>('autoHistoryStore', []);
const manual = persisted<HistoryEntry[]>('manualHistoryStore', []);
const mode = persisted<HistoryType>('autoHistoryMode', 'manual');
let loader = $state<HistoryEntry[]>([]);
// Loader entries are in-memory, so a persisted 'loader' mode is empty after reload.
if (mode.value === 'loader') {
mode.value = 'manual';
}
// The persisted slot backing a mode; loader is in-memory and has no slot.
const slotFor = (m: HistoryType): Persisted<HistoryEntry[]> | null => {
switch (m) {
case 'auto': {
return auto;
}
case 'manual': {
return manual;
}
default: {
return null;
}
}
};
export const historyState = {
get entries(): HistoryEntry[] {
return slotFor(mode.value)?.value ?? loader;
},
get loaderEntries(): HistoryEntry[] {
return loader;
},
get mode(): HistoryType {
return mode.value;
}
};
export const setMode = (next: HistoryType): void => {
mode.value = next;
};
// Dedup key: only the fields that define the diagram, so volatile/view-only
// fields (renderCount, pan/zoom, …) don't count as a change.
export const stateKey = (state: State): string =>
JSON.stringify({ code: state.code, mermaid: state.mermaid });
const createEntry = (state: State, type: 'auto' | 'manual'): HistoryEntry => ({
id: uuidV4(),
name: generateSlug(2),
state,
time: Date.now(),
type
});
// Returns true if added, false if it duplicated the most recent entry.
const addEntry = (
slot: Persisted<HistoryEntry[]>,
state: State,
type: 'auto' | 'manual',
maxLength?: number
): boolean => {
const entries = slot.value;
if (entries.length > 0 && stateKey(entries[0].state) === stateKey(state)) {
return false;
}
const trimmed =
maxLength && entries.length >= maxLength ? entries.slice(0, maxLength - 1) : entries;
slot.value = [createEntry(state, type), ...trimmed];
logEvent('history', { action: 'save', type });
return true;
};
export const addManualEntry = (state: State): boolean => addEntry(manual, state, 'manual');
export const addAutoEntry = (state: State): boolean =>
addEntry(auto, state, 'auto', MAX_AUTO_HISTORY_LENGTH);
// Replaces the in-memory revisions (e.g. when a gist is loaded), assigning ids.
export const setLoaderEntries = (entries: Optional<HistoryEntry, 'id'>[]): void => {
loader = entries.map((entry) =>
entry.id ? (entry as HistoryEntry) : { ...entry, id: uuidV4() }
);
};
export const removeEntry = (id: string): void => {
const slot = slotFor(mode.value);
if (!slot) {
return;
}
slot.value = slot.value.filter((entry) => entry.id !== id);
logEvent('history', { action: 'clear', type: 'single' });
};
export const clearActive = (): void => {
const slot = slotFor(mode.value);
if (!slot) {
return;
}
slot.value = [];
logEvent('history', { action: 'clear', type: 'all' });
};
const validateEntry = (entry: HistoryEntry): boolean =>
Boolean(entry && entry.type && entry.state) && typeof entry.time === 'number';
export interface RestoreResult {
restored: number;
invalid: number;
duplicates: number;
}
// Routes each uploaded entry to the store matching its own type, skipping ids
// that already exist.
export const restoreEntries = (data: HistoryEntry[]): RestoreResult => {
const valid = data.filter((entry) => validateEntry(entry));
const invalid = data.length - valid.length;
let restored = 0;
const slots: [HistoryType, Persisted<HistoryEntry[]>][] = [
['auto', auto],
['manual', manual]
];
for (const [type, slot] of slots) {
const incoming = valid.filter((entry) => entry.type === type);
if (incoming.length === 0) {
continue;
}
const existingIDs = slot.value.map(({ id }) => id);
const fresh = incoming.filter(({ id }) => !existingIDs.includes(id));
restored += fresh.length;
slot.value = [...slot.value, ...fresh].sort((a, b) => b.time - a.time);
}
const duplicates = valid.length - restored;
logEvent('history', { action: 'restore', duplicates, invalid, success: restored });
return { restored, invalid, duplicates };
};
const setIDs = (entries: HistoryEntry[]): HistoryEntry[] =>
entries.map((entry) => (entry.id ? entry : { ...entry, id: uuidV4() }));
// One-time migration: re-reads localStorage so entries written by an older
// version get ids, then persists and updates the reactive state.
export const injectHistoryIDs = (): void => {
auto.value = setIDs(readJSON<HistoryEntry[]>('autoHistoryStore', []));
manual.value = setIDs(readJSON<HistoryEntry[]>('manualHistoryStore', []));
};
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);
}
return stopAutoSave;
};
export const stopAutoSave = (): void => {
if (autoSaveTimer !== undefined) {
clearInterval(autoSaveTimer);
autoSaveTimer = undefined;
}
};
@@ -0,0 +1,314 @@
import type { HistoryEntry } from '$lib/types';
import { defaultState, inputStateStore } from '$lib/util/state';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
addAutoEntry,
addManualEntry,
clearActive,
historyState,
injectHistoryIDs,
removeEntry,
restoreEntries,
setLoaderEntries,
setMode,
startAutoSave,
stateKey,
stopAutoSave
} from './historyState.svelte';
const codeState = (code: string) => ({ ...defaultState, code });
/** Read the entries currently shown for a given mode. */
const entriesFor = (mode: 'auto' | 'manual' | 'loader'): HistoryEntry[] => {
setMode(mode);
return historyState.entries;
};
beforeEach(() => {
// Reset every store through the public API so tests don't leak into each other.
setMode('manual');
clearActive();
setMode('auto');
clearActive();
setLoaderEntries([]);
setMode('manual');
});
describe('stateKey', () => {
it('ignores volatile and view-only fields, keying only on code + config', () => {
const a = {
...defaultState,
code: 'graph TD\n A-->B',
panZoom: true,
renderCount: 1,
updateDiagram: true
};
const b = {
...defaultState,
code: 'graph TD\n A-->B',
pan: { x: 5, y: 5 },
panZoom: false,
renderCount: 99,
updateDiagram: false
};
expect(stateKey(a)).toBe(stateKey(b));
});
it('differs when code differs', () => {
expect(stateKey(codeState('graph TD\n A-->B'))).not.toBe(
stateKey(codeState('graph TD\n A-->C'))
);
});
it('differs when config differs', () => {
const a = { ...defaultState, mermaid: '{"theme":"dark"}' };
const b = { ...defaultState, mermaid: '{"theme":"forest"}' };
expect(stateKey(a)).not.toBe(stateKey(b));
});
});
describe('addManualEntry', () => {
it('adds to the manual store only, never the auto store', () => {
expect(addManualEntry(codeState('graph TD\n A-->B'))).toBe(true);
expect(entriesFor('manual')).toHaveLength(1);
expect(entriesFor('auto')).toHaveLength(0);
});
it('returns false and does not add a duplicate of the latest entry', () => {
const state = codeState('graph TD\n A-->B');
expect(addManualEntry(state)).toBe(true);
expect(addManualEntry(state)).toBe(false);
expect(entriesFor('manual')).toHaveLength(1);
});
it('treats states differing only in volatile/view fields as duplicates', () => {
expect(addManualEntry({ ...defaultState, code: 'graph TD\n A-->B', renderCount: 1 })).toBe(
true
);
expect(
addManualEntry({
...defaultState,
code: 'graph TD\n A-->B',
panZoom: false,
renderCount: 2,
updateDiagram: true
})
).toBe(false);
expect(entriesFor('manual')).toHaveLength(1);
});
it('adds a new entry when the code changes', () => {
expect(addManualEntry(codeState('graph TD\n A-->B'))).toBe(true);
expect(addManualEntry(codeState('graph TD\n A-->C'))).toBe(true);
expect(entriesFor('manual')).toHaveLength(2);
});
it('generates an id and a name for each entry', () => {
addManualEntry(codeState('graph TD\n A-->B'));
const [entry] = entriesFor('manual');
expect(entry.id).toBeTruthy();
expect(entry.name).toBeTruthy();
expect(entry.type).toBe('manual');
});
});
describe('addAutoEntry', () => {
it('adds to the auto store only, never the manual store', () => {
expect(addAutoEntry(codeState('graph TD\n A-->B'))).toBe(true);
expect(entriesFor('auto')).toHaveLength(1);
expect(entriesFor('manual')).toHaveLength(0);
});
it('returns false and does not add a duplicate of the latest entry', () => {
const state = codeState('graph TD\n A-->B');
expect(addAutoEntry(state)).toBe(true);
expect(addAutoEntry(state)).toBe(false);
expect(entriesFor('auto')).toHaveLength(1);
});
it('caps the auto store at 30 entries, dropping the oldest', () => {
for (let i = 0; i < 35; i++) {
addAutoEntry(codeState(`graph TD\n A-->B${i}`));
}
const entries = entriesFor('auto');
expect(entries).toHaveLength(30);
expect(entries[0].state.code).toBe('graph TD\n A-->B34');
});
});
describe('historyState.entries', () => {
it('reflects the active mode', () => {
addManualEntry(codeState('manual-code'));
addAutoEntry(codeState('auto-code'));
setMode('manual');
expect(historyState.entries).toHaveLength(1);
expect(historyState.entries[0].state.code).toBe('manual-code');
setMode('auto');
expect(historyState.entries).toHaveLength(1);
expect(historyState.entries[0].state.code).toBe('auto-code');
setMode('loader');
expect(historyState.entries).toHaveLength(0);
});
});
describe('removeEntry / clearActive', () => {
it('removes a single entry from the active store by id', () => {
addManualEntry(codeState('graph TD\n A-->B'));
addManualEntry(codeState('graph TD\n A-->C'));
setMode('manual');
const target = historyState.entries[1].id;
removeEntry(target);
expect(historyState.entries).toHaveLength(1);
expect(historyState.entries.some((e) => e.id === target)).toBe(false);
});
it('clears all entries in the active store only', () => {
addManualEntry(codeState('graph TD\n A-->B'));
addAutoEntry(codeState('graph TD\n A-->C'));
setMode('manual');
clearActive();
expect(entriesFor('manual')).toHaveLength(0);
expect(entriesFor('auto')).toHaveLength(1);
});
it('does nothing in loader mode', () => {
setLoaderEntries([
{ name: 'rev', state: defaultState, time: 1, type: 'loader', url: 'http://x' }
]);
setMode('loader');
clearActive();
expect(historyState.entries).toHaveLength(1);
});
});
describe('setLoaderEntries', () => {
it('replaces the in-memory revisions and assigns ids', () => {
setLoaderEntries([
{ name: 'v1', state: defaultState, time: 1, type: 'loader', url: 'http://x/1' },
{ name: 'v2', state: defaultState, time: 2, type: 'loader', url: 'http://x/2' }
]);
setMode('loader');
expect(historyState.entries).toHaveLength(2);
expect(historyState.entries.every((e) => e.id)).toBe(true);
setLoaderEntries([
{ name: 'only', state: defaultState, time: 3, type: 'loader', url: 'http://x/3' }
]);
expect(historyState.entries).toHaveLength(1);
expect(historyState.entries[0].name).toBe('only');
});
});
describe('restoreEntries', () => {
it('routes each entry to the store matching its own type', () => {
const result = restoreEntries([
{ id: 'a1', name: 'a', state: defaultState, time: 10, type: 'auto' },
{ id: 'm1', name: 'm', state: defaultState, time: 20, type: 'manual' }
]);
expect(result.restored).toBe(2);
expect(entriesFor('auto').map((e) => e.id)).toEqual(['a1']);
expect(entriesFor('manual').map((e) => e.id)).toEqual(['m1']);
});
it('skips duplicates by id and reports them', () => {
restoreEntries([{ id: 'm1', name: 'm', state: defaultState, time: 20, type: 'manual' }]);
const result = restoreEntries([
{ id: 'm1', name: 'm', state: defaultState, time: 20, type: 'manual' },
{ id: 'm2', name: 'm2', state: defaultState, time: 30, type: 'manual' }
]);
expect(result.restored).toBe(1);
expect(result.duplicates).toBe(1);
expect(entriesFor('manual')).toHaveLength(2);
});
it('reports invalid entries and does not restore them', () => {
const result = restoreEntries([
{ id: 'm1', name: 'm', state: defaultState, time: 20, type: 'manual' },
{ foo: 'bar' } as unknown as HistoryEntry
]);
expect(result.restored).toBe(1);
expect(result.invalid).toBe(1);
});
it('sorts restored entries newest first', () => {
restoreEntries([
{ id: 'm1', name: 'old', state: defaultState, time: 10, type: 'manual' },
{ id: 'm2', name: 'new', state: defaultState, time: 30, type: 'manual' },
{ id: 'm3', name: 'mid', state: defaultState, time: 20, type: 'manual' }
]);
expect(entriesFor('manual').map((e) => e.time)).toEqual([30, 20, 10]);
});
it('restores entries whose time is 0 (epoch) instead of treating them as invalid', () => {
const result = restoreEntries([
{ id: 'm0', name: 'epoch', state: defaultState, time: 0, type: 'manual' }
]);
expect(result.restored).toBe(1);
expect(result.invalid).toBe(0);
expect(entriesFor('manual')).toHaveLength(1);
});
});
describe('injectHistoryIDs migration', () => {
it('adds ids to persisted entries that lack them', () => {
window.localStorage.setItem(
'manualHistoryStore',
'[{"state":{"code":"a"},"time":1,"type":"manual","name":"x"}]'
);
window.localStorage.setItem(
'autoHistoryStore',
'[{"state":{"code":"b"},"time":2,"type":"auto","name":"y"}]'
);
injectHistoryIDs();
const manual = JSON.parse(
window.localStorage.getItem('manualHistoryStore') ?? '[]'
) as HistoryEntry[];
const auto = JSON.parse(
window.localStorage.getItem('autoHistoryStore') ?? '[]'
) as HistoryEntry[];
expect(manual).toHaveLength(1);
expect(auto).toHaveLength(1);
expect(manual.every(({ id }) => id !== undefined)).toBe(true);
expect(auto.every(({ id }) => id !== undefined)).toBe(true);
});
});
describe('auto-save lifecycle', () => {
afterEach(() => {
stopAutoSave();
vi.useRealTimers();
});
it('records an auto entry on each interval from the current editor state', () => {
vi.useFakeTimers();
inputStateStore.set(codeState('graph TD\n auto-saved'));
startAutoSave();
vi.advanceTimersByTime(60_000);
const entries = entriesFor('auto');
expect(entries).toHaveLength(1);
expect(entries[0].state.code).toBe('graph TD\n auto-saved');
});
it('is idempotent: calling startAutoSave twice does not double-record', () => {
vi.useFakeTimers();
inputStateStore.set(codeState('graph TD\n once'));
startAutoSave();
startAutoSave();
vi.advanceTimersByTime(60_000);
expect(entriesFor('auto')).toHaveLength(1);
});
it('stops recording after stopAutoSave', () => {
vi.useFakeTimers();
inputStateStore.set(codeState('graph TD\n stoppable'));
startAutoSave();
vi.advanceTimersByTime(60_000);
stopAutoSave();
inputStateStore.set(codeState('graph TD\n after-stop'));
vi.advanceTimersByTime(60_000);
expect(entriesFor('auto')).toHaveLength(1);
});
});
+4 -4
View File
@@ -28,7 +28,7 @@
sharesData?: boolean;
checkDiagramType?: boolean;
isSectionEnd?: boolean;
renderer: (item: Omit<MenuItem, 'renderer'>) => ReturnType<Snippet>;
renderer: Snippet<[Omit<MenuItem, 'renderer'>]>;
}
const menuItems: MenuItem[] = $derived([
@@ -89,7 +89,7 @@
]);
</script>
{#snippet menuItem(options: MenuItem)}
{#snippet menuItem(options: Omit<MenuItem, 'renderer'>)}
<a
href={options.href}
target="_blank"
@@ -104,7 +104,7 @@
</a>
{/snippet}
{#snippet mcMenuItem(item: MenuItem)}
{#snippet mcMenuItem(item: Omit<MenuItem, 'renderer'>)}
<McWrapper
side="right"
labelPrefix={item.sharesData === false ? 'Opens a new tab in' : undefined}
@@ -114,7 +114,7 @@
</McWrapper>
{/snippet}
{#snippet darkModeMenuItem(options: MenuItem)}
{#snippet darkModeMenuItem(options: Omit<MenuItem, 'renderer'>)}
<div
class={cn(
'flex cursor-pointer items-center justify-between border-b-2 px-3 py-2 hover:bg-muted',
+3 -3
View File
@@ -13,7 +13,7 @@
import { Button } from '$/components/ui/button';
import { Separator } from '$/components/ui/separator';
import { dismissPromotion, getActivePromotion } from '$lib/util/promos/promo';
import type { ComponentProps, Snippet } from 'svelte';
import { untrack, type ComponentProps, type Snippet } from 'svelte';
import MermaidIcon from '~icons/custom/mermaid';
import CloseIcon from '~icons/material-symbols/close-rounded';
import GithubIcon from '~icons/mdi/github';
@@ -41,7 +41,7 @@
}
];
let activePromotion = $state(hidePromotion ? undefined : getActivePromotion());
let activePromotion = $state(untrack(() => (hidePromotion ? undefined : getActivePromotion())));
const trackBannerClick = () => {
if (!activePromotion) {
@@ -84,7 +84,7 @@
<div class="flex flex-1 items-center gap-2">
<MainMenu />
<MermaidIcon class="size-6" />
<a href={resolve('/')} class="whitespace-nowrap text-accent">
<a href={resolve('/', {})} class="whitespace-nowrap text-accent">
{#if !mobileToggle}
Mermaid
{/if}
+1 -1
View File
@@ -35,7 +35,7 @@
const samples = { ...getSampleDiagrams(), ...extras } as const;
const loadSampleDiagram = (diagramType: string): void => {
updateCode(samples[diagramType], {
updateCode(samples[diagramType as keyof typeof samples], {
resetPanZoom: true,
updateDiagram: true
});
+1 -1
View File
@@ -1,4 +1,4 @@
<script>
<script lang="ts">
import ExternalLinkWrapper from '$/components/ExternalLinkWrapper.svelte';
import * as Dialog from '$/components/ui/dialog';
import { env } from '$/util/env';
+1 -1
View File
@@ -1,4 +1,4 @@
<script>
<script lang="ts">
import { buttonVariants } from '$/components/ui/button';
import * as Dialog from '$/components/ui/dialog';
import { Separator } from '$/components/ui/separator';
@@ -1,3 +1,5 @@
<script lang="ts"></script>
<div class="text-center">
<a
href="https://mermaid.ai/privacy-policy"
@@ -24,8 +24,12 @@
}: ToggleGroupPrimitive.RootProps & ToggleVariants = $props();
setToggleGroupCtx({
variant,
size
get variant() {
return variant;
},
get size() {
return size;
}
});
</script>
+8 -6
View File
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { addHistoryEntry } from '$lib/components/History/history';
import { setLoaderEntries } from '$lib/components/History/historyState.svelte';
import type { State } from '$lib/types';
import { defaultState } from '$lib/util/state';
import { fetchJSON, fetchText } from '$lib/util/util';
@@ -117,14 +117,16 @@ export const loadGistData = async (gistURL: string): Promise<State> => {
throw new Error('Invalid gist provided');
}
const state = getStateFromGist(entry, gistURL);
for (const gist of gistHistory) {
addHistoryEntry({
setLoaderEntries(
gistHistory
.map((gist) => ({
name: `${gist.author} v${gist.version}`,
state: getStateFromGist(gist),
time: gist.time,
type: 'loader',
type: 'loader' as const,
url: gist.url
});
}
}))
.reverse()
);
return state;
};
+1 -1
View File
@@ -1,6 +1,6 @@
import { writable, get, type Writable } from 'svelte/store';
import { persist, localStorage } from '$lib/util/persist';
import { injectHistoryIDs } from '$lib/components/History/history';
import { injectHistoryIDs } from '$lib/components/History/historyState.svelte';
import { logEvent } from './stats';
interface MigrationState {
+2 -2
View File
@@ -1,7 +1,7 @@
import { env } from '$lib/util/env';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import type { Component } from 'svelte';
import type { Component, Snippet } from 'svelte';
import { get, writable, type Writable } from 'svelte/store';
import { localStorage, persist } from '../persist';
import April2025 from './April2025.svelte';
@@ -12,7 +12,7 @@ dayjs.extend(duration);
interface Promotion {
startDate: Date;
endDate: Date;
component: Component;
component: Component<{ closeBanner: Snippet }>;
hideDurationMs: number;
}
+1 -5
View File
@@ -189,7 +189,7 @@ function getUnsafePaths(object: object, unsafeKeys: string[], path: string[] = [
}
}
Object.keys(object).forEach((key) => {
const value = object[key] as unknown;
const value = (object as Record<string, unknown>)[key];
const currentPath = [...path, key];
// Prototype pollution check.
if (key.startsWith('__')) {
@@ -317,10 +317,6 @@ export const initURLSubscription = (): void => {
});
};
export const getStateString = (): string => {
return JSON.stringify(get(inputStateStore));
};
export const verifyState = (): void => {
const state = get(inputStateStore);
if (!state.panZoom) {
+5 -1
View File
@@ -5,6 +5,7 @@
import Editor from '$/components/Editor.svelte';
import EnhancedEditsButton from '$/components/EnhancedEditsButton.svelte';
import History from '$/components/History/History.svelte';
import { startAutoSave } from '$/components/History/historyState.svelte';
import McWrapper from '$/components/McWrapper.svelte';
import MermaidChartIcon from '$/components/MermaidChartIcon.svelte';
import EditorChooserModal from '$/components/migration/EditorChooserModal.svelte';
@@ -63,6 +64,9 @@
});
});
// Record the Timeline for the whole session, not just while the panel is open.
onMount(() => startAutoSave());
let isHistoryOpen = $state(false);
let editorPane: Resizable.Pane | undefined;
@@ -87,7 +91,7 @@
{/snippet}
<Navbar mobileToggle={isMobile ? mobileToggle : undefined}>
<Toggle bind:pressed={isHistoryOpen} size="sm">
<Toggle bind:pressed={isHistoryOpen} size="sm" title="History" aria-label="History">
<HistoryIcon />
</Toggle>
<Share />
+120 -57
View File
@@ -1,84 +1,147 @@
import { expect, test } from '@playwright/test';
import { typeInEditor } from './utils';
import { expect, test, type Page } from '@playwright/test';
test.describe.skip('Save History', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(Date, 'now', {
value: () => new Date(2022, 0, 1).getTime()
const config = '{\n "theme": "default"\n}';
const entry = (id: string, name: string, type: 'manual' | 'auto', label: string) => ({
id,
name,
type,
time: Number(id.slice(2)),
state: {
code: `flowchart TD\n A[${label}]`,
mermaid: config,
autoSync: true,
updateDiagram: false
}
});
const manualHistory = [
entry('m-2', 'hollow-art', 'manual', 'Halloween'),
entry('m-1', 'helpful-ocean', 'manual', 'Pumpkin')
];
const autoHistory = [
entry('a-2', 'barking-dog', 'auto', 'NewYear'),
entry('a-1', 'needy-mosquito', 'auto', 'Fireworks')
];
const openHistory = (page: Page) => page.getByRole('button', { name: 'History' }).click();
test.describe('History', () => {
test.beforeEach(async ({ page }) => {
// Freeze time so auto-save snapshots are deterministic.
await page.addInitScript(() => {
Object.defineProperty(Date, 'now', { value: () => new Date(2022, 0, 1).getTime() });
});
await page.goto('/edit');
await page.getByText('History').click();
});
test('should load history from localstorage', async ({ page }) => {
await page.evaluate(() => {
localStorage.setItem(
'manualHistoryStore',
'[{"state":{"code":"graph TD\\n A[Halloween] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":false},"time":0,"type":"manual","id":"d7ea820e-21dd-418a-b984-fd58acde09df","name":"hollow-art"},{"state":{"code":"graph TD\\n A[Christmas] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":true},"time":0,"type":"manual","id":"b749ffc6-522b-4a44-86cf-7c1ffc3146b3","name":"helpful-ocean"}]'
test('loads Saved and Timeline history from localStorage and restores entries', async ({
page
}) => {
await page.evaluate(
([manual, auto]) => {
localStorage.setItem('manualHistoryStore', manual);
localStorage.setItem('autoHistoryStore', auto);
},
[JSON.stringify(manualHistory), JSON.stringify(autoHistory)]
);
localStorage.setItem(
'autoHistoryStore',
'[{"state":{"code":"graph TD\\n A[New Year] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":false},"time":0,"type":"auto","id":"69ea820e-522b-4a44-86cf-fd58acde09df","name":"barking-dog"},{"state":{"code":"graph TD\\n A[Christmas] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":true},"time":0,"type":"manual","id":"x749ffc6-21dd-418a-b984-7c1ffc3146b3","name":"needy-mosquito"}]'
);
});
await page.reload();
await page.getByText('History').click();
await openHistory(page);
// Saved tab is active by default.
await expect(page.locator('#historyList li')).toHaveCount(2);
await expect(page.locator('#historyList').getByText('No items in History')).not.toBeVisible();
await expect(page.locator('#historyList')).toContainText('helpful-ocean');
await expect(page.locator('#historyList')).toContainText('hollow-art');
await page.getByText('Restore').first().click();
await expect(page.locator('#view').getByText('Halloween')).toBeVisible();
await page.getByText('Timeline').click();
await expect(page.locator('#historyList')).toContainText('helpful-ocean');
await page.getByRole('button', { name: 'Restore this version' }).first().click();
await expect(page.locator('#view')).toContainText('Halloween');
// Switching to the Timeline tab shows the auto entries only.
await page.getByRole('tab', { name: 'Timeline' }).click();
await expect(page.locator('#historyList li')).toHaveCount(2);
await expect(page.locator('#historyList').getByText('No items in History')).not.toBeVisible();
await expect(page.locator('#historyList')).toContainText('needy-mosquito');
await expect(page.locator('#historyList')).toContainText('barking-dog');
await page.getByText('Restore').first().click();
await expect(page.locator('#view').getByText('New Year')).toBeVisible();
await expect(page.locator('#historyList')).toContainText('needy-mosquito');
await expect(page.locator('#historyList')).not.toContainText('hollow-art');
await page.getByRole('button', { name: 'Restore this version' }).first().click();
await expect(page.locator('#view')).toContainText('NewYear');
});
test.skip('should save when clicked', async ({ page }) => {
await expect(page.locator('#historyList li')).toHaveCount(0);
await expect(page.locator('#historyList')).toContainText('No items in History');
await page.locator('#saveHistory').click();
await expect(page.locator('#historyList').getByText('No items in History')).not.toBeVisible();
await expect(page.locator('#historyList li')).toHaveCount(1);
const dialogPromise = page.waitForEvent('dialog');
await page.locator('#saveHistory').click();
const dialog = await dialogPromise;
expect(dialog.message()).toBe('State already saved.');
await dialog.accept();
test('each entry has a copyable link that opens it in a new tab', async ({ page }) => {
await page.evaluate(
(manual) => localStorage.setItem('manualHistoryStore', manual),
JSON.stringify(manualHistory)
);
await page.reload();
await openHistory(page);
await typeInEditor(page, ' C --> HistoryTest');
// It is a real link (so it can be copied / opened in a new tab), not a button.
const link = page.getByRole('link', { name: 'Open in new tab' }).first();
await expect(link).toHaveAttribute('target', '_blank');
const href = await link.getAttribute('href');
expect(href).toContain('/edit#pako:');
// Following it loads that entry's diagram.
await page.goto(href ?? '');
await expect(page.locator('#view')).toContainText('Halloween');
});
test('keeps the active tab highlighted when switching modes', async ({ page }) => {
await openHistory(page);
const saved = page.getByRole('tab', { name: 'Saved' });
const timeline = page.getByRole('tab', { name: 'Timeline' });
await expect(saved).toHaveClass(/border-b-2/);
await expect(timeline).not.toHaveClass(/border-b-2/);
await timeline.click();
await expect(timeline).toHaveClass(/border-b-2/);
await expect(saved).not.toHaveClass(/border-b-2/);
});
test('saves the current state and reports duplicates', async ({ page }) => {
await openHistory(page);
await expect(page.locator('#historyList li')).toHaveCount(0);
await page.locator('#saveHistory').click();
await expect(page.locator('#historyList li')).toHaveCount(1);
// Saving again without changes does not add a duplicate and notifies the user.
await page.locator('#saveHistory').click();
await expect(page.getByText('State already saved.')).toBeVisible();
await expect(page.locator('#historyList li')).toHaveCount(1);
// Loading a different sample changes the state, so it saves as a new entry.
await page.getByRole('button', { name: 'Sequence', exact: true }).click();
await expect(page.locator('#view')).not.toContainText('Christmas');
await page.locator('#saveHistory').click();
await expect(page.locator('#historyList li')).toHaveCount(2);
});
test.skip('should be able to restore and delete', async ({ page }) => {
test('auto-saves to the Timeline only, never the Saved list', async ({ page }) => {
await openHistory(page);
await page.locator('#saveHistory').click();
await typeInEditor(page, ' C --> HistoryTest');
await expect(page.locator('#historyList').getByText('No items in History')).not.toBeVisible();
await expect(page.locator('#historyList li')).toHaveCount(1);
await expect(page.locator('#view').getByText('HistoryTest')).toBeVisible();
await page.getByText('Restore').click();
await expect(page.locator('#view').getByText('HistoryTest')).not.toBeVisible();
await page.getByText('Delete').click();
await expect(page.locator('#historyList li')).toHaveCount(0);
await expect(page.locator('#historyList')).toContainText('No items in History');
await page.getByRole('tab', { name: 'Timeline' }).click();
// A manual save must not appear under Timeline.
await expect(page.locator('#historyList')).toContainText('No timeline snapshots yet.');
});
test('deletes a single entry and clears all after confirmation', async ({ page }) => {
await openHistory(page);
await page.locator('#saveHistory').click();
await typeInEditor(page, ' C --> HistoryTest');
await page.getByRole('button', { name: 'Sequence', exact: true }).click();
await expect(page.locator('#view')).not.toContainText('Christmas');
await page.locator('#saveHistory').click();
await page.locator('#editor').type('ing');
await expect(page.locator('#historyList li')).toHaveCount(2);
await page.getByRole('button', { name: 'Delete this version' }).first().click();
await expect(page.locator('#historyList li')).toHaveCount(1);
page.on('dialog', (dialog) => dialog.accept());
await page.locator('#clearHistory').click();
const dialog = await page.waitForEvent('dialog');
expect(dialog.message()).toBe('Clear all saved items?');
await dialog.accept();
await expect(page.locator('#historyList')).toContainText('No items in History');
await expect(page.locator('#historyList li')).toHaveCount(0);
await expect(page.locator('#historyList')).toContainText('No saved states yet.');
});
});
+1
View File
@@ -12,6 +12,7 @@
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"strictNullChecks": true,
"skipLibCheck": true,
"types": ["vitest/importMeta", "@playwright/test"]
},
"extends": "./.svelte-kit/tsconfig.json"