Compare commits

..
Author SHA1 Message Date
Sidharth Vinod d1fd4dc244 feat: More edit modes in CTA 2026-04-02 17:33:52 +05:30
Sidharth Vinod 9436f8ddda chore: Remove canonical 2026-04-01 22:43:58 +05:30
66 changed files with 5110 additions and 4961 deletions
-49
View File
@@ -1,49 +0,0 @@
name: Docker Build
on:
push:
branches:
- master
- develop
pull_request:
branches:
- master
- develop
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login to Gitea Container Registry
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: gitea.forkless.com
username: ${{ secrets.GITEAUSER }}
password: ${{ secrets.GITEATOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: gitea.forkless.com/forkless/mermaid-live-editor
tags: |
type=raw,value=latest,enable=${{ github.ref_name == 'master' }}
type=raw,value=nightly,enable=${{ github.ref_name == 'develop' }}
type=sha,prefix={{branch}}-
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
target: mermaid
push: ${{ github.event_name == 'push' }}
pull: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
MERMAID_RENDERER_URL=${{ vars.MERMAID_RENDERER_URL || 'https://mermaid.ink' }}
MERMAID_KROKI_RENDERER_URL=${{ vars.MERMAID_KROKI_RENDERER_URL || 'https://kroki.io' }}
MERMAID_IS_ENABLED_MERMAID_CHART_LINKS=false
@@ -1,60 +0,0 @@
name: Close broken link issues
on:
issues:
types: [opened, edited]
permissions:
issues: write
jobs:
close-broken-link-issues:
if: github.event.issue.state == 'open'
runs-on: ubuntu-latest
steps:
- name: Close issues with only "Broken link" in the title
uses: actions/github-script@v7
with:
script: |
const title = context.payload.issue.title.trim();
const isBrokenLinkOnlyTitle = /^broken link\.?$/i.test(title);
if (!isBrokenLinkOnlyTitle) {
return;
}
const { owner, repo } = context.repo;
const issueNumber = context.payload.issue.number;
const newIssueUrl = `https://github.com/${owner}/${repo}/issues/new?assignees=&labels=bug&template=bug_report.md&title=Broken%20link`;
const mermaidIssuesUrl = 'https://github.com/mermaid-js/mermaid/issues/new';
const commentBody = [
`@${context.payload.issue.user.login} This issue was automatically closed because the title only contains "Broken link" without additional details.`,
'',
'**Please only open issues here if something is broken in the live editor itself** (e.g. a link fails to load, the editor UI misbehaves, or a feature of mermaid.live does not work).',
'',
'**Do not open issues here for syntax errors or invalid Mermaid diagram code.** Those belong in the main Mermaid repository: [mermaid-js/mermaid](https://github.com/mermaid-js/mermaid/issues/new).',
'',
'If you are reporting a live editor bug, please include:',
'- The full URL that failed to load',
'- What you expected to happen',
'- Any error messages you saw',
'',
`You can [open a new issue in this repo](${newIssueUrl}) with this information, or report diagram syntax issues in the [Mermaid repo](${mermaidIssuesUrl}).`
].join('\n');
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: commentBody
});
await github.rest.issues.update({
owner,
repo,
issue_number: issueNumber,
state: 'closed',
state_reason: 'not_planned'
});
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
name: 'Playwright Tests' name: 'Playwright Tests'
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: mcr.microsoft.com/playwright:v1.60.0-jammy image: mcr.microsoft.com/playwright:v1.52.0-jammy
steps: steps:
- name: Checkout - name: Checkout
-3
View File
@@ -39,8 +39,5 @@ jobs:
- name: Lint - name: Lint
run: pnpm lint run: pnpm lint
- name: Type check
run: pnpm check
- name: Run unit tests - name: Run unit tests
run: pnpm test:unit run: pnpm test:unit
-1
View File
@@ -15,4 +15,3 @@
/playwright-report/ /playwright-report/
/blob-report/ /blob-report/
/playwright/.cache/ /playwright/.cache/
.playwright-mcp/
+1 -1
View File
@@ -1 +1 @@
24.16.0 22.15.0
-3
View File
@@ -12,14 +12,11 @@
"fsegurai", "fsegurai",
"gantt", "gantt",
"gitgraph", "gitgraph",
"hugeicons",
"KROKI", "KROKI",
"localstorage", "localstorage",
"mermaidchart", "mermaidchart",
"mindmap", "mindmap",
"NEWYEAR", "NEWYEAR",
"noopener",
"noreferrer",
"Pageview", "Pageview",
"pako", "pako",
"panmove", "panmove",
+1 -1
View File
@@ -1,4 +1,4 @@
FROM docker.io/library/node:24.16.0-alpine3.22 AS mermaid-live-editor-dependencies FROM docker.io/library/node:22.15.0-alpine3.21 AS mermaid-live-editor-dependencies
RUN apk --no-cache add build-base git python3 && \ RUN apk --no-cache add build-base git python3 && \
rm -rf /var/cache/apk/* rm -rf /var/cache/apk/*
+74 -77
View File
@@ -9,8 +9,6 @@
"dev:test": "pnpm dev", "dev:test": "pnpm dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "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": "prettier --check --cache . && eslint .",
"lint:fix": "prettier --write --cache . && eslint --fix .", "lint:fix": "prettier --write --cache . && eslint --fix .",
"format": "prettier --write --cache .", "format": "prettier --write --cache .",
@@ -25,96 +23,95 @@
"test:e2e:debug": "playwright test --debug" "test:e2e:debug": "playwright test --debug"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^2.1.0", "@eslint/compat": "^1.2.5",
"@eslint/eslintrc": "^3.3.5", "@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^10.0.1", "@eslint/js": "^9.39.2",
"@fortawesome/fontawesome-free": "^7.2.0", "@fortawesome/fontawesome-free": "^6.7.2",
"@iconify-json/hugeicons": "^1.2.29", "@iconify-json/material-symbols": "^1.2.20",
"@iconify-json/logos": "^1.2.11",
"@iconify-json/material-symbols": "^1.2.79",
"@iconify-json/mdi": "^1.2.3", "@iconify-json/mdi": "^1.2.3",
"@playwright/test": "^1.60.0", "@playwright/test": "^1.52.0",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.9",
"@sveltejs/kit": "^2.63.1", "@sveltejs/kit": "^2.37.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^6.1.3",
"@tailwindcss/typography": "^0.5.20", "@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.3.1", "@tailwindcss/vite": "^4.1.18",
"@types/hammerjs": "^2.0.46", "@types/hammerjs": "^2.0.46",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^24.13.2", "@types/node": "^22.15.10",
"@types/pako": "2.0.4", "@types/pako": "2.0.3",
"@vitest/coverage-v8": "^4.1.9", "@types/uuid": "9.0.8",
"@vitest/ui": "^4.1.9", "@vitest/coverage-v8": "^3.2.4",
"autoprefixer": "^10.5.0", "@vitest/ui": "^3.2.4",
"bits-ui": "^2.18.1", "autoprefixer": "^10.4.21",
"c8": "11.0.0", "bits-ui": "^2.9.6",
"chai": "^6.2.2", "c8": "7.14.0",
"chai": "^4.5.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cssnano": "^8.0.2", "cssnano": "^6.1.2",
"dotenv": "^17.4.2", "dotenv": "^17.3.1",
"eslint": "^10.4.1", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-es": "^4.1.0", "eslint-plugin-es": "^4.1.0",
"eslint-plugin-no-only-tests": "^3.4.0", "eslint-plugin-no-only-tests": "^3.3.0",
"eslint-plugin-sort-keys": "^2.3.5", "eslint-plugin-sort-keys": "^2.3.5",
"eslint-plugin-svelte": "^3.19.0", "eslint-plugin-svelte": "^3.0.0",
"eslint-plugin-tailwindcss": "^3.18.3", "eslint-plugin-tailwindcss": "^3.18.2",
"eslint-plugin-unicorn": "^65.0.1", "eslint-plugin-unicorn": "^60.0.0",
"globals": "^17.6.0", "esserializer": "^1.3.11",
"husky": "^9.1.7", "globals": "^16.0.0",
"jsdom": "^29.1.1", "husky": "^8.0.3",
"lint-staged": "^17.0.7", "jsdom": "^25.0.1",
"lucide-svelte": "^1.0.1", "lint-staged": "^15.5.1",
"node-html-parser": "^7.1.0", "lucide-svelte": "^0.507.0",
"paneforge": "1.0.2", "node-html-parser": "^6.1.13",
"prettier": "^3.8.4", "paneforge": "1.0.0-next.6",
"prettier-plugin-svelte": "^4.1.1", "prettier": "^3.8.1",
"prettier-plugin-tailwindcss": "^0.8.0", "prettier-plugin-svelte": "^3.4.1",
"svelte": "^5.56.3", "prettier-plugin-tailwindcss": "^0.7.2",
"svelte-check": "^4.6.0", "svelte": "^5.38.6",
"svelte-preprocess": "^6.0.5", "svelte-preprocess": "^6.0.3",
"svelte-sonner": "^1.1.1", "svelte-sonner": "^1.0.5",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.3.1",
"tailwind-variants": "^3.2.2", "tailwind-variants": "^3.1.0",
"tailwindcss": "^4.3.1", "tailwindcss": "^4.1.18",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.3.8",
"typescript": "^6.0.3", "typescript": "^5.9.2",
"typescript-eslint": "^8.60.1", "typescript-eslint": "^8.42.0",
"unplugin-icons": "^23.0.1", "unplugin-icons": "^22.2.0",
"vite": "^8.0.16", "vite": "^7.1.4",
"vite-plugin-devtools-json": "^1.0.0", "vite-plugin-devtools-json": "^1.0.0",
"vitest": "^4.1.9", "vitest": "^3.2.4",
"vitest-dom": "^0.1.1" "vitest-dom": "^0.1.1"
}, },
"dependencies": { "dependencies": {
"@codemirror/lang-json": "^6.0.2", "@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-markdown": "^6.5.0", "@codemirror/lang-markdown": "^6.3.2",
"@codemirror/lang-yaml": "^6.1.3", "@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.12.3", "@codemirror/language": "^6.11.0",
"@codemirror/state": "^6.6.0", "@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.43.1", "@codemirror/view": "^6.36.7",
"@fontsource-variable/recursive": "^5.2.8", "@fontsource-variable/recursive": "^5.2.5",
"@fsegurai/codemirror-theme-vscode-dark": "^6.2.6", "@fsegurai/codemirror-theme-vscode-dark": "^6.1.4",
"@fsegurai/codemirror-theme-vscode-light": "^6.2.6", "@fsegurai/codemirror-theme-vscode-light": "^6.1.4",
"@mermaid-js/examples": "^1.2.0", "@mermaid-js/examples": "^1.0.0",
"@mermaid-js/layout-elk": "^0.2.1", "@mermaid-js/layout-elk": "^0.1.9",
"@mermaid-js/layout-tidy-tree": "^0.2.2", "@mermaid-js/layout-tidy-tree": "^0.2.1",
"@mermaid-js/mermaid-zenuml": "^0.2.3", "@mermaid-js/mermaid-zenuml": "^0.2.2",
"codemirror": "^6.0.2", "codemirror": "^6.0.1",
"dayjs": "^1.11.21", "dayjs": "^1.11.13",
"hammerjs": "^2.0.8", "hammerjs": "^2.0.8",
"js-base64": "3.7.8", "js-base64": "3.7.7",
"lodash-es": "^4.18.1", "lodash-es": "^4.17.21",
"mermaid": "^11.15.0", "mermaid": "^11.13.0",
"mode-watcher": "^1.1.0", "mode-watcher": "^0.5.1",
"monaco-editor": "0.55.1", "monaco-editor": "0.52.2",
"pako": "2.1.0", "pako": "2.1.0",
"plausible-tracker": "^0.3.9", "plausible-tracker": "^0.3.9",
"random-word-slugs": "0.1.7", "random-word-slugs": "0.1.7",
"svg-pan-zoom": "3.6.2", "svg-pan-zoom": "3.6.2",
"svg2roughjs": "^3.2.3", "svg2roughjs": "^3.2.1",
"uuid": "14.0.0" "uuid": "9.0.1"
}, },
"lint-staged": { "lint-staged": {
"*.{ts,svelte,js,css,md,json}": [ "*.{ts,svelte,js,css,md,json}": [
@@ -123,9 +120,9 @@
] ]
}, },
"engines": { "engines": {
"node": ">=24.16.0" "node": ">=20.19.0"
}, },
"packageManager": "pnpm@10.34.4+sha512.8768be55200ae3f2226b6527fcca2687e14bc4e5f12d7721a0f25da3df47915177058648db4177baf348120fa0ba2752d8d8d93f6beaf1fe64ae18da8de961af", "packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39",
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"deasync", "deasync",
+3675 -2917
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -8,8 +8,7 @@
<meta <meta
name="description" name="description"
content="Simplify documentation and avoid heavy tools. Open source Visio Alternative. Commonly used for explaining your code! Mermaid is a simple markdown-like script language for generating charts from text via javascript." /> content="Simplify documentation and avoid heavy tools. Open source Visio Alternative. Commonly used for explaining your code! Mermaid is a simple markdown-like script language for generating charts from text via javascript." />
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" /> <link rel="icon" type="image/png" href="%sveltekit.assets%/favicon.svg" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/favicon.png" />
<link rel="mask-icon" href="%sveltekit.assets%/favicon.svg" color="#000000" /> <link rel="mask-icon" href="%sveltekit.assets%/favicon.svg" color="#000000" />
<meta name="theme-color" content="#ff3670" /> <meta name="theme-color" content="#ff3670" />
<link rel="manifest" href="%sveltekit.assets%/manifest.json" /> <link rel="manifest" href="%sveltekit.assets%/manifest.json" />
+15 -19
View File
@@ -11,7 +11,7 @@
import { getDomain } from '$/util/util'; import { getDomain } from '$/util/util';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { waitForRender } from '$lib/util/autoSync'; import { waitForRender } from '$lib/util/autoSync';
import { inputState, updateCodeStore, urls, validatedState } from '$lib/util/state.svelte'; import { inputStateStore, stateStore, urlsStore } from '$lib/util/state';
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 (validatedState.current.rough) { if ($stateStore.rough) {
fixForeignObjectClipping(svg); fixForeignObjectClipping(svg);
} }
@@ -106,7 +106,7 @@ ${svgString}`);
}; };
const exportImage = async (event: Event, exporter: Exporter) => { const exportImage = async (event: Event, exporter: Exporter) => {
updateCodeStore({ panZoom: false }); $inputStateStore.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)();
updateCodeStore({ panZoom: true }); $inputStateStore.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 (!inputState.panZoom) { if (!$inputStateStore.panZoom) {
updateCodeStore({ panZoom: true }); $inputStateStore.panZoom = true;
} }
}, 2000); }, 2000);
event.stopPropagation(); event.stopPropagation();
@@ -199,10 +199,7 @@ ${svgString}`);
}; };
}; };
const onCopyClipboard = async (event?: Event) => { const onCopyClipboard = async (event: Event) => {
if (!event) {
return;
}
await exportImage(event, clipboardCopy); await exportImage(event, clipboardCopy);
logEvent('copyClipboard'); logEvent('copyClipboard');
}; };
@@ -222,8 +219,7 @@ ${svgString}`);
}; };
let gistURL = $state(''); let gistURL = $state('');
$effect(() => { stateStore.subscribe(({ loader }) => {
const { loader } = validatedState.current;
if (loader?.type === 'gist') { if (loader?.type === 'gist') {
gistURL = loader.config.url; gistURL = loader.config.url;
} }
@@ -288,10 +284,10 @@ ${svgString}`);
bind:value={imageSize} /> bind:value={imageSize} />
</div> </div>
<div class="flex gap-2"> <div class="flex gap-2">
{@render dualActionButton('PNG', onDownloadPNG, urls.current.png)} {@render dualActionButton('PNG', onDownloadPNG, $urlsStore.png)}
{@render dualActionButton('SVG', onDownloadSVG, urls.current.svg)} {@render dualActionButton('SVG', onDownloadSVG, $urlsStore.svg)}
<ExternalLinkWrapper domain={getDomain(urls.current.kroki)} isVisible={!!urls.current.kroki}> <ExternalLinkWrapper domain={getDomain($urlsStore.kroki)} isVisible={!!$urlsStore.kroki}>
<a target="_blank" rel="noreferrer" class="flex-grow" href={urls.current.kroki}> <a target="_blank" rel="noreferrer" class="flex-grow" href={$urlsStore.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>
@@ -304,9 +300,9 @@ ${svgString}`);
{/if} {/if}
<ExternalLinkWrapper <ExternalLinkWrapper
labelPrefix="Thumbnail generated by" labelPrefix="Thumbnail generated by"
domain={getDomain(urls.current.png)} domain={getDomain($urlsStore.png)}
isVisible={!!urls.current.mdCode}> isVisible={!!$urlsStore.mdCode}>
<CopyInput value={urls.current.mdCode} label="Copy Markdown" testID={TID.copyMarkdown} /> <CopyInput value={$urlsStore.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" />
+4 -3
View File
@@ -14,8 +14,9 @@
onselect?: (tab: Tab) => void; onselect?: (tab: Tab) => void;
} = $props(); } = $props();
// Derive (don't mutate the prop) so the highlight tracks a bound activeTabID. if (!activeTabID && tabs.length > 0) {
const effectiveTabID = $derived(activeTabID || tabs[0]?.id); activeTabID = tabs[0].id;
}
const toggleTabs = (tab: Tab) => { const toggleTabs = (tab: Tab) => {
return (event: Event) => { return (event: Event) => {
@@ -33,7 +34,7 @@
variant="ghost" variant="ghost"
class={[ class={[
'px-2', 'px-2',
effectiveTabID === tab.id && 'rounded-b-none border-b-2 border-b-primary-foreground/50' activeTabID === tab.id && 'rounded-b-none border-b-2 border-b-primary-foreground/50'
]} ]}
onclick={toggleTabs(tab)} onclick={toggleTabs(tab)}
onkeypress={toggleTabs(tab)}> onkeypress={toggleTabs(tab)}>
+42 -64
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { EditorProps } from '$/types'; import type { EditorProps } from '$/types';
import { env } from '$/util/env'; import { env } from '$/util/env';
import { urls, validatedState } from '$/util/state.svelte'; import { stateStore, urlsStore } from '$/util/state';
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';
@@ -27,7 +27,6 @@
lineNumbersMinChars: 4 lineNumbersMinChars: 4
} satisfies monaco.editor.IStandaloneEditorConstructionOptions; } satisfies monaco.editor.IStandaloneEditorConstructionOptions;
let currentText = ''; let currentText = '';
let isUpdatingFromState = false;
let showPopup = $state(false); let showPopup = $state(false);
let popupPosition = $state({ top: 0, lineNumber: 0 }); let popupPosition = $state({ top: 0, lineNumber: 0 });
let decorationsCollection: monaco.editor.IEditorDecorationsCollection | undefined; let decorationsCollection: monaco.editor.IEditorDecorationsCollection | undefined;
@@ -35,16 +34,6 @@
let lastMouseLine = 0; let lastMouseLine = 0;
const aiPromptManager = new AIPromptViewZoneManager(); const aiPromptManager = new AIPromptViewZoneManager();
const applyEditorTheme = (currentMode: typeof mode.current) => {
if (!editor) return;
monaco.editor.setTheme(`mermaid${currentMode === 'dark' ? '-dark' : ''}`);
divElement?.classList.toggle('mermaid-dark', currentMode === 'dark');
};
$effect(() => {
applyEditorTheme(mode.current);
});
const jsonModel = monaco.editor.createModel( const jsonModel = monaco.editor.createModel(
'', '',
'json', 'json',
@@ -115,7 +104,7 @@
throw new Error('divEl is undefined'); throw new Error('divEl is undefined');
} }
monaco.json.jsonDefaults.setDiagnosticsOptions({ monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true, validate: true,
enableSchemaRequest: true, enableSchemaRequest: true,
schemas: [ schemas: [
@@ -143,13 +132,43 @@
editor.onDidChangeModelContent(({ isFlush }) => { editor.onDidChangeModelContent(({ isFlush }) => {
const newText = editor?.getValue(); const newText = editor?.getValue();
if (!newText || currentText === newText || isFlush || isUpdatingFromState) { if (!newText || currentText === newText || isFlush) {
return; return;
} }
currentText = newText; currentText = newText;
onUpdate(currentText); onUpdate(currentText);
}); });
const unsubscribeState = stateStore.subscribe(({ errorMarkers, editorMode, code, mermaid }) => {
if (!editor) {
return;
}
const model = editorMode === 'code' ? mermaidModel : jsonModel;
if (editor.getModel()?.id !== model.id) {
editor.setModel(model);
renderAIPromptGutterGlyphIcon();
}
// Clear decorations if not in 'code' mode, or if the model changes
if (editorMode !== 'code' || editor.getModel()?.id !== mermaidModel.id) {
decorationsCollection?.clear();
}
// Update editor text if it's different
const newText = editorMode === 'code' ? code : mermaid;
if (newText !== currentText) {
editor.setScrollTop(0);
editor.setValue(newText);
currentText = newText;
renderAIPromptGutterGlyphIcon();
}
// Display/clear errors
monaco.editor.setModelMarkers(model, 'mermaid', errorMarkers);
});
editor.onMouseMove((e) => { editor.onMouseMove((e) => {
if (!editor) return; if (!editor) return;
if (showPopup) return; if (showPopup) return;
@@ -164,8 +183,12 @@
renderAIPromptGutterGlyphIcon(); renderAIPromptGutterGlyphIcon();
}); });
applyEditorTheme(mode.current); const unsubscribeMode = mode.subscribe((mode) => {
if (editor) {
monaco.editor.setTheme(`mermaid${mode === 'dark' ? '-dark' : ''}`);
divElement?.classList.toggle('mermaid-dark', mode === 'dark');
}
});
const resizeObserver = new ResizeObserver((entries) => { const resizeObserver = new ResizeObserver((entries) => {
editor?.layout({ editor?.layout({
height: entries[0].contentRect.height, height: entries[0].contentRect.height,
@@ -180,6 +203,8 @@
renderAIPromptGutterGlyphIcon(); renderAIPromptGutterGlyphIcon();
return () => { return () => {
unsubscribeState();
unsubscribeMode();
resizeObserver.disconnect(); resizeObserver.disconnect();
jsonModel.dispose(); jsonModel.dispose();
mermaidModel.dispose(); mermaidModel.dispose();
@@ -187,49 +212,6 @@
editor?.dispose(); editor?.dispose();
}; };
}); });
$effect(() => {
const { errorMarkers, editorMode, code, mermaid } = validatedState.current;
if (!editor) {
return;
}
const model = editorMode === 'code' ? mermaidModel : jsonModel;
if (editor.getModel()?.id !== model.id) {
editor.setModel(model);
renderAIPromptGutterGlyphIcon();
}
// Clear decorations if not in 'code' mode, or if the model changes
if (editorMode !== 'code' || editor.getModel()?.id !== mermaidModel.id) {
decorationsCollection?.clear();
}
// Update editor text if it's different
const newText = editorMode === 'code' ? code : mermaid;
if (newText !== currentText) {
isUpdatingFromState = true;
try {
editor.setScrollTop(0);
editor.pushUndoStop();
editor.executeEdits('updateCode', [
{
range: model.getFullModelRange(),
text: newText
}
]);
editor.pushUndoStop();
currentText = newText;
} finally {
isUpdatingFromState = false;
}
renderAIPromptGutterGlyphIcon();
}
// Display/clear errors
monaco.editor.setModelMarkers(model, 'mermaid', errorMarkers);
});
</script> </script>
<div class="relative h-full grow overflow-hidden"> <div class="relative h-full grow overflow-hidden">
@@ -242,11 +224,7 @@
onClose={closePopup} onClose={closePopup}
onTryFree={() => { onTryFree={() => {
logMermaidChartClick('vibeDiagramming'); logMermaidChartClick('vibeDiagramming');
window.open( window.open($urlsStore.mermaidChart({ medium: 'vibe_diagramming' }).save, '_blank');
urls.current.mermaidChart({ medium: 'vibe_diagramming' }).save,
'_blank',
'noopener'
);
closePopup(); closePopup();
}} /> }} />
</div> </div>
@@ -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 { validatedState } from '$/util/state.svelte'; import { stateStore } from '$/util/state';
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,14 +92,12 @@
} as const satisfies DocumentationConfig; } as const satisfies DocumentationConfig;
const doc = $derived.by(() => { const doc = $derived.by(() => {
const { editorMode, diagramType } = validatedState.current; const { editorMode, diagramType } = $stateStore;
if (!diagramType) { if (!diagramType) {
return { key: '', url: docURLBase }; return { key: '', url: docURLBase };
} }
const key = standardizeDiagramType(diagramType); const key = standardizeDiagramType(diagramType);
const docConfig: { code: string; config?: string } = docMap[key as keyof typeof docMap] ?? { const docConfig = docMap[key] ?? { code: '' };
code: ''
};
const url = docURLBase + (docConfig[editorMode] ?? docConfig.code ?? ''); const url = docURLBase + (docConfig[editorMode] ?? docConfig.code ?? '');
return { key, url }; return { key, url };
}); });
+8 -8
View File
@@ -6,14 +6,14 @@
import { Button } from '$/components/ui/button'; import { Button } from '$/components/ui/button';
import { TID } from '$/constants'; import { TID } from '$/constants';
import { env } from '$/util/env'; import { env } from '$/util/env';
import { updateCode, updateConfig, urls, validatedState } from '$lib/util/state.svelte'; import { stateStore, updateCode, updateConfig, urlsStore } from '$lib/util/state';
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 (validatedState.current.editorMode === 'code') { if ($stateStore.editorMode === 'code') {
updateCode(text); updateCode(text);
} else { } else {
updateConfig(text); updateConfig(text);
@@ -27,7 +27,7 @@
}, 3000); }, 3000);
$effect(() => { $effect(() => {
if (validatedState.current.error) { if ($stateStore.error) {
showErrorDebounced(); showErrorDebounced();
} else { } else {
showErrorDebounced.cancel(); showErrorDebounced.cancel();
@@ -46,27 +46,27 @@
{:else} {:else}
<DesktopEditor {onUpdate} /> <DesktopEditor {onUpdate} />
{/if} {/if}
{#if showError && validatedState.current.error instanceof Error} {#if showError && $stateStore.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 && validatedState.current.editorMode === 'code'} {#if env.isEnabledMermaidChartLinks && $stateStore.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 validatedState.current.editorMode === 'code'} {#if $stateStore.editorMode === 'code'}
<McWrapper> <McWrapper>
<Button <Button
variant="accent" variant="accent"
size="sm" size="sm"
data-testid={TID.aiRepairButton} data-testid={TID.aiRepairButton}
href={urls.current.mermaidChart({ medium: 'ai_repair' }).save} href={$urlsStore.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>{validatedState.current.error?.toString()}</pre> <pre>{$stateStore.error?.toString()}</pre>
</output> </output>
</div> </div>
{/if} {/if}
+22 -23
View File
@@ -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 { validatedState, urls } from '$/util/state.svelte'; import { stateStore, urlsStore } from '$/util/state';
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 = validatedState.current.diagramType; const dt = $stateStore.diagramType;
return dt ? standardizeDiagramType(dt) : undefined; return dt ? standardizeDiagramType(dt) : undefined;
}); });
@@ -34,24 +34,23 @@
source: string; source: string;
} }
const cycleIntervalMs = 30_000; let currentActionMedium = $state<EnhancedEditAction['medium'] | undefined>(undefined);
let previousDiagramType = $state<string | undefined>(undefined);
let currentActionIndex = $state(0);
const availableActions = $derived.by<EnhancedEditAction[]>(() => { const availableActions = $derived.by<EnhancedEditAction[]>(() => {
if (!validatedState.current.diagramType) { if (!$stateStore.diagramType) {
return []; return [];
} }
const actions: EnhancedEditAction[] = [ const actions: EnhancedEditAction[] = [
{ campaign: 'voice_1', label: 'with voice', medium: 'voice_edit', source: 'voiceEdit' }, { campaign: 'ai_1', label: 'with AI', medium: 'ai_edit', source: 'aiEdit' },
{ campaign: 'ai_1', label: 'with AI', medium: 'ai_edit', source: 'aiEdit' } { campaign: 'voice_1', label: 'with Voice', medium: 'voice_edit', source: 'voiceEdit' }
]; ];
if (showVisualEdit) { if (showVisualEdit) {
actions.unshift({ actions.unshift({
campaign: 'visual_1', campaign: 'visual_1',
label: 'visually', label: 'Visually',
medium: 'visual_edit', medium: 'visual_edit',
source: 'visualEdit' source: 'visualEdit'
}); });
@@ -66,30 +65,30 @@
return undefined; return undefined;
} }
return actions[currentActionIndex % actions.length]; return actions.find(({ medium }) => medium === currentActionMedium) ?? actions[0];
}); });
$effect(() => { $effect(() => {
const actionCount = availableActions.length; const actions = availableActions;
if (actionCount === 0) { if (!diagramType || actions.length === 0) {
currentActionIndex = 0;
return; return;
} }
if (currentActionIndex >= actionCount) { const hasCurrentAction = actions.some(({ medium }) => medium === currentActionMedium);
currentActionIndex = 0;
}
if (actionCount <= 1) { if (diagramType !== previousDiagramType || !hasCurrentAction) {
currentActionMedium = actions[Math.floor(Math.random() * actions.length)].medium;
previousDiagramType = diagramType;
}
});
$effect(() => {
if (diagramType) {
return; return;
} }
const intervalID = setInterval(() => { previousDiagramType = undefined;
currentActionIndex = (currentActionIndex + 1) % actionCount;
}, cycleIntervalMs);
return () => clearInterval(intervalID);
}); });
</script> </script>
@@ -98,7 +97,7 @@
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
href={urls.current.mermaidChart({ href={$urlsStore.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 { validatedState } from '$/util/state.svelte'; import { stateStore } from '$/util/state';
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 && validatedState.current.diagramType === 'zenuml' shouldCheckDiagramType && $stateStore.diagramType === 'zenuml'
); );
</script> </script>
+79 -76
View File
@@ -2,11 +2,12 @@
import Card from '$lib/components/Card/Card.svelte'; import Card from '$lib/components/Card/Card.svelte';
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 { getStateString, 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';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import BookmarkIcon from '~icons/material-symbols/bookmark-outline-rounded'; import BookmarkIcon from '~icons/material-symbols/bookmark-outline-rounded';
import TrashAltIcon from '~icons/material-symbols/delete-outline-rounded'; import TrashAltIcon from '~icons/material-symbols/delete-outline-rounded';
import DownloadIcon from '~icons/material-symbols/download-rounded'; import DownloadIcon from '~icons/material-symbols/download-rounded';
@@ -15,51 +16,41 @@
import UploadIcon from '~icons/material-symbols/upload-rounded'; import UploadIcon from '~icons/material-symbols/upload-rounded';
import HistoryIcon from '~icons/mdi/clock-outline'; import HistoryIcon from '~icons/mdi/clock-outline';
import GitAltIcon from '~icons/mdi/git'; import GitAltIcon from '~icons/mdi/git';
import OpenInNewIcon from '~icons/material-symbols/open-in-new-rounded';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
import { Separator } from '../ui/separator'; import { Separator } from '../ui/separator';
import { import {
addManualEntry, addHistoryEntry,
clearActive, clearHistoryData,
historyState, getPreviousState,
removeEntry, historyModeStore,
restoreEntries, historyStore,
setMode loaderHistoryStore,
} from './historyState.svelte'; restoreHistory
} from './history';
dayjs.extend(dayjsRelativeTime); dayjs.extend(dayjsRelativeTime);
const baseTabs: Tab[] = [ const HISTORY_SAVE_INTERVAL = 60_000;
{ 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) => { const tabSelectHandler = (tab: Tab) => {
setMode(tab.id as HistoryType); historyModeStore.set(tab.id as HistoryType);
}; };
let tabs: Tab[] = $state([
{
id: 'manual',
title: 'Saved',
icon: BookmarkIcon
},
{
id: 'auto',
title: 'Timeline',
icon: HistoryIcon
}
]);
const downloadHistory = () => { const downloadHistory = () => {
const data = historyState.entries; const data = get(historyStore);
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' }); const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
@@ -67,7 +58,9 @@
a.download = `mermaid-history-${dayjs().format('YYYY-MM-DD-HHmmss')}.json`; a.download = `mermaid-history-${dayjs().format('YYYY-MM-DD-HHmmss')}.json`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
logEvent('history', { action: 'download' }); logEvent('history', {
action: 'download'
});
}; };
const uploadHistory = () => { const uploadHistory = () => {
@@ -80,39 +73,59 @@
return; return;
} }
const data: HistoryEntry[] = JSON.parse(await file.text()); const data: HistoryEntry[] = JSON.parse(await file.text());
const { restored, invalid, duplicates } = restoreEntries(data); restoreHistory(data);
notify(`${restored} restored, ${duplicates} duplicate, ${invalid} invalid.`);
}); });
input.click(); input.click();
}; };
const saveHistory = () => { const saveHistory = (auto = false) => {
if (!addManualEntry($state.snapshot(inputState))) { 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) {
notify('State already saved.'); notify('State already saved.');
} }
}; };
const clearAll = () => { const clearHistory = (id?: string): void => {
if (prompt('Clear all saved items?')) { if (!id && !prompt('Clear all saved items?')) {
clearActive(); return;
} }
clearHistoryData(id);
}; };
const restoreHistoryItem = (state: State): void => { const restoreHistoryItem = (state: State): void => {
replaceInputState({ ...state, updateDiagram: true }); inputStateStore.set({ ...state, updateDiagram: true });
}; };
// Absolute editor URL for an entry, so the link can be opened in a new tab or copied. onMount(() => {
const entryUrl = (state: State): string => historyModeStore.set('manual');
`${window.location.origin}${window.location.pathname}#${serializeState(state)}`; setInterval(() => {
saveHistory(true);
}, HISTORY_SAVE_INTERVAL);
});
// Serialize each entry's URL once per change rather than per row on every render. loaderHistoryStore.subscribe((entries) => {
const entriesWithUrl = $derived( if (entries.length > 0 && tabs.length === 2) {
historyState.entries.map((entry) => ({ ...entry, openUrl: entryUrl(entry.state) })) tabs = [
); {
id: 'loader',
title: 'Revisions',
icon: GitAltIcon
},
...tabs
];
historyModeStore.set('loader');
}
});
</script> </script>
<Card onselect={tabSelectHandler} isOpen isClosable={false} {tabs} activeTabID={historyState.mode}> <Card onselect={tabSelectHandler} isOpen isClosable={false} {tabs}>
{#snippet actions()} {#snippet actions()}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<Button <Button
@@ -121,7 +134,7 @@
id="uploadHistory" id="uploadHistory"
onclick={uploadHistory} onclick={uploadHistory}
title="Upload history"><UploadIcon /></Button> title="Upload history"><UploadIcon /></Button>
{#if historyState.entries.length > 0} {#if $historyStore.length > 0}
<Button <Button
id="downloadHistory" id="downloadHistory"
size="icon" size="icon"
@@ -134,22 +147,22 @@
id="saveHistory" id="saveHistory"
size="icon" size="icon"
variant="ghost" variant="ghost"
onclick={saveHistory} onclick={() => saveHistory()}
title="Save current state"><SaveIcon /></Button> title="Save current state"><SaveIcon /></Button>
{#if historyState.mode !== 'loader'} {#if $historyModeStore !== 'loader'}
<Button <Button
id="clearHistory" id="clearHistory"
size="icon" size="icon"
variant="ghost" variant="ghost"
class="hover:text-destructive" class="hover:text-destructive"
onclick={clearAll} onclick={() => clearHistory()}
title="Delete all saved states"><TrashAltIcon /></Button> title="Delete all saved states"><TrashAltIcon /></Button>
{/if} {/if}
</div> </div>
{/snippet} {/snippet}
<ul class="flex h-full min-w-fit flex-col gap-2 overflow-auto p-2" id="historyList"> <ul class="flex h-full min-w-fit flex-col gap-2 overflow-auto p-2" id="historyList">
{#if entriesWithUrl.length > 0} {#if $historyStore.length > 0}
{#each entriesWithUrl as { id, state, time, name, url, type, openUrl } (id)} {#each $historyStore as { id, state, time, name, url, type } (id)}
<li class="flex flex-col gap-2"> <li class="flex flex-col gap-2">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div class="flex flex-col"> <div class="flex flex-col">
@@ -171,20 +184,7 @@
<span class="text-sm whitespace-nowrap text-primary-foreground/50"> <span class="text-sm whitespace-nowrap text-primary-foreground/50">
{dayjs(time).fromNow()} {dayjs(time).fromNow()}
</span> </span>
<Button <Button size="icon" variant="ghost" onclick={() => restoreHistoryItem(state)}>
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 /> <UndoIcon />
</Button> </Button>
{#if type !== 'loader'} {#if type !== 'loader'}
@@ -192,8 +192,7 @@
size="icon" size="icon"
variant="ghost" variant="ghost"
class="hover:text-destructive" class="hover:text-destructive"
title="Delete this version" onclick={() => clearHistory(id)}>
onclick={() => removeEntry(id)}>
<TrashAltIcon /> <TrashAltIcon />
</Button> </Button>
{/if} {/if}
@@ -203,7 +202,11 @@
</li> </li>
{/each} {/each}
{:else} {:else}
<div class="m-2 text-center whitespace-pre-line">{emptyMessage}</div> <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>
{/if} {/if}
</ul> </ul>
</Card> </Card>
+126
View File
@@ -0,0 +1,126 @@
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
@@ -0,0 +1,151 @@
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;
};
@@ -1,177 +0,0 @@
import type { HistoryEntry, HistoryType, Optional, State } from '$lib/types';
import { persisted, readJSON, type Persisted } from '$lib/util/persist.svelte';
import { inputState } from '$lib/util/state.svelte';
import { logEvent } from '$lib/util/stats';
import { generateSlug } from 'random-word-slugs';
import { v4 as uuidV4 } from 'uuid';
const MAX_AUTO_HISTORY_LENGTH = 30;
const AUTO_SAVE_INTERVAL = 60_000;
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($state.snapshot(inputState)),
AUTO_SAVE_INTERVAL
);
}
return stopAutoSave;
};
export const stopAutoSave = (): void => {
if (autoSaveTimer !== undefined) {
clearInterval(autoSaveTimer);
autoSaveTimer = undefined;
}
};
@@ -1,314 +0,0 @@
import type { HistoryEntry } from '$lib/types';
import { defaultState, replaceInputState } from '$lib/util/state.svelte';
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();
replaceInputState(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();
replaceInputState(codeState('graph TD\n once'));
startAutoSave();
startAutoSave();
vi.advanceTimersByTime(60_000);
expect(entriesFor('auto')).toHaveLength(1);
});
it('stops recording after stopAutoSave', () => {
vi.useFakeTimers();
replaceInputState(codeState('graph TD\n stoppable'));
startAutoSave();
vi.advanceTimersByTime(60_000);
stopAutoSave();
replaceInputState(codeState('graph TD\n after-stop'));
vi.advanceTimersByTime(60_000);
expect(entriesFor('auto')).toHaveLength(1);
});
});
+10 -10
View File
@@ -3,7 +3,7 @@
import * as Popover from '$/components/ui/popover'; import * as Popover from '$/components/ui/popover';
import { Switch } from '$/components/ui/switch'; import { Switch } from '$/components/ui/switch';
import { env } from '$/util/env'; import { env } from '$/util/env';
import { urls } from '$/util/state.svelte'; import { urlsStore } from '$/util/state';
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';
@@ -28,14 +28,14 @@
sharesData?: boolean; sharesData?: boolean;
checkDiagramType?: boolean; checkDiagramType?: boolean;
isSectionEnd?: boolean; isSectionEnd?: boolean;
renderer: Snippet<[Omit<MenuItem, 'renderer'>]>; renderer: (item: Omit<MenuItem, 'renderer'>) => ReturnType<Snippet>;
} }
const menuItems: MenuItem[] = $derived([ const menuItems: MenuItem[] = $derived([
{ label: 'New', icon: AddIcon, href: urls.current.new, renderer: menuItem }, { label: 'New', icon: AddIcon, href: $urlsStore.new, renderer: menuItem },
{ label: 'Duplicate', icon: DuplicateIcon, href: window.location.href, renderer: menuItem }, { label: 'Duplicate', icon: DuplicateIcon, href: window.location.href, renderer: menuItem },
{ {
href: urls.current.mermaidChart({ medium: 'main_menu' }).playground, href: $urlsStore.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: urls.current.mermaidChart({ medium: 'main_menu' }).plugins, href: $urlsStore.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: urls.current.mermaidChart({ medium: 'main_menu' }).home, href: $urlsStore.mermaidChart({ medium: 'main_menu' }).home,
icon: MermaidChartIcon, icon: MermaidChartIcon,
label: 'Mermaid', label: 'Mermaid',
onclick: () => logMermaidChartClick('mermaidHome'), onclick: () => logMermaidChartClick('mermaidHome'),
@@ -89,7 +89,7 @@
]); ]);
</script> </script>
{#snippet menuItem(options: Omit<MenuItem, 'renderer'>)} {#snippet menuItem(options: MenuItem)}
<a <a
href={options.href} href={options.href}
target="_blank" target="_blank"
@@ -104,7 +104,7 @@
</a> </a>
{/snippet} {/snippet}
{#snippet mcMenuItem(item: Omit<MenuItem, 'renderer'>)} {#snippet mcMenuItem(item: MenuItem)}
<McWrapper <McWrapper
side="right" side="right"
labelPrefix={item.sharesData === false ? 'Opens a new tab in' : undefined} labelPrefix={item.sharesData === false ? 'Opens a new tab in' : undefined}
@@ -114,7 +114,7 @@
</McWrapper> </McWrapper>
{/snippet} {/snippet}
{#snippet darkModeMenuItem(options: Omit<MenuItem, 'renderer'>)} {#snippet darkModeMenuItem(options: MenuItem)}
<div <div
class={cn( class={cn(
'flex cursor-pointer items-center justify-between border-b-2 px-3 py-2 hover:bg-muted', 'flex cursor-pointer items-center justify-between border-b-2 px-3 py-2 hover:bg-muted',
@@ -126,7 +126,7 @@
Dark Mode Dark Mode
</span> </span>
<Switch <Switch
checked={mode.current === 'dark'} checked={$mode === 'dark'}
onCheckedChange={(dark) => setMode(dark ? 'dark' : 'light')} /> onCheckedChange={(dark) => setMode(dark ? 'dark' : 'light')} />
</div> </div>
{/snippet} {/snippet}
+39 -40
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { EditorProps } from '$/types'; import type { EditorProps } from '$/types';
import { validatedState } from '$/util/state.svelte'; import { stateStore } from '$/util/state';
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';
@@ -15,22 +15,14 @@
let editorView: EditorView | undefined; let editorView: EditorView | undefined;
let editorContainer: HTMLDivElement; let editorContainer: HTMLDivElement;
// Deliberately not $state: the sync effect below both reads and writes it, let currentText = $state('');
// so a reactive currentText would make every keystroke re-run the effect
// against the not-yet-revalidated state and revert the user's input.
let currentText = '';
const themeCompartment = new Compartment();
const languageCompartment = new Compartment();
const { onUpdate }: EditorProps = $props(); const { onUpdate }: EditorProps = $props();
$effect(() => {
editorView?.dispatch({
effects: themeCompartment.reconfigure(mode.current === 'dark' ? vsCodeDark : vsCodeLight)
});
});
onMount(() => { onMount(() => {
const themeCompartment = new Compartment();
const languageCompartment = new Compartment();
editorView = new EditorView({ editorView = new EditorView({
state: EditorState.create({ state: EditorState.create({
doc: currentText, doc: currentText,
@@ -64,37 +56,44 @@
parent: editorContainer parent: editorContainer
}); });
const unsubscribeMode = mode.subscribe((mode) => {
editorView?.dispatch({
effects: themeCompartment.reconfigure(mode === 'dark' ? vsCodeDark : vsCodeLight)
});
});
const unsubscribeState = stateStore.subscribe(({ editorMode, code, mermaid }) => {
const text = editorMode === 'code' ? code : mermaid;
if (currentText === text || !editorView) {
return;
}
currentText = text;
editorView.dispatch({
changes: {
from: 0,
to: editorView.state.doc.length,
insert: text
}
});
const stateLanguage = editorView.state.facet(language);
const isStateJson = stateLanguage === jsonLanguage;
const isCodeJson = editorMode === 'config';
if (stateLanguage && isStateJson === isCodeJson) {
return;
}
editorView.dispatch({
effects: languageCompartment.reconfigure(
isCodeJson ? json() : yamlFrontmatter({ content: markdown() })
)
});
});
return () => { return () => {
unsubscribeMode();
unsubscribeState();
editorView?.destroy(); editorView?.destroy();
}; };
}); });
$effect(() => {
const { editorMode, code, mermaid } = validatedState.current;
const text = editorMode === 'code' ? code : mermaid;
if (currentText === text || !editorView) {
return;
}
currentText = text;
editorView.dispatch({
changes: {
from: 0,
to: editorView.state.doc.length,
insert: text
}
});
const stateLanguage = editorView.state.facet(language);
const isStateJson = stateLanguage === jsonLanguage;
const isCodeJson = editorMode === 'config';
if (stateLanguage && isStateJson === isCodeJson) {
return;
}
editorView.dispatch({
effects: languageCompartment.reconfigure(
isCodeJson ? json() : yamlFrontmatter({ content: markdown() })
)
});
});
</script> </script>
<div bind:this={editorContainer} class="size-full"></div> <div bind:this={editorContainer} class="size-full"></div>
+4 -5
View File
@@ -8,12 +8,11 @@
</script> </script>
<script lang="ts"> <script lang="ts">
import { resolve } from '$app/paths';
import MainMenu from '$/components/MainMenu.svelte'; import MainMenu from '$/components/MainMenu.svelte';
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 { dismissPromotion, getActivePromotion } from '$lib/util/promos/promo.svelte'; import { dismissPromotion, getActivePromotion } from '$lib/util/promos/promo';
import { untrack, type ComponentProps, type Snippet } from 'svelte'; import type { ComponentProps, Snippet } from 'svelte';
import MermaidIcon from '~icons/custom/mermaid'; import MermaidIcon from '~icons/custom/mermaid';
import CloseIcon from '~icons/material-symbols/close-rounded'; import CloseIcon from '~icons/material-symbols/close-rounded';
import GithubIcon from '~icons/mdi/github'; import GithubIcon from '~icons/mdi/github';
@@ -41,7 +40,7 @@
} }
]; ];
let activePromotion = $state(untrack(() => (hidePromotion ? undefined : getActivePromotion()))); let activePromotion = $state(hidePromotion ? undefined : getActivePromotion());
const trackBannerClick = () => { const trackBannerClick = () => {
if (!activePromotion) { if (!activePromotion) {
@@ -84,7 +83,7 @@
<div class="flex flex-1 items-center gap-2"> <div class="flex flex-1 items-center gap-2">
<MainMenu /> <MainMenu />
<MermaidIcon class="size-6" /> <MermaidIcon class="size-6" />
<a href={resolve('/', {})} class="whitespace-nowrap text-accent"> <a href="/" class="whitespace-nowrap text-accent">
{#if !mobileToggle} {#if !mobileToggle}
Mermaid Mermaid
{/if} {/if}
+2 -2
View File
@@ -3,7 +3,7 @@
import { Button } from '$/components/ui/button'; import { Button } from '$/components/ui/button';
import { Separator } from '$/components/ui/separator'; import { Separator } from '$/components/ui/separator';
import type { PanZoomState } from '$/util/panZoom'; import type { PanZoomState } from '$/util/panZoom';
import { urls } from '$/util/state.svelte'; import { urlsStore } from '$/util/state';
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={urls.current.view} target="_blank"> <Button variant="ghost" size="icon" title="Full Screen" href={$urlsStore.view} target="_blank">
<ExpandIcon /> <ExpandIcon />
</Button> </Button>
</FloatingToolbar> </FloatingToolbar>
+17 -54
View File
@@ -1,20 +1,13 @@
<script lang="ts"> <script lang="ts">
import Card from '$/components/Card/Card.svelte'; import Card from '$/components/Card/Card.svelte';
import { Button, buttonVariants } from '$/components/ui/button'; import { Button } from '$/components/ui/button';
import * as Popover from '$/components/ui/popover'; import { getSampleDiagrams } from '$/util/mermaid';
import { getSampleDiagrams, type SampleExample } 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 { cn } from '$lib/utils';
import ShapesIcon from '~icons/material-symbols/account-tree-outline-rounded'; import ShapesIcon from '~icons/material-symbols/account-tree-outline-rounded';
import ChevronDownIcon from '~icons/material-symbols/keyboard-arrow-down-rounded';
const extras: Record<string, SampleExample[]> = { const extras = {
ZenUML: [ ZenUML: `zenuml
{
title: 'Order Service',
isDefault: true,
code: `zenuml
title Order Service title Order Service
@Actor Client #FFEBE6 @Actor Client #FFEBE6
@Boundary OrderController #0747A6 @Boundary OrderController #0747A6
@@ -32,24 +25,21 @@
if(order != null) { if(order != null) {
par { par {
PurchaseService.createPO(order) PurchaseService.createPO(order)
InvoiceService.createInvoice(order) InvoiceService.createInvoice(order)
} }
} }
} }
} }
` `
}
]
}; };
const samples = { ...getSampleDiagrams(), ...extras }; const samples = { ...getSampleDiagrams(), ...extras } as const;
const loadSampleDiagram = (diagramType: string): void => {
const loadSampleDiagram = (diagramType: string, example: SampleExample): void => { updateCode(samples[diagramType], {
updateCode(example.code, {
resetPanZoom: true, resetPanZoom: true,
updateDiagram: true updateDiagram: true
}); });
logEvent('loadSampleDiagram', { diagramType, exampleTitle: example.title }); logEvent('loadSampleDiagram', { diagramType });
}; };
const mainDiagrams = [ const mainDiagrams = [
@@ -72,39 +62,12 @@
<Card title="Sample Diagrams" isOpen isStackable icon={{ component: ShapesIcon }}> <Card title="Sample Diagrams" isOpen isStackable icon={{ component: ShapesIcon }}>
<div class="flex h-fit max-h-52 flex-wrap gap-2 overflow-y-auto p-2"> <div class="flex h-fit max-h-52 flex-wrap gap-2 overflow-y-auto p-2">
{#each diagramOrder as sample (sample)} {#each diagramOrder as sample (sample)}
{@const examples = samples[sample]} <Button
<div class="flex min-w-20 flex-grow"> size="sm"
<Button class="w-fit min-w-20 flex-grow normal-case"
size="sm" onclick={() => loadSampleDiagram(sample)}>
class={cn('flex-grow normal-case', examples.length > 1 && 'rounded-r-none')} {sample}
onclick={() => loadSampleDiagram(sample, examples[0])}> </Button>
{sample}
</Button>
{#if examples.length > 1}
<Popover.Root>
<Popover.Trigger
aria-label="Choose a {sample} example"
class={cn(
buttonVariants({ size: 'sm' }),
'rounded-l-none border-l border-primary-foreground/30 px-0.5 [&_svg]:size-5'
)}>
<ChevronDownIcon />
</Popover.Trigger>
<Popover.Content align="start" class="flex w-fit flex-col gap-1 p-1">
{#each examples as example (example.title)}
<Popover.Close
class={cn(
buttonVariants({ variant: 'ghost', size: 'sm' }),
'justify-start normal-case'
)}
onclick={() => loadSampleDiagram(sample, example)}>
{example.title}
</Popover.Close>
{/each}
</Popover.Content>
</Popover.Root>
{/if}
</div>
{/each} {/each}
</div> </div>
</Card> </Card>
+1 -1
View File
@@ -1,4 +1,4 @@
<script lang="ts"> <script>
import ExternalLinkWrapper from '$/components/ExternalLinkWrapper.svelte'; import ExternalLinkWrapper from '$/components/ExternalLinkWrapper.svelte';
import * as Dialog from '$/components/ui/dialog'; import * as Dialog from '$/components/ui/dialog';
import { env } from '$/util/env'; import { env } from '$/util/env';
+3 -3
View File
@@ -1,9 +1,9 @@
<script lang="ts"> <script>
import { buttonVariants } from '$/components/ui/button'; import { buttonVariants } from '$/components/ui/button';
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 { urls } from '$/util/state.svelte'; import { urlsStore } from '$/util/state';
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={urls.current.mermaidChart({ medium: 'share' }).playground} /> <CopyInput value={$urlsStore.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>
+5 -11
View File
@@ -1,27 +1,21 @@
<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, inputState, updateCodeStore } from '$/util/state.svelte'; import { defaultState, inputStateStore } from '$/util/state';
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 (inputState.grid === undefined) { if ($inputStateStore.grid === undefined) {
// Handle cases where old states were saved without grid option // Handle cases where old states were saved without grid option
updateCodeStore({ grid: defaultState.grid }); $inputStateStore.grid = defaultState.grid;
} }
</script> </script>
<FloatingToolbar> <FloatingToolbar>
<Toggle <Toggle bind:pressed={$inputStateStore.rough} size="sm" title="Hand-Drawn">
bind:pressed={() => inputState.rough, (rough) => updateCodeStore({ rough })}
size="sm"
title="Hand-Drawn">
<RoughIcon /> <RoughIcon />
</Toggle> </Toggle>
<Toggle <Toggle bind:pressed={$inputStateStore.grid} size="sm" title="Background Grid">
bind:pressed={() => inputState.grid ?? defaultState.grid, (grid) => updateCodeStore({ grid })}
size="sm"
title="Background Grid">
<BackgroundIcon /> <BackgroundIcon />
</Toggle> </Toggle>
</FloatingToolbar> </FloatingToolbar>
+2 -2
View File
@@ -24,12 +24,12 @@
</script> </script>
<div class="inline-grid"> <div class="inline-grid">
{#key mode.current} {#key $mode}
<div <div
in:spin={{ clockWise: true }} in:spin={{ clockWise: true }}
out:spin={{ clockWise: false }} out:spin={{ clockWise: false }}
class="col-start-1 row-start-1"> class="col-start-1 row-start-1">
{#if mode.current === 'dark'} {#if $mode === 'dark'}
<MoonIcon /> <MoonIcon />
{:else} {:else}
<SunIcon /> <SunIcon />
@@ -23,9 +23,9 @@
variant="ghost" variant="ghost"
size="icon" size="icon"
data-testid={TID.themeToggleButton} data-testid={TID.themeToggleButton}
title="Switch to {mode.current === 'dark' ? 'light' : 'dark'} theme" title="Switch to {$mode === 'dark' ? 'light' : 'dark'} theme"
class="[&_svg]:size-5" class="[&_svg]:size-5"
onclick={() => setMode(mode.current === 'dark' ? 'light' : 'dark')}> onclick={() => setMode($mode === 'dark' ? 'light' : 'dark')}>
<ThemeIcon /> <ThemeIcon />
</Button> </Button>
</FloatingToolbar> </FloatingToolbar>
+9 -11
View File
@@ -3,7 +3,7 @@
import { recordRenderTime, shouldRefreshView } from '$/util/autoSync'; import { recordRenderTime, shouldRefreshView } from '$/util/autoSync';
import { render as renderDiagram } from '$/util/mermaid'; import { render as renderDiagram } from '$/util/mermaid';
import { PanZoomState } from '$/util/panZoom'; import { PanZoomState } from '$/util/panZoom';
import { updateCodeStore, validatedState } from '$/util/state.svelte'; import { inputStateStore, stateStore, updateCodeStore } from '$/util/state';
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,20 +133,18 @@
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, () => {
updateCodeStore({ updateDiagram: true }); $inputStateStore.updateDiagram = true;
}); });
}; };
onMount(() => { onMount(() => {
setupPanZoomObserver(); setupPanZoomObserver();
}); // Queue state changes to avoid race condition
let pendingStateChange = Promise.resolve();
// Queue state changes to avoid race condition stateStore.subscribe((state) => {
let pendingStateChange = Promise.resolve(); // eslint-disable-next-line @typescript-eslint/no-empty-function
$effect(() => { pendingStateChange = pendingStateChange.then(() => handleStateChange(state).catch(() => {}));
const state = validatedState.current; });
// eslint-disable-next-line @typescript-eslint/no-empty-function
pendingStateChange = pendingStateChange.then(() => handleStateChange(state).catch(() => {}));
}); });
</script> </script>
@@ -155,7 +153,7 @@
<div <div
id="view" id="view"
bind:this={view} bind:this={view}
class={['h-full w-full', shouldShowGrid && `grid-bg-${mode.current}`, error && 'opacity-50']}> class={['h-full w-full', shouldShowGrid && `grid-bg-${$mode}`, error && 'opacity-50']}>
<div id="container" bind:this={container} class="h-full overflow-auto"></div> <div id="container" bind:this={container} class="h-full overflow-auto"></div>
</div> </div>
@@ -1,101 +1,164 @@
<script lang="ts"> <script lang="ts">
import McWrapper from '$/components/McWrapper.svelte';
import MermaidChartIcon from '$/components/MermaidChartIcon.svelte';
import PrivacyPolicyLink from '$/components/migration/PrivacyPolicyLink.svelte';
import { Button } from '$/components/ui/button'; import { Button } from '$/components/ui/button';
import * as Dialog from '$/components/ui/dialog'; import * as Dialog from '$/components/ui/dialog';
import { dismissEditorChooser } from '$/util/migration/domainMigration'; import { dismissEditorChooser } from '$/util/migration/domainMigration';
import type { Component } from 'svelte'; import { logEvent, logMermaidChartClick } from '$/util/stats';
import AtlassianIcon from '~icons/logos/atlassian'; import { getCheckoutUrl } from '$/util/util';
import AmazonIcon from '~icons/logos/aws'; import CodeIcon from '~icons/custom/code';
import GoogleIcon from '~icons/logos/google'; import ArrowIcon from '~icons/material-symbols/arrow-forward-rounded';
import MicrosoftIcon from '~icons/logos/microsoft'; import OpenSourceIcon from '~icons/material-symbols/book-2-outline-rounded';
import { createEditorChooserActions } from './editorChooserActions'; import ChatIcon from '~icons/material-symbols/chat-outline-rounded';
import EditIcon from '~icons/material-symbols/edit-outline-rounded';
import HistoryIcon from '~icons/material-symbols/history';
import HomeIcon from '~icons/material-symbols/home-storage-outline-rounded';
import SparklesIcon from '~icons/material-symbols/kid-star-outline';
import LanguageIcon from '~icons/material-symbols/language';
import WidthIcon from '~icons/material-symbols/width-rounded';
interface Props { interface Props {
open: boolean; open: boolean;
} }
let { open = $bindable() }: Props = $props(); let { open = $bindable() }: Props = $props();
// Tracks whether the current close was triggered by an explicit action
// (button click logs its own event) vs. implicit dismissal (ESC/click-outside). const close = () => {
let handled = false; dismissEditorChooser();
const actions = createEditorChooserActions(() => {
handled = true;
open = false; open = false;
}); };
const features = [ const handleStartTrial = () => {
{ title: 'AI diagram generation', description: 'Describe what you need, AI builds it' }, logEvent('chooseEditor', { choice: 'plus' });
{ logMermaidChartClick('editorPicker');
title: 'Visual drag-and-drop editor', close();
description: 'Edit diagrams without writing code' window.open(
}, getCheckoutUrl({ utmCampaign: 'start_plus', utmMedium: '2_editor_selection' }),
{ '_blank'
title: 'Unlimited diagram storage', );
description: 'Save and organize all your diagrams' };
},
{
title: 'Team collaboration',
description: 'Share, comment, and edit together in real-time'
}
];
const trustedLogos: { name: string; icon: Component }[] = [ const handleStartFree = () => {
{ name: 'Google', icon: GoogleIcon }, logEvent('chooseEditor', { choice: 'openSource' });
{ name: 'Microsoft', icon: MicrosoftIcon }, close();
{ name: 'Atlassian', icon: AtlassianIcon }, };
{ name: 'Amazon', icon: AmazonIcon }
]; const handleContinueToNewHome = () => {
logEvent('chooseEditor', { choice: 'newHome' });
close();
window.open('https://mermaid.ai/live', '_blank');
};
</script> </script>
<Dialog.Root <Dialog.Root
bind:open bind:open
onOpenChange={(v) => { onOpenChange={(v) => {
if (v) return; if (!v) handleStartFree();
if (!handled) actions.log('dismissed');
handled = false;
dismissEditorChooser();
}}> }}>
<Dialog.Content class="flex max-w-lg flex-col gap-3 bg-background p-8"> <Dialog.Content class="flex max-w-2xl flex-col gap-6 bg-pink-50 p-6 dark:bg-background">
<Dialog.Header class="flex-col items-start gap-2 space-y-0 text-left sm:text-left"> <Dialog.Header>
<MermaidChartIcon class="size-10" /> <Dialog.Title class="text-center text-2xl font-semibold">Choose your editor</Dialog.Title>
<Dialog.Title class="pt-2 text-2xl font-bold">Try the full Mermaid experience</Dialog.Title> <Dialog.Description class="text-center text-sm font-light">
<Dialog.Description class="text-xs font-light text-muted-foreground"> You'll never see this again
Free forever, with Plus features free for 7 days.
</Dialog.Description> </Dialog.Description>
</Dialog.Header> </Dialog.Header>
<ul class="mt-2 flex flex-col gap-3"> <div class="grid gap-4 sm:grid-cols-2">
{#each features as feature (feature.title)} <!-- Mermaid Plus Card -->
<li class="flex items-center gap-3 rounded-lg border border-border p-3"> <div
<span class="size-2 shrink-0 rounded-full bg-accent"></span> class="relative flex flex-col overflow-hidden rounded-xl border-2 border-accent bg-white shadow dark:bg-card">
<div class="flex flex-col gap-0.5"> <div class="bg-accent px-6 py-2 text-center text-sm font-semibold text-accent-foreground">
<p class="text-xs">{feature.title}</p> Recommended
<p class="text-xs font-light text-muted-foreground">{feature.description}</p> </div>
<div class="flex flex-col gap-4 p-6">
<div>
<h3 class="text-xl font-bold">Mermaid Plus</h3>
<p class="text-sm text-muted-foreground">Unlock AI, storage and collaboration</p>
</div> </div>
</li>
{/each}
</ul>
<div class="mt-2 flex items-center gap-3"> <div class="flex flex-col gap-2">
<McWrapper labelPrefix="Opens "> <div class="flex justify-center">
<Button variant="accent" onclick={() => actions.startTrial()}>Start free trial</Button> <span
</McWrapper> class="rounded-full bg-pink-100 px-3 py-0.5 text-xs font-semibold text-pink-700 dark:bg-pink-950 dark:text-pink-300">
<Button variant="outline" onclick={() => actions.dismiss('stayOnLive')}> 10% off with code JS26
Stay on mermaid.live </span>
</Button> </div>
</div>
<div class="mt-3 flex flex-col items-start gap-4"> <Button variant="accent" class="w-full" onclick={handleStartTrial}>
<p class="text-xs">Trusted by 5M people and over 200k companies</p> Start free trial
<div class="flex w-full items-center justify-between gap-2"> </Button>
{#each trustedLogos as logo (logo.name)} </div>
<logo.icon class="h-6 w-auto grayscale" aria-label={logo.name} />
{/each} <ul class="space-y-3 text-sm">
<li class="flex items-center gap-2">
<EditIcon class="size-4 shrink-0 text-muted-foreground" />
Visual editor
</li>
<li class="flex items-center gap-2">
<SparklesIcon class="size-4 shrink-0 text-muted-foreground" />
300 AI credits
</li>
<li class="flex items-center gap-2">
<HomeIcon class="size-4 shrink-0 text-muted-foreground" />
Unlimited diagram storage
</li>
<li class="flex items-center gap-2">
<WidthIcon class="size-4 shrink-0 text-muted-foreground" />
Limitless diagram size
</li>
<li class="flex items-center gap-2">
<ChatIcon class="size-4 shrink-0 text-muted-foreground" />
View & comment collaboration
</li>
</ul>
</div>
</div>
<!-- Open Source Card -->
<div class="flex flex-col gap-4 rounded-xl border bg-white p-6 shadow dark:bg-card">
<div class="flex flex-col justify-end pt-10">
<h3 class="text-xl font-bold">Open Source</h3>
<p class="text-sm text-muted-foreground">Code only, no login, always free</p>
</div>
<div class="flex flex-col gap-2">
<p class="mt-2 text-sm text-muted-foreground">Mermaid has a new home</p>
<Button variant="outline" class="w-full border-accent" onclick={handleContinueToNewHome}>
Continue to mermaid.ai/live
<ArrowIcon class="ml-1 size-4" />
</Button>
<Button variant="outline" class="w-full" onclick={handleStartFree}>
Stay on mermaid.live
</Button>
</div>
<ul class="space-y-3 text-sm">
<li class="flex items-center gap-2">
<LanguageIcon class="size-4 shrink-0 text-muted-foreground" />
Diagram stored in URL
</li>
<li class="flex items-center gap-2">
<CodeIcon class="size-4 shrink-0 text-muted-foreground" />
Code editor
</li>
<li class="flex items-center gap-2">
<OpenSourceIcon class="size-4 shrink-0 text-muted-foreground" />
Open source
</li>
<li class="flex items-center gap-2">
<HistoryIcon class="size-4 shrink-0 text-muted-foreground" />
Version history
</li>
</ul>
</div> </div>
</div> </div>
<PrivacyPolicyLink /> <div class="text-center">
<a
href="https://mermaid.ai/privacy-policy"
target="_blank"
class="text-sm text-foreground underline hover:text-accent">
mermaid.ai Privacy Policy
</a>
</div>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>
@@ -1,10 +0,0 @@
<script lang="ts"></script>
<div class="text-center">
<a
href="https://mermaid.ai/privacy-policy"
target="_blank"
class="text-sm text-foreground underline hover:text-accent">
mermaid.ai Privacy Policy
</a>
</div>
@@ -1,38 +0,0 @@
import { logEvent, logMermaidChartClick } from '$/util/stats';
import { getCheckoutUrl, getMermaidAiLiveUrl } from '$/util/util';
const utmMedium = 'editorSelection';
const utmCampaign = 'live_2026';
export interface EditorChooserActions {
log: (buttonClick: string) => void;
startTrial: (buttonClick?: string) => void;
dismiss: (buttonClick: string) => void;
openMermaidAiLive: (buttonClick: string) => void;
}
export const createEditorChooserActions = (close: () => void): EditorChooserActions => {
const log = (buttonClick: string) => {
logEvent('chooseEditor', { buttonClick });
};
const startTrial = (buttonClick = 'startTrial') => {
log(buttonClick);
logMermaidChartClick('editorPicker');
close();
window.open(getCheckoutUrl({ utmCampaign, utmMedium }), '_blank', 'noopener');
};
const dismiss = (buttonClick: string) => {
log(buttonClick);
close();
};
const openMermaidAiLive = (buttonClick: string) => {
log(buttonClick);
close();
window.open(getMermaidAiLiveUrl({ utmCampaign, utmMedium }), '_blank', 'noopener');
};
return { log, startTrial, dismiss, openMermaidAiLive };
};
+1 -1
View File
@@ -6,7 +6,7 @@
</script> </script>
<Sonner <Sonner
theme={mode.current} theme={$mode}
class="toaster group" class="toaster group"
toastOptions={{ toastOptions={{
classes: { classes: {
@@ -24,12 +24,8 @@
}: ToggleGroupPrimitive.RootProps & ToggleVariants = $props(); }: ToggleGroupPrimitive.RootProps & ToggleVariants = $props();
setToggleGroupCtx({ setToggleGroupCtx({
get variant() { variant,
return variant; size
},
get size() {
return size;
}
}); });
</script> </script>
+11 -13
View File
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { setLoaderEntries } from '$lib/components/History/historyState.svelte'; import { addHistoryEntry } from '$lib/components/History/history';
import type { State } from '$lib/types'; import type { State } from '$lib/types';
import { defaultState } from '$lib/util/state.svelte'; import { defaultState } from '$lib/util/state';
import { fetchJSON, fetchText } from '$lib/util/util'; import { fetchJSON, fetchText } from '$lib/util/util';
const codeFileName = 'code.mmd'; const codeFileName = 'code.mmd';
@@ -117,16 +117,14 @@ export const loadGistData = async (gistURL: string): Promise<State> => {
throw new Error('Invalid gist provided'); throw new Error('Invalid gist provided');
} }
const state = getStateFromGist(entry, gistURL); const state = getStateFromGist(entry, gistURL);
setLoaderEntries( for (const gist of gistHistory) {
gistHistory addHistoryEntry({
.map((gist) => ({ name: `${gist.author} v${gist.version}`,
name: `${gist.author} v${gist.version}`, state: getStateFromGist(gist),
state: getStateFromGist(gist), time: gist.time,
time: gist.time, type: 'loader',
type: 'loader' as const, url: gist.url
url: gist.url });
})) }
.reverse()
);
return state; return state;
}; };
+1 -2
View File
@@ -1,5 +1,5 @@
import type { Loader, State } from '$lib/types'; import type { Loader, State } from '$lib/types';
import { defaultState, sanitizeConfig, updateCodeStore } from '$lib/util/state.svelte'; import { defaultState, updateCodeStore } from '$lib/util/state';
import { fetchText } from '$lib/util/util'; import { fetchText } from '$lib/util/util';
import { loadGistData } from './gist'; import { loadGistData } from './gist';
@@ -50,7 +50,6 @@ export const loadDataFromUrl = async (): Promise<void> => {
} }
} }
if (loaded) { if (loaded) {
state.mermaid = sanitizeConfig(state.mermaid || defaultState.mermaid);
updateCodeStore({ updateCodeStore({
...state, ...state,
updateDiagram: true updateDiagram: true
-14
View File
@@ -1,14 +0,0 @@
import type { LoadingState } from '$lib/types';
export const loadingState = $state<LoadingState>({ loading: false });
export const initLoading = async <T>(message: string, task: Promise<T>): Promise<T> => {
loadingState.loading = true;
loadingState.message = message;
try {
return await task;
} finally {
loadingState.loading = false;
loadingState.message = undefined;
}
};
+20
View File
@@ -0,0 +1,20 @@
import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
import type { LoadingState } from '$lib/types';
const defaultLoading: LoadingState = {
loading: false
};
export const loadingStateStore: Writable<LoadingState> = writable(defaultLoading);
export const initLoading = async <T>(message: string, task: Promise<T>): Promise<T> => {
loadingStateStore.set({
loading: true,
message
});
const result: T = await task;
loadingStateStore.set({
loading: false
});
return result;
};
-23
View File
@@ -1,23 +0,0 @@
import { describe, expect, it } from 'vitest';
import { getSampleDiagrams } from './mermaid';
describe('getSampleDiagrams', () => {
const samples = getSampleDiagrams();
it('should return at least one example per diagram', () => {
expect(Object.keys(samples).length).toBeGreaterThan(0);
for (const [name, examples] of Object.entries(samples)) {
expect(examples.length, `${name} should have at least one example`).toBeGreaterThan(0);
for (const example of examples) {
expect(example.title, `${name} has an example without a title`).toBeTruthy();
expect(example.code, `${name} example "${example.title}" has no code`).toBeTruthy();
}
}
});
it('should list the default example first', () => {
for (const [name, examples] of Object.entries(samples)) {
expect(examples[0].isDefault, `${name} should have its default example first`).toBe(true);
}
});
});
+11 -16
View File
@@ -24,11 +24,6 @@ export const parse = async (code: string) => {
return await mermaid.parse(code); return await mermaid.parse(code);
}; };
/**
* @see https://mermaid.js.org/config/schema-docs/config.html
*/
export const defaultMermaidConfig = mermaid.mermaidAPI.defaultConfig ?? {};
export const standardizeDiagramType = (diagramType: string) => { export const standardizeDiagramType = (diagramType: string) => {
switch (diagramType) { switch (diagramType) {
case 'class': case 'class':
@@ -49,20 +44,20 @@ export const standardizeDiagramType = (diagramType: string) => {
type DiagramDefinition = (typeof diagramData)[number]; type DiagramDefinition = (typeof diagramData)[number];
export type SampleExample = DiagramDefinition['examples'][number];
const isValidDiagram = (diagram: DiagramDefinition): diagram is Required<DiagramDefinition> => { const isValidDiagram = (diagram: DiagramDefinition): diagram is Required<DiagramDefinition> => {
return Boolean(diagram.name && diagram.examples && diagram.examples.length > 0); return Boolean(diagram.name && diagram.examples && diagram.examples.length > 0);
}; };
export const getSampleDiagrams = (): Record<string, SampleExample[]> => { export const getSampleDiagrams = () => {
const samples: Record<string, SampleExample[]> = {}; const diagrams = diagramData
for (const diagram of diagramData.filter((d) => isValidDiagram(d))) { .filter((d) => isValidDiagram(d))
// The default example comes first, so it is loaded when clicking the .map(({ examples, ...rest }) => ({
// diagram name and shown at the top of the example dropdown. ...rest,
samples[diagram.name.replace(/ (Diagram|Chart|Graph)/, '')] = [...diagram.examples].sort( example: examples?.filter(({ isDefault }) => isDefault)[0]
(a, b) => Number(b.isDefault ?? false) - Number(a.isDefault ?? false) }));
); const examples: Record<string, string> = {};
for (const diagram of diagrams) {
examples[diagram.name.replace(/ (Diagram|Chart|Graph)/, '')] = diagram.example.code;
} }
return samples; return examples;
}; };
+1 -13
View File
@@ -3,7 +3,6 @@ import { env } from '$/util/env';
const mermaidAiDomain = 'mermaid.ai'; const mermaidAiDomain = 'mermaid.ai';
const mermaidLiveDomain = 'mermaid.live'; const mermaidLiveDomain = 'mermaid.live';
const netlifyPreviewDomain = 'netlify.app';
/** /**
* Check if we're on mermaid.ai * Check if we're on mermaid.ai
@@ -21,13 +20,6 @@ export const isOnMermaidLive = (): boolean => {
return domain === mermaidLiveDomain || domain.endsWith(`.${mermaidLiveDomain}`); return domain === mermaidLiveDomain || domain.endsWith(`.${mermaidLiveDomain}`);
}; };
/**
* Check if we're on a Netlify preview/staging deploy (*.netlify.app).
*/
const isOnNetlifyPreview = (): boolean => {
return window.location.hostname.endsWith(`.${netlifyPreviewDomain}`);
};
/** /**
* Check if the current URL has pako data (diagram content from a shared link). * Check if the current URL has pako data (diagram content from a shared link).
*/ */
@@ -63,15 +55,11 @@ const isReferredFromMermaid = (): boolean => {
* Check if the editor chooser modal should be shown. * Check if the editor chooser modal should be shown.
* Shows for new users who haven't dismissed it and aren't viewing a shared link. * Shows for new users who haven't dismissed it and aren't viewing a shared link.
* Not shown on mobile (viewport width < 640px). * Not shown on mobile (viewport width < 640px).
* Can be forced open for QA via the `?editorChooser=1` query flag, which bypasses
* the hostname and dismissed checks.
*/ */
export const shouldShowEditorChooser = (): boolean => { export const shouldShowEditorChooser = (): boolean => {
if (!env.isEnabledMermaidChartLinks) return false; if (!env.isEnabledMermaidChartLinks) return false;
if (!isOnMermaidAI() && !isOnMermaidLive()) return false;
if (window.innerWidth < 640) return false; if (window.innerWidth < 640) return false;
const forced = new URLSearchParams(window.location.search).get('editorChooser') === '1';
if (forced) return true;
if (!isOnMermaidAI() && !isOnMermaidLive() && !isOnNetlifyPreview()) return false;
if (window.localStorage.getItem(C.editorChooserDismissedKey) === 'true') return false; if (window.localStorage.getItem(C.editorChooserDismissedKey) === 'true') return false;
if (hasPakoData()) return false; if (hasPakoData()) return false;
if (isReferredFromMermaid()) return false; if (isReferredFromMermaid()) return false;
+1 -1
View File
@@ -14,7 +14,7 @@ describe('migrations', () => {
}); });
it('should migrate from v0 to v1', async () => { it('should migrate from v0 to v1', async () => {
const { applyMigrations } = await import('./migrations.svelte'); const { applyMigrations } = await import('./migrations');
let manualHistoryStore: HistoryEntry[] = JSON.parse( let manualHistoryStore: HistoryEntry[] = JSON.parse(
window.localStorage.getItem('manualHistoryStore') ?? '[]' window.localStorage.getItem('manualHistoryStore') ?? '[]'
) as HistoryEntry[]; ) as HistoryEntry[];
@@ -1,5 +1,6 @@
import { injectHistoryIDs } from '$lib/components/History/historyState.svelte'; import { writable, get, type Writable } from 'svelte/store';
import { persisted } from '$lib/util/persist.svelte'; import { persist, localStorage } from '$lib/util/persist';
import { injectHistoryIDs } from '$lib/components/History/history';
import { logEvent } from './stats'; import { logEvent } from './stats';
interface MigrationState { interface MigrationState {
@@ -10,10 +11,14 @@ const migrations: Record<string, () => void> = {
injectHistoryIDs injectHistoryIDs
}; };
const migrationState = persisted<MigrationState>('migrations', { version: -1 }); const migrationStore: Writable<MigrationState> = persist(
writable({ version: -1 }),
localStorage(),
'migrations'
);
export const applyMigrations = (): void => { export const applyMigrations = (): void => {
const { version } = migrationState.value; const { version }: MigrationState = get(migrationStore);
const allMigrations = Object.entries(migrations); const allMigrations = Object.entries(migrations);
if (version === allMigrations.length - 1) { if (version === allMigrations.length - 1) {
return; return;
@@ -24,7 +29,7 @@ export const applyMigrations = (): void => {
console.log(`Applying migration ${i}: ${key}.`); console.log(`Applying migration ${i}: ${key}.`);
fn(); fn();
logEvent('migration', { key }); logEvent('migration', { key });
migrationState.value = { version: i }; migrationStore.set({ version: i });
} }
logEvent('migration', { status: 'complete', from: version, to: allMigrations.length - 1 }); logEvent('migration', { status: 'complete', from: version, to: allMigrations.length - 1 });
}; };
-66
View File
@@ -1,66 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { persisted, readJSON, writeJSON } from './persist.svelte';
beforeEach(() => {
window.localStorage.clear();
});
describe('readJSON', () => {
it('returns the fallback when the key is missing', () => {
expect(readJSON('missing', 'fallback')).toBe('fallback');
});
it('returns the parsed value when present', () => {
window.localStorage.setItem('key', '{"a":1}');
expect(readJSON<{ a: number }>('key', { a: 0 })).toEqual({ a: 1 });
});
it('returns the fallback when the stored value is corrupt', () => {
window.localStorage.setItem('corrupt', '{oops');
expect(readJSON('corrupt', 'fallback')).toBe('fallback');
// The previous persistence layer could write the literal string "undefined".
window.localStorage.setItem('legacy', 'undefined');
expect(readJSON('legacy', 'fallback')).toBe('fallback');
});
it('returns the fallback when the stored value parses to null', () => {
// The pre-runes persistence layer treated a stored null as absent.
window.localStorage.setItem('legacy-null', 'null');
expect(readJSON('legacy-null', 'fallback')).toBe('fallback');
});
});
describe('writeJSON', () => {
it('round-trips values through localStorage as JSON', () => {
writeJSON('key', { nested: { value: 2 } });
expect(window.localStorage.getItem('key')).toBe('{"nested":{"value":2}}');
expect(readJSON('key', {})).toEqual({ nested: { value: 2 } });
});
});
describe('persisted', () => {
it('initialises from storage when a value exists', () => {
window.localStorage.setItem('counter', '5');
const counter = persisted('counter', 0);
expect(counter.value).toBe(5);
});
it('uses the initial value when storage is empty, without writing it', () => {
const counter = persisted('counter', 7);
expect(counter.value).toBe(7);
expect(window.localStorage.getItem('counter')).toBeNull();
});
it('persists on assignment and exposes the new value', () => {
const counter = persisted('counter', 0);
counter.value = 42;
expect(counter.value).toBe(42);
expect(window.localStorage.getItem('counter')).toBe('42');
});
it('uses the initial value when storage holds a literal null', () => {
window.localStorage.setItem('settings', 'null');
const settings = persisted('settings', { theme: 'default' });
expect(settings.value).toEqual({ theme: 'default' });
});
});
-53
View File
@@ -1,53 +0,0 @@
/**
* Runes-based localStorage persistence, shared by the persisted state in
* `state.svelte.ts`, `migrations.svelte.ts`, `promo.svelte.ts` and History.
*
* Values are stored as plain JSON. Reads of missing or corrupt values fall
* back to the provided default, so values written by older versions of the
* editor (which serialized plain objects to the same JSON shape) stay loadable.
*/
const hasStorage = (): boolean => typeof window !== 'undefined' && !!window.localStorage;
export const readJSON = <T>(key: string, fallback: T): T => {
if (!hasStorage()) {
return fallback;
}
try {
const raw = window.localStorage.getItem(key);
if (raw === null) {
return fallback;
}
// A stored literal "null" means the value is absent: the pre-runes
// persistence layer never wrote null and treated it as missing.
return (JSON.parse(raw) as T) ?? fallback;
} catch {
return fallback;
}
};
export const writeJSON = (key: string, value: unknown): void => {
if (hasStorage()) {
window.localStorage.setItem(key, JSON.stringify(value));
}
};
export interface Persisted<T> {
value: T;
}
// A localStorage-backed reactive value. Reads on init, writes on every set.
// Raw state: replace `value` wholesale to change it. With a deep proxy,
// in-place mutation would update the UI without ever being persisted.
export const persisted = <T>(key: string, initial: T): Persisted<T> => {
let value = $state.raw<T>(readJSON(key, initial));
return {
get value() {
return value;
},
set value(next: T) {
value = next;
writeJSON(key, next);
}
};
};
+254
View File
@@ -0,0 +1,254 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// Copied from https://github.com/MacFJA/svelte-persistent-store
// # The MIT License (MIT)
// Copyright (c) 2021 [MacFJA](https://github.com/MacFJA)
// > Permission is hereby granted, free of charge, to any person obtaining a copy
// > of this software and associated documentation files (the "Software"), to deal
// > in the Software without restriction, including without limitation the rights
// > to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// > copies of the Software, and to permit persons to whom the Software is
// > furnished to do so, subject to the following conditions:
// >
// > The above copyright notice and this permission notice shall be included in
// > all copies or substantial portions of the Software.
// >
// > THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// > IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// > FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// > AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// > LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// > OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// > THE SOFTWARE.
import ESSerializer from 'esserializer';
import type { Writable } from 'svelte/store';
/**
* Disabled warnings about missing/unavailable storages
*/
export function disableWarnings(): void {
noWarnings = true;
}
/**
* If set to true, no warning will be emitted if the requested Storage is not found.
* This option can be useful when the lib is used on a server.
*/
let noWarnings = false;
/**
* List of storages where the warning have already been displayed.
*/
const alreadyWarnFor: string[] = [];
/**
* Add a log to indicate that the requested Storage have not been found.
* @param {string} storageName
*/
const warnStorageNotFound = (storageName: string) => {
const isProduction = typeof process !== 'undefined' && process.env.NODE_ENV === 'production';
if (!noWarnings && !alreadyWarnFor.includes(storageName) && !isProduction) {
let message = `Unable to find the ${storageName}. No data will be persisted.`;
if (typeof window === 'undefined') {
message +=
'\n' +
'Are you running on a server? Most of storages are not available while running on a server.';
}
console.warn(message);
alreadyWarnFor.push(storageName);
}
};
const serialize = (value: unknown): string => ESSerializer.serialize(value);
const deserialize = (value?: string | null): unknown => {
// @TODO: to remove in the next major
if (value === 'undefined') {
return undefined;
}
if (value !== null && value !== undefined) {
try {
return ESSerializer.deserialize(value);
} catch {
// Do nothing
// use the value "as is"
}
try {
return JSON.parse(value);
} catch {
// Do nothing
// use the value "as is"
}
}
return value;
};
/**
* A store that keep it's value in time.
*/
export interface PersistentStore<T> extends Writable<T> {
/**
* Delete the store value from the persistent storage
*/
delete(): void;
}
/**
* Storage interface
*/
export interface StorageInterface<T> {
/**
* Get a value from the storage.
*
* If the value doesn't exists in the storage, `null` should be returned.
* This method MUST be synchronous.
* @param key The key/name of the value to retrieve
*/
getValue(key: string): T | null;
/**
* Save a value in the storage.
* @param key The key/name of the value to save
* @param value The value to save
*/
setValue(key: string, value: T): void;
/**
* Remove a value from the storage
* @param key The key/name of the value to remove
*/
deleteValue(key: string): void;
}
export interface SelfUpdateStorageInterface<T> extends StorageInterface<T> {
/**
* Add a listener to the storage values changes
* @param {string} key The key to listen
* @param {(newValue: T) => void} listener The listener callback function
*/
addListener(key: string, listener: (newValue: T) => void): void;
/**
* Remove a listener from the storage values changes
* @param {string} key The key that was listened
* @param {(newValue: T) => void} listener The listener callback function to remove
*/
removeListener(key: string, listener: (newValue: T) => void): void;
}
/**
* Make a store persistent
* @param {Writable<*>} store The store to enhance
* @param {StorageInterface} storage The storage to use
* @param {string} key The name of the data key
*/
export function persist<T>(
store: Writable<T>,
storage: StorageInterface<T>,
key: string
): PersistentStore<T> {
const initialValue = storage.getValue(key);
if (null !== initialValue) {
store.set(initialValue);
}
if ('addListener' in storage) {
(storage as SelfUpdateStorageInterface<T>).addListener(key, (newValue) => {
store.set(newValue);
});
}
store.subscribe((value) => {
storage.setValue(key, value);
});
return {
...store,
delete() {
storage.deleteValue(key);
}
};
}
function getBrowserStorage(
browserStorage: Storage,
listenExternalChanges = false
): SelfUpdateStorageInterface<any> {
const listeners: { key: string; listener: (newValue: any) => void }[] = [];
const listenerFunction = (event: StorageEvent) => {
const eventKey = event.key;
if (event.storageArea === browserStorage) {
for (const { listener } of listeners.filter(({ key }) => key === eventKey)) {
listener(deserialize(event.newValue));
}
}
};
const connect = () => {
if (listenExternalChanges && typeof window !== 'undefined' && window.addEventListener) {
window.addEventListener('storage', listenerFunction);
}
};
const disconnect = () => {
if (listenExternalChanges && typeof window !== 'undefined' && window.removeEventListener) {
window.removeEventListener('storage', listenerFunction);
}
};
return {
addListener(key: string, listener: (newValue: any) => void) {
listeners.push({ key, listener });
if (listeners.length === 1) {
connect();
}
},
deleteValue(key: string) {
browserStorage.removeItem(key);
},
getValue(key: string): any {
const value = browserStorage.getItem(key);
return deserialize(value);
},
removeListener(key: string, listener: (newValue: any) => void) {
const index = listeners.indexOf({ key, listener });
if (index !== -1) {
listeners.splice(index, 1);
}
if (listeners.length === 0) {
disconnect();
}
},
setValue(key: string, value: any) {
browserStorage.setItem(key, serialize(value));
}
};
}
/**
* Storage implementation that use the browser local storage
* @param listenExternalChanges - Update the store if the localStorage is updated from another page
*/
export function localStorage<T>(listenExternalChanges = false): StorageInterface<T> {
if (typeof window !== 'undefined' && window.localStorage) {
return getBrowserStorage(window.localStorage, listenExternalChanges);
}
warnStorageNotFound('window.localStorage');
return noopStorage();
}
/**
* Storage implementation that do nothing
*/
export function noopStorage<T>(): StorageInterface<T> {
return {
getValue(): null {
return null;
},
deleteValue() {
// Do nothing
},
setValue() {
// Do nothing
}
};
}
+4 -9
View File
@@ -60,13 +60,6 @@
onDestroy(() => { onDestroy(() => {
clearInterval(interval); clearInterval(interval);
}); });
const taglineHref = $derived(
`${MCBaseURL}${currentTagline.url.path}?${new URLSearchParams({
...commonParams,
...currentTagline.url.params
}).toString()}`
);
</script> </script>
<div <div
@@ -77,9 +70,11 @@
<div class="grid grow"> <div class="grid grow">
{#key currentTagline} {#key currentTagline}
<a <a
href={taglineHref} href="{MCBaseURL}{currentTagline.url.path}?{new URLSearchParams({
...commonParams,
...currentTagline.url.params
}).toString()}"
target="_blank" target="_blank"
rel="noopener noreferrer"
class="col-start-1 row-start-1 flex items-center justify-center gap-4 no-underline" class="col-start-1 row-start-1 flex items-center justify-center gap-4 no-underline"
in:fade={{ delay: 800 }} in:fade={{ delay: 800 }}
out:fade={{ duration: 1000 }}> out:fade={{ duration: 1000 }}>
@@ -1,8 +1,9 @@
import { env } from '$lib/util/env'; import { env } from '$lib/util/env';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration'; import duration from 'dayjs/plugin/duration';
import type { Component, Snippet } from 'svelte'; import type { Component } from 'svelte';
import { persisted } from '../persist.svelte'; import { get, writable, type Writable } from 'svelte/store';
import { localStorage, persist } from '../persist';
import April2025 from './April2025.svelte'; import April2025 from './April2025.svelte';
import JS2026 from './JS2026.svelte'; import JS2026 from './JS2026.svelte';
@@ -11,7 +12,7 @@ dayjs.extend(duration);
interface Promotion { interface Promotion {
startDate: Date; startDate: Date;
endDate: Date; endDate: Date;
component: Component<{ closeBanner: Snippet }>; component: Component;
hideDurationMs: number; hideDurationMs: number;
} }
@@ -34,21 +35,25 @@ export const dismissPromotion = (id?: string): void => {
if (!id || !promotions[id]) { if (!id || !promotions[id]) {
return; return;
} }
hiddenPromotions.value = { hiddenPromotionsStore.update((dismissedIDs) => {
...hiddenPromotions.value, dismissedIDs[id] = dayjs().add(promotions[id].hideDurationMs).valueOf();
[id]: dayjs().add(promotions[id].hideDurationMs).valueOf() return dismissedIDs;
}; });
}; };
const hiddenPromotions = persisted<Record<string, number>>('hiddenPromotions', {}); const hiddenPromotionsStore: Writable<Record<string, number>> = persist(
writable({}),
localStorage(),
'hiddenPromotions'
);
export const getActivePromotion = (): (Promotion & { id: string }) | undefined => { export const getActivePromotion = (): (Promotion & { id: string }) | undefined => {
if (!env.isEnabledMermaidChartLinks) { if (!env.isEnabledMermaidChartLinks) {
return; return;
} }
const hidePromotionsUntil = hiddenPromotions.value; const hidePromotionsUntil = get(hiddenPromotionsStore);
const now = dayjs(); const now = new Date();
const promotionWithID = Object.entries(promotions) const promotionWithID = Object.entries(promotions)
.filter( .filter(
([id, p]) => ([id, p]) =>
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { serializeState, deserializeState, type SerdeType } from './serde'; import { serializeState, deserializeState, type SerdeType } from './serde';
import { defaultState } from './state.svelte'; import { defaultState } from './state';
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 => {
-88
View File
@@ -1,88 +0,0 @@
import type { State } from '$lib/types';
import { flushSync } from 'svelte';
import { describe, expect, it } from 'vitest';
import {
defaultState,
inputState,
loadState,
replaceInputState,
toggleDarkTheme,
updateCode,
updateCodeStore,
updateConfig,
verifyState
} from './state.svelte';
// Runs `body` inside an effect and reports how often the effect (re-)runs.
const countEffectRuns = (body: () => void): { runs: () => number; stop: () => void } => {
let runs = 0;
const stop = $effect.root(() => {
$effect(() => {
runs++;
body();
});
});
flushSync();
return { runs: () => runs, stop };
};
const readStoredState = (): State =>
JSON.parse(window.localStorage.getItem('codeStore') ?? '{}') as State;
describe('update functions called from effects', () => {
// Effects that call an update function must not subscribe to the input
// state the function reads, or unrelated state changes re-fire the effect
// (and self-reads loop, e.g. the dark-theme effect in +layout.svelte).
const cases: [string, () => void][] = [
['updateCodeStore', () => updateCodeStore({})],
['updateCode', () => updateCode('graph TD\n inside-effect')],
['updateConfig', () => updateConfig('{"theme":"default"}')],
['toggleDarkTheme', () => toggleDarkTheme(false)],
['replaceInputState', () => replaceInputState({ ...defaultState })],
['verifyState', () => verifyState()],
['loadState', () => loadState('')]
];
it.each(cases)('%s does not make the calling effect track input state', (_name, call) => {
const counter = countEffectRuns(call);
try {
expect(counter.runs()).toBe(1);
updateCode('graph TD\n external-change');
updateConfig('{"theme":"forest"}');
updateCodeStore({ pan: { x: 1, y: 2 } });
flushSync();
expect(counter.runs()).toBe(1);
} finally {
counter.stop();
}
});
});
describe('update functions persist input state', () => {
it('updateCode writes the new code to localStorage', () => {
updateCode('graph TD\n persisted-by-test');
expect(readStoredState().code).toBe('graph TD\n persisted-by-test');
});
it('updateCodeStore merges partial state and persists it', () => {
updateCodeStore({ rough: true });
expect(inputState.rough).toBe(true);
expect(readStoredState().rough).toBe(true);
});
it('replaceInputState drops keys absent from the next state and persists', () => {
updateCodeStore({ pan: { x: 1, y: 2 } });
expect(inputState.pan).toEqual({ x: 1, y: 2 });
replaceInputState({ ...defaultState });
expect(inputState.pan).toBeUndefined();
expect(readStoredState().pan).toBeUndefined();
expect(readStoredState().code).toBe(defaultState.code);
});
it('verifyState forces panZoom back on', () => {
updateCodeStore({ panZoom: false });
verifyState();
expect(inputState.panZoom).toBe(true);
expect(readStoredState().panZoom).toBe(true);
});
});
-375
View File
@@ -1,375 +0,0 @@
import type { ErrorHash, MarkerData, State, ValidatedState } from '$/types';
import { resolve } from '$app/paths';
import { debounce, get as lodashGet } from 'lodash-es';
import type { MermaidConfig } from 'mermaid';
import { untrack } from 'svelte';
import { env } from './env';
import {
extractErrorLineText,
findMostRelevantLineNumber,
replaceLineNumberInErrorMessage
} from './errorHandling';
import { defaultMermaidConfig, parse } from './mermaid';
import { readJSON, writeJSON } from './persist.svelte';
import { deserializeState, pakoSerde, serializeState } from './serde';
import { errorDebug, formatJSON, getUTMSource, MCBaseURL } from './util';
export const defaultState: State = {
code: `flowchart TD
A[Christmas] -->|Get money| B(Go shopping)
B --> C{Let me think}
C -->|One| D[Laptop]
C -->|Two| E[iPhone]
C -->|Three| F[fa:fa-car Car]
`,
grid: true,
mermaid: formatJSON({
theme: 'default'
}),
panZoom: true,
rough: false,
updateDiagram: true
};
const urlParseFailedState = `flowchart TD
A[Loading URL failed. We can try to figure out why.] -->|Decode JSON| B(Please check the console to see the JSON and error details.)
B --> C{Is the JSON correct?}
C -->|Yes| D(Please Click here to Raise an issue in github.<br/>Including the broken link in the issue <br/> will speed up the fix.)
C -->|No| E{Did someone <br/>send you this link?}
E -->|Yes| F[Ask them to send <br/>you the complete link]
E -->|No| G{Did you copy <br/> the complete URL?}
G --> |Yes| D
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"`;
const CODE_STORE_KEY = 'codeStore';
// The single mutable input state; only update() below may write to it.
// The fallback is cloned so mutations never write through to defaultState.
const input = $state<State>(readJSON(CODE_STORE_KEY, { ...defaultState }));
// inputState is shared externally when exporting via URL, History, etc.
// It is reactive for reads; the read-only type keeps writes inside this
// module, where update() persists and re-validates every change.
export const inputState: Readonly<State> = input;
const validatedStateOf = (state: State, serialized: string): ValidatedState => ({
...state,
editorMode: state.editorMode ?? 'code',
error: undefined,
errorMarkers: [],
serialized
});
const initialState = $state.snapshot(input) as State;
// Only ever replaced wholesale, so raw (shallow) reactivity is enough.
let validatedCurrent = $state.raw<ValidatedState>(
validatedStateOf(initialState, serializeState(initialState))
);
let lastDiagramType = '';
const processState = async (state: State) => {
const processed = validatedStateOf(state, '');
// No changes should be done to fields part of `state`.
try {
processed.serialized = serializeState(state);
const { diagramType } = await parse(state.code);
processed.diagramType = diagramType;
if (lastDiagramType === 'zenuml' && diagramType !== lastDiagramType) {
// Temp Hack to refresh page after displaying ZenUML.
setTimeout(() => window.location.reload(), 500);
}
lastDiagramType = diagramType;
JSON.parse(state.mermaid);
} catch (error) {
processed.error = error as Error;
errorDebug();
console.error(error);
if (error && typeof error === 'object' && 'hash' in error) {
try {
let errorString = processed.error.toString();
const errorLineText = extractErrorLineText(errorString);
const realLineNumber = findMostRelevantLineNumber(errorLineText, state.code);
let first_line: number, last_line: number, first_column: number, last_column: number;
try {
({ first_line, last_line, first_column, last_column } = (error.hash as ErrorHash).loc);
} catch {
const lineNo = findMostRelevantLineNumber(errorString, state.code);
first_line = lineNo;
last_line = lineNo + 1;
first_column = 0;
last_column = 0;
}
if (realLineNumber !== -1) {
errorString = replaceLineNumberInErrorMessage(errorString, realLineNumber);
}
processed.error = new Error(errorString);
const marker: MarkerData = {
endColumn: last_column + (first_column === last_column ? 0 : 5),
endLineNumber: last_line + (realLineNumber - first_line),
message: errorString || 'Syntax error',
severity: 8, // Error
startColumn: first_column,
startLineNumber: realLineNumber
};
processed.errorMarkers = [marker];
} catch (error) {
console.error('Error without line helper', error);
}
}
}
return processed;
};
// Replaces the old URL-hash store subscription; assigned by initURLSubscription.
let updateHash: ((serialized: string) => void) | undefined;
// Persist the current input state and asynchronously re-validate it,
// publishing the result to `validatedState` (and the URL hash, once
// initURLSubscription has run). Only called from update(), which suppresses
// dependency tracking.
const persistAndProcess = (): void => {
const snapshot = $state.snapshot(input) as State;
writeJSON(CODE_STORE_KEY, snapshot);
void processState(snapshot).then((processed) => {
validatedCurrent = processed;
updateHash?.(processed.serialized);
});
};
// The single mutation gateway: every update function funnels its writes
// through here. The mutator runs untracked so effects that call an update
// function never subscribe to the input state it reads, and the trailing
// persist + re-validate cannot be forgotten by a new update function.
const update = (mutate: (state: State) => void): void => {
untrack(() => {
mutate(input);
persistAndProcess();
});
};
// All internal reads should be done via validatedState, but it should not be
// persisted/shared externally.
export const validatedState = {
get current(): ValidatedState {
return validatedCurrent;
}
};
const urlsCurrent = $derived.by(() => {
const { code, serialized } = validatedCurrent;
const { krokiRendererUrl, rendererUrl } = env;
const png = rendererUrl ? `${rendererUrl}/img/${serialized}?type=png` : '';
return {
kroki: krokiRendererUrl ? `${krokiRendererUrl}/mermaid/svg/${pakoSerde.serialize(code)}` : '',
mdCode: png ? `[![](${png})](${window.location.href})` : '',
mermaidChart: ({
medium,
campaign
}: {
medium:
| 'ai_edit'
| 'ai_repair'
| 'main_menu'
| 'save_diagram'
| 'share'
| 'vibe_diagramming'
| 'visual_edit'
| 'voice_edit';
campaign?: string;
}) => {
const utmSource = getUTMSource();
const params = new URLSearchParams({
utm_source: utmSource,
utm_medium: medium,
...(campaign ? { utm_campaign: campaign } : {})
}).toString();
return {
save: `${MCBaseURL}/app/plugin/save?state=${serialized}&${params}`,
playground: `${MCBaseURL}/play?${params}#${serialized}`,
plugins: `${MCBaseURL}/plugins?${params}`,
home: `${MCBaseURL}/?${params}`
};
},
new: `${resolve('/edit', {})}#${serializeState(defaultState)}`,
png,
svg: rendererUrl ? `${rendererUrl}/svg/${serialized}` : '',
view: `${resolve('/view', {})}#${serialized}`
};
});
export const urls = {
get current() {
return urlsCurrent;
}
};
/**
* Gets a list of paths that contain unsafe keys which might pose security risks.
*
* @param object - The object to check for unsafe keys.
* @param unsafeKeys - List of unsafe keys.
* @param path - The current path being checked (used for recursion).
* @returns List of unsafe paths.
*/
function getUnsafePaths(object: object, unsafeKeys: string[], path: string[] = []) {
const unsafePaths = new Array<string[]>();
for (const key of unsafeKeys) {
// Copied from mermaid's sanitize function in case there's non-enumerable keys
if (Object.hasOwn(object, key)) {
unsafePaths.push([...path, key]);
continue;
}
}
Object.keys(object).forEach((key) => {
const value = (object as Record<string, unknown>)[key];
const currentPath = [...path, key];
// Prototype pollution check.
if (key.startsWith('__')) {
unsafePaths.push(currentPath);
return;
}
if (typeof value === 'object' && value !== null) {
unsafePaths.push(...getUnsafePaths(value as object, unsafeKeys, currentPath));
} else if (
typeof value === 'string' &&
// XSS prevention checks -- See mermaid `sanitize` function for reference.
(value.includes('<') || value.includes('>') || value.includes('url(data:'))
) {
unsafePaths.push(currentPath);
}
});
return unsafePaths;
}
/**
* Asks the user for confirmation if the config contains settings that might
* pose security risks, such as a relaxed `securityLevel`.
*
* @param config - The Mermaid configuration to sanitize.
* @returns The sanitized Mermaid configuration as a JSON string.
*/
export const sanitizeConfig = (config: string | MermaidConfig) => {
const mermaidConfig: MermaidConfig =
typeof config === 'string' ? (JSON.parse(config) as MermaidConfig) : config;
const secureKeys = defaultMermaidConfig.secure ?? [];
const unsafePaths = getUnsafePaths(mermaidConfig, secureKeys).filter((path) => {
return lodashGet(mermaidConfig, path) !== lodashGet(defaultMermaidConfig, path);
});
if (
unsafePaths.length > 0 &&
confirm(
`Removing ${unsafePaths
.map((unsafePath) => {
return `${JSON.stringify(unsafePath.join('.'))}: ${JSON.stringify(lodashGet(mermaidConfig, unsafePath))}`;
})
.join(
',\n'
)} from the config for safety.\nClick Cancel if you trust the source of this Diagram.`
)
) {
for (const unsafePath of unsafePaths) {
const pathToObject = [...unsafePath];
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- We know this exists since it was found in `getUnsafePaths`
const lastKey = pathToObject.pop()!;
const lastObject =
pathToObject.length === 0 ? mermaidConfig : lodashGet(mermaidConfig, pathToObject);
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Copied from mermaid code
delete lastObject[lastKey];
}
}
return formatJSON(mermaidConfig);
};
export const loadState = (data: string): void => {
console.log(`Loading '${data}'`);
update((state) => {
let next: State;
try {
next = deserializeState(data);
next.mermaid = sanitizeConfig(next.mermaid || defaultState.mermaid);
} catch (error) {
next = $state.snapshot(state) as State;
if (data) {
console.error('Init error', error);
next.code = urlParseFailedState;
next.mermaid = defaultState.mermaid;
}
}
applyPartial(state, next);
});
};
let renderCount = 0;
const applyPartial = (state: State, newState: Partial<State>): void => {
renderCount++;
Object.assign(state, newState, { renderCount });
};
export const updateCodeStore = (newState: Partial<State>): void => {
update((state) => applyPartial(state, newState));
};
export const updateCode = (
code: string,
{
updateDiagram = false,
resetPanZoom = false
}: { updateDiagram?: boolean; resetPanZoom?: boolean } = {}
): void => {
errorDebug();
update((state) => {
if (resetPanZoom) {
state.pan = undefined;
state.zoom = undefined;
}
state.code = code;
state.updateDiagram = updateDiagram;
});
};
export const updateConfig = (config: string): void => {
updateCodeStore({ mermaid: config });
};
export const toggleDarkTheme = (dark: boolean): void => {
update((state) => {
const config = JSON.parse(state.mermaid) as MermaidConfig;
if (!config.theme || ['dark', 'default'].includes(config.theme)) {
config.theme = dark ? 'dark' : 'default';
}
state.mermaid = formatJSON(config);
});
};
// 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 => {
update((state) => {
for (const key of Object.keys(state)) {
if (!(key in next)) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- full-replace semantics
delete (state as unknown as Record<string, unknown>)[key];
}
}
Object.assign(state, next);
});
};
export const initURLSubscription = (): void => {
updateHash = debounce((serialized: string) => {
history.replaceState(undefined, '', `#${serialized}`);
}, 250);
updateHash(validatedCurrent.serialized);
};
export const verifyState = (): void => {
update((state) => applyPartial(state, state.panZoom ? {} : { panZoom: true }));
};
+267
View File
@@ -0,0 +1,267 @@
import type { ErrorHash, MarkerData, State, ValidatedState } from '$/types';
import { debounce } from 'lodash-es';
import type { MermaidConfig } from 'mermaid';
import { derived, get, writable, type Readable } from 'svelte/store';
import { env } from './env';
import {
extractErrorLineText,
findMostRelevantLineNumber,
replaceLineNumberInErrorMessage
} from './errorHandling';
import { parse } from './mermaid';
import { localStorage, persist } from './persist';
import { deserializeState, pakoSerde, serializeState } from './serde';
import { errorDebug, formatJSON, getUTMSource, MCBaseURL } from './util';
export const defaultState: State = {
code: `flowchart TD
A[Christmas] -->|Get money| B(Go shopping)
B --> C{Let me think}
C -->|One| D[Laptop]
C -->|Two| E[iPhone]
C -->|Three| F[fa:fa-car Car]
`,
grid: true,
mermaid: formatJSON({
theme: 'default'
}),
panZoom: true,
rough: false,
updateDiagram: true
};
const urlParseFailedState = `flowchart TD
A[Loading URL failed. We can try to figure out why.] -->|Decode JSON| B(Please check the console to see the JSON and error details.)
B --> C{Is the JSON correct?}
C -->|Yes| D(Please Click here to Raise an issue in github.<br/>Including the broken link in the issue <br/> will speed up the fix.)
C -->|No| E{Did someone <br/>send you this link?}
E -->|Yes| F[Ask them to send <br/>you the complete link]
E -->|No| G{Did you copy <br/> the complete URL?}
G --> |Yes| D
G --> |"No :("| H(Try using the Timeline tab in History <br/>from same browser you used to create the diagram.)
click D href "https://github.com/mermaid-js/mermaid-live-editor/issues/new?assignees=&labels=bug&template=bug_report.md&title=Broken%20link" "Raise issue"`;
// inputStateStore handles all updates and is shared externally when exporting via URL, History, etc.
export const inputStateStore = persist(writable(defaultState), localStorage(), 'codeStore');
export const currentState: ValidatedState = (() => {
const state = get(inputStateStore);
return {
...state,
editorMode: state.editorMode ?? 'code',
error: undefined,
errorMarkers: [],
serialized: serializeState(state)
};
})();
let lastDiagramType = '';
const processState = async (state: State) => {
const processed: ValidatedState = {
...state,
editorMode: state.editorMode ?? 'code',
error: undefined,
errorMarkers: [],
serialized: ''
};
// No changes should be done to fields part of `state`.
try {
processed.serialized = serializeState(state);
const { diagramType } = await parse(state.code);
processed.diagramType = diagramType;
if (lastDiagramType === 'zenuml' && diagramType !== lastDiagramType) {
// Temp Hack to refresh page after displaying ZenUML.
setTimeout(() => window.location.reload(), 500);
}
lastDiagramType = diagramType;
JSON.parse(state.mermaid);
} catch (error) {
processed.error = error as Error;
errorDebug();
console.error(error);
if (error && typeof error === 'object' && 'hash' in error) {
try {
let errorString = processed.error.toString();
const errorLineText = extractErrorLineText(errorString);
const realLineNumber = findMostRelevantLineNumber(errorLineText, state.code);
let first_line: number, last_line: number, first_column: number, last_column: number;
try {
({ first_line, last_line, first_column, last_column } = (error.hash as ErrorHash).loc);
} catch {
const lineNo = findMostRelevantLineNumber(errorString, state.code);
first_line = lineNo;
last_line = lineNo + 1;
first_column = 0;
last_column = 0;
}
if (realLineNumber !== -1) {
errorString = replaceLineNumberInErrorMessage(errorString, realLineNumber);
}
processed.error = new Error(errorString);
const marker: MarkerData = {
endColumn: last_column + (first_column === last_column ? 0 : 5),
endLineNumber: last_line + (realLineNumber - first_line),
message: errorString || 'Syntax error',
severity: 8, // Error
startColumn: first_column,
startLineNumber: realLineNumber
};
processed.errorMarkers = [marker];
} catch (error) {
console.error('Error without line helper', error);
}
}
}
return processed;
};
// All internal reads should be done via stateStore, but it should not be persisted/shared externally.
export const stateStore: Readable<ValidatedState> = derived(
[inputStateStore],
([state], set) => {
void processState(state).then(set);
},
currentState
);
export const urlsStore = derived([stateStore], ([{ code, serialized }]) => {
const { krokiRendererUrl, rendererUrl } = env;
const png = rendererUrl ? `${rendererUrl}/img/${serialized}?type=png` : '';
return {
kroki: krokiRendererUrl ? `${krokiRendererUrl}/mermaid/svg/${pakoSerde.serialize(code)}` : '',
mdCode: png
? `[![](${png})](${window.location.protocol}//${window.location.host}${window.location.pathname}#${serialized})`
: '',
mermaidChart: ({
medium,
campaign
}: {
medium:
| 'ai_repair'
| 'ai_edit'
| 'main_menu'
| 'save_diagram'
| 'share'
| 'vibe_diagramming'
| 'visual_edit'
| 'voice_edit';
campaign?: string;
}) => {
const utmSource = getUTMSource();
const params = new URLSearchParams({
utm_source: utmSource,
utm_medium: medium,
...(campaign ? { utm_campaign: campaign } : {})
}).toString();
return {
save: `${MCBaseURL}/app/plugin/save?state=${serialized}&${params}`,
playground: `${MCBaseURL}/play?${params}#${serialized}`,
plugins: `${MCBaseURL}/plugins?${params}`,
home: `${MCBaseURL}/?${params}`
};
},
new: `${window.location.protocol}//${window.location.host}${window.location.pathname}#${serializeState(defaultState)}`,
png,
svg: rendererUrl ? `${rendererUrl}/svg/${serialized}` : '',
view: `/view#${serialized}`
};
});
export const loadState = (data: string): void => {
let state: State;
console.log(`Loading '${data}'`);
try {
state = deserializeState(data);
if (!state.mermaid) {
state.mermaid = defaultState.mermaid;
}
const mermaidConfig: MermaidConfig =
typeof state.mermaid === 'string'
? (JSON.parse(state.mermaid) as MermaidConfig)
: state.mermaid;
if (
mermaidConfig.securityLevel &&
mermaidConfig.securityLevel !== 'strict' &&
confirm(
`Removing "securityLevel":"${mermaidConfig.securityLevel}" from the config for safety.\nClick Cancel if you trust the source of this Diagram.`
)
) {
delete mermaidConfig.securityLevel; // Prevent setting overriding securityLevel when loading state to mitigate possible XSS attack
}
state.mermaid = formatJSON(mermaidConfig);
} catch (error) {
state = get(inputStateStore);
if (data) {
console.error('Init error', error);
state.code = urlParseFailedState;
state.mermaid = defaultState.mermaid;
}
}
updateCodeStore(state);
};
let renderCount = 0;
export const updateCodeStore = (newState: Partial<State>): void => {
inputStateStore.update((state) => {
renderCount++;
return { ...state, ...newState, renderCount };
});
};
export const updateCode = (
code: string,
{
updateDiagram = false,
resetPanZoom = false
}: { updateDiagram?: boolean; resetPanZoom?: boolean } = {}
): void => {
errorDebug();
inputStateStore.update((state) => {
if (resetPanZoom) {
state.pan = undefined;
state.zoom = undefined;
}
return { ...state, code, updateDiagram };
});
};
export const updateConfig = (config: string): void => {
updateCodeStore({ mermaid: config });
};
export const toggleDarkTheme = (dark: boolean): void => {
inputStateStore.update((state) => {
const config = JSON.parse(state.mermaid) as MermaidConfig;
if (!config.theme || ['dark', 'default'].includes(config.theme)) {
config.theme = dark ? 'dark' : 'default';
}
return { ...state, mermaid: formatJSON(config) };
});
};
export const initURLSubscription = (): void => {
const updateHash = debounce((hash) => {
history.replaceState(undefined, '', `#${hash}`);
}, 250);
stateStore.subscribe(({ serialized }) => {
updateHash(serialized);
});
};
export const getStateString = (): string => {
return JSON.stringify(get(inputStateStore));
};
export const verifyState = (): void => {
const state = get(inputStateStore);
if (!state.panZoom) {
state.panZoom = true;
}
updateCodeStore(state);
};
-3
View File
@@ -113,9 +113,6 @@ export const logEvent = (
name: AnalyticsEvent, name: AnalyticsEvent,
data?: Record<string, string | number | boolean> data?: Record<string, string | number | boolean>
): void => { ): void => {
if (browser && window.location.hostname === 'localhost') {
console.log('[plausible]', name, data);
}
if (!plausible) { if (!plausible) {
return; return;
} }
+8 -15
View File
@@ -1,10 +1,10 @@
import { C } from '$/constants'; import { C } from '$/constants';
import { env } from './env'; import { env } from './env';
import { loadDataFromUrl } from './fileLoaders/loader'; import { loadDataFromUrl } from './fileLoaders/loader';
import { initLoading } from './loading.svelte'; import { initLoading } from './loading';
import { isOnMermaidAI } from './migration/domainMigration'; import { isOnMermaidAI } from './migration/domainMigration';
import { applyMigrations } from './migrations.svelte'; import { applyMigrations } from './migrations';
import { initURLSubscription, loadState, updateCodeStore, verifyState } from './state.svelte'; import { initURLSubscription, loadState, updateCodeStore, verifyState } from './state';
import { getAnalyticsSafeUrl, initAnalytics, plausible } from './stats'; import { getAnalyticsSafeUrl, initAnalytics, plausible } from './stats';
export const getDomain = (url?: string): string => { export const getDomain = (url?: string): string => {
@@ -42,30 +42,23 @@ export const MCBaseURL = env.isEnabledMermaidChartLinks
? 'https://mermaid.ai' // 'http://localhost:5174' ? 'https://mermaid.ai' // 'http://localhost:5174'
: 'https://example.com'; : 'https://example.com';
const buildUtmParams = ({ export const getCheckoutUrl = ({
utmCampaign, utmCampaign,
utmMedium utmMedium
}: { }: {
utmCampaign: string; utmCampaign: string;
utmMedium: string; utmMedium: string;
}): URLSearchParams => }): string => {
new URLSearchParams({ const params = new URLSearchParams({
coupon: 'arDfyFT8',
tier: 'plus',
utm_campaign: utmCampaign, utm_campaign: utmCampaign,
utm_medium: utmMedium, utm_medium: utmMedium,
utm_source: getUTMSource() utm_source: getUTMSource()
}); });
export const getCheckoutUrl = (utm: { utmCampaign: string; utmMedium: string }): string => {
const params = buildUtmParams(utm);
params.set('coupon', 'arDfyFT8');
params.set('tier', 'plus');
return `${MCBaseURL}/app/user/billing/checkout?${params.toString()}`; return `${MCBaseURL}/app/user/billing/checkout?${params.toString()}`;
}; };
export const getMermaidAiLiveUrl = (utm: { utmCampaign: string; utmMedium: string }): string => {
return `${MCBaseURL}/live?${buildUtmParams(utm).toString()}`;
};
let count = 0; let count = 0;
export const errorDebug = (limit = 1000) => { export const errorDebug = (limit = 1000) => {
count += 1; count += 1;
+7 -8
View File
@@ -1,21 +1,20 @@
<script> <script>
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { resolve } from '$app/paths'; import { page } from '$app/stores';
import { page } from '$app/state';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
// Only redirect if it's a 404 error // Only redirect if it's a 404 error
onMount(() => { onMount(() => {
if (page.status === 404) { if ($page.status === 404) {
goto(resolve('/')); goto('/');
} }
}); });
</script> </script>
{#if page.status !== 404} {#if $page.status !== 404}
<div class="container mx-auto p-8"> <div class="container mx-auto p-8">
<h1 class="mb-4 text-2xl font-bold">Error {page.status}</h1> <h1 class="mb-4 text-2xl font-bold">Error {$page.status}</h1>
<p class="mb-4">{page.error?.message || 'An unexpected error occurred'}</p> <p class="mb-4">{$page.error?.message || 'An unexpected error occurred'}</p>
<a href={resolve('/')} class="text-blue-500 hover:underline">Return to Home</a> <a href="/" class="text-blue-500 hover:underline">Return to Home</a>
</div> </div>
{/if} {/if}
+5 -5
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Toaster } from '$/components/ui/sonner/index.js'; import { Toaster } from '$/components/ui/sonner/index.js';
import { loadingState } from '$/util/loading.svelte'; import { loadingStateStore } from '$/util/loading';
import { toggleDarkTheme } from '$/util/state.svelte'; import { toggleDarkTheme } from '$/util/state';
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';
@@ -34,7 +34,7 @@
}); });
$effect(() => { $effect(() => {
toggleDarkTheme(mode.current === 'dark'); toggleDarkTheme($mode === 'dark');
}); });
</script> </script>
@@ -45,12 +45,12 @@
{@render children()} {@render children()}
</main> </main>
{#if loadingState.loading} {#if $loadingStateStore.loading}
<div <div
class="absolute top-0 left-0 z-50 flex h-screen w-screen justify-center bg-gray-600 align-middle opacity-50"> class="absolute top-0 left-0 z-50 flex h-screen w-screen justify-center bg-gray-600 align-middle opacity-50">
<div class="my-auto text-4xl font-bold text-indigo-100"> <div class="my-auto text-4xl font-bold text-indigo-100">
<div class="loader mx-auto"></div> <div class="loader mx-auto"></div>
<div>{loadingState.message}</div> <div>{$loadingStateStore.message}</div>
</div> </div>
</div> </div>
{/if} {/if}
+5 -9
View File
@@ -5,7 +5,6 @@
import Editor from '$/components/Editor.svelte'; import Editor from '$/components/Editor.svelte';
import EnhancedEditsButton from '$/components/EnhancedEditsButton.svelte'; import EnhancedEditsButton from '$/components/EnhancedEditsButton.svelte';
import History from '$/components/History/History.svelte'; import History from '$/components/History/History.svelte';
import { startAutoSave } from '$/components/History/historyState.svelte';
import McWrapper from '$/components/McWrapper.svelte'; import McWrapper from '$/components/McWrapper.svelte';
import MermaidChartIcon from '$/components/MermaidChartIcon.svelte'; import MermaidChartIcon from '$/components/MermaidChartIcon.svelte';
import EditorChooserModal from '$/components/migration/EditorChooserModal.svelte'; import EditorChooserModal from '$/components/migration/EditorChooserModal.svelte';
@@ -23,7 +22,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 { validatedState, updateCodeStore, urls } from '$/util/state.svelte'; import { stateStore, updateCodeStore, urlsStore } from '$/util/state';
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';
@@ -64,9 +63,6 @@
}); });
}); });
// Record the Timeline for the whole session, not just while the panel is open.
onMount(() => startAutoSave());
let isHistoryOpen = $state(false); let isHistoryOpen = $state(false);
let editorPane: Resizable.Pane | undefined; let editorPane: Resizable.Pane | undefined;
@@ -91,7 +87,7 @@
{/snippet} {/snippet}
<Navbar mobileToggle={isMobile ? mobileToggle : undefined}> <Navbar mobileToggle={isMobile ? mobileToggle : undefined}>
<Toggle bind:pressed={isHistoryOpen} size="sm" title="History" aria-label="History"> <Toggle bind:pressed={isHistoryOpen} size="sm">
<HistoryIcon /> <HistoryIcon />
</Toggle> </Toggle>
<Share /> <Share />
@@ -99,7 +95,7 @@
<Button <Button
variant="accent" variant="accent"
size="sm" size="sm"
href={urls.current.mermaidChart({ medium: 'save_diagram' }).save} href={$urlsStore.mermaidChart({ medium: 'save_diagram' }).save}
target="_blank" target="_blank"
onclick={() => logMermaidChartClick('saveDiagram')}> onclick={() => logMermaidChartClick('saveDiagram')}>
<MermaidChartIcon /> <MermaidChartIcon />
@@ -124,7 +120,7 @@
onselect={tabSelectHandler} onselect={tabSelectHandler}
isOpen isOpen
tabs={editorTabs} tabs={editorTabs}
activeTabID={validatedState.current.editorMode} activeTabID={$stateStore.editorMode}
isClosable={false}> isClosable={false}>
{#snippet actions()} {#snippet actions()}
<DiagramDocButton /> <DiagramDocButton />
@@ -140,7 +136,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={validatedState.current.grid} /> <View {panZoomState} shouldShowGrid={$stateStore.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>
+53 -116
View File
@@ -1,147 +1,84 @@
import { expect, test, type Page } from '@playwright/test'; import { expect, test } from '@playwright/test';
import { typeInEditor } from './utils';
const config = '{\n "theme": "default"\n}'; test.describe.skip('Save History', () => {
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 }) => { test.beforeEach(async ({ page }) => {
// Freeze time so auto-save snapshots are deterministic.
await page.addInitScript(() => { await page.addInitScript(() => {
Object.defineProperty(Date, 'now', { value: () => new Date(2022, 0, 1).getTime() }); Object.defineProperty(Date, 'now', {
value: () => new Date(2022, 0, 1).getTime()
});
}); });
await page.goto('/edit'); await page.goto('/edit');
await page.getByText('History').click();
}); });
test('loads Saved and Timeline history from localStorage and restores entries', async ({ test('should load history from localstorage', async ({ page }) => {
page await page.evaluate(() => {
}) => { localStorage.setItem(
await page.evaluate( 'manualHistoryStore',
([manual, auto]) => { '[{"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"}]'
localStorage.setItem('manualHistoryStore', manual); );
localStorage.setItem('autoHistoryStore', auto); localStorage.setItem(
}, 'autoHistoryStore',
[JSON.stringify(manualHistory), JSON.stringify(autoHistory)] '[{"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.reload();
await openHistory(page); await page.getByText('History').click();
// Saved tab is active by default.
await expect(page.locator('#historyList li')).toHaveCount(2); await expect(page.locator('#historyList li')).toHaveCount(2);
await expect(page.locator('#historyList')).toContainText('hollow-art'); 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('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 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 li')).toHaveCount(2);
await expect(page.locator('#historyList')).toContainText('barking-dog'); 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('needy-mosquito');
await expect(page.locator('#historyList')).not.toContainText('hollow-art'); await expect(page.locator('#historyList')).toContainText('barking-dog');
await page.getByText('Restore').first().click();
await page.getByRole('button', { name: 'Restore this version' }).first().click(); await expect(page.locator('#view').getByText('New Year')).toBeVisible();
await expect(page.locator('#view')).toContainText('NewYear');
}); });
test('each entry has a copyable link that opens it in a new tab', async ({ page }) => { test.skip('should save when clicked', async ({ page }) => {
await page.evaluate(
(manual) => localStorage.setItem('manualHistoryStore', manual),
JSON.stringify(manualHistory)
);
await page.reload();
await openHistory(page);
// 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 expect(page.locator('#historyList li')).toHaveCount(0);
await expect(page.locator('#historyList')).toContainText('No items in History');
await page.locator('#saveHistory').click(); 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); await expect(page.locator('#historyList li')).toHaveCount(1);
const dialogPromise = page.waitForEvent('dialog');
// Saving again without changes does not add a duplicate and notifies the user.
await page.locator('#saveHistory').click(); await page.locator('#saveHistory').click();
await expect(page.getByText('State already saved.')).toBeVisible(); const dialog = await dialogPromise;
await expect(page.locator('#historyList li')).toHaveCount(1); expect(dialog.message()).toBe('State already saved.');
await dialog.accept();
// Loading a different sample changes the state, so it saves as a new entry. 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('#saveHistory').click();
await expect(page.locator('#historyList li')).toHaveCount(2); await expect(page.locator('#historyList li')).toHaveCount(2);
}); });
test('auto-saves to the Timeline only, never the Saved list', async ({ page }) => { test.skip('should be able to restore and delete', async ({ page }) => {
await openHistory(page);
await page.locator('#saveHistory').click(); 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('#historyList li')).toHaveCount(1);
await expect(page.locator('#view').getByText('HistoryTest')).toBeVisible();
await page.getByRole('tab', { name: 'Timeline' }).click(); await page.getByText('Restore').click();
// A manual save must not appear under Timeline. await expect(page.locator('#view').getByText('HistoryTest')).not.toBeVisible();
await expect(page.locator('#historyList')).toContainText('No timeline snapshots yet.'); await page.getByText('Delete').click();
}); await expect(page.locator('#historyList li')).toHaveCount(0);
await expect(page.locator('#historyList')).toContainText('No items in History');
test('deletes a single entry and clears all after confirmation', async ({ page }) => {
await openHistory(page);
await page.locator('#saveHistory').click(); await page.locator('#saveHistory').click();
await page.getByRole('button', { name: 'Sequence', exact: true }).click(); await typeInEditor(page, ' C --> HistoryTest');
await expect(page.locator('#view')).not.toContainText('Christmas');
await page.locator('#saveHistory').click(); await page.locator('#saveHistory').click();
await expect(page.locator('#historyList li')).toHaveCount(2); await page.locator('#editor').type('ing');
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(); await page.locator('#clearHistory').click();
await expect(page.locator('#historyList li')).toHaveCount(0);
await expect(page.locator('#historyList')).toContainText('No saved states yet.'); 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');
}); });
}); });
-40
View File
@@ -1,5 +1,3 @@
import type { State } from '$/types';
import assert from 'node:assert';
import { expect, test } from './test'; import { expect, test } from './test';
test.describe('Site Loads', () => { test.describe('Site Loads', () => {
@@ -69,44 +67,6 @@ test.describe('Site Loads', () => {
}); });
}); });
test('should prompt user to scrub unsafe config', async ({ editPage, page }) => {
let dialogAccepted = false;
page.on('dialog', async (dialog) => {
expect(dialog.type()).toBe('confirm');
expect(dialog.message()).toContain('from the config for safety');
await dialog.accept();
dialogAccepted = true;
});
await editPage.start(
`/edit?${new URLSearchParams({
code: `data:application/vnd.mermaid,${encodeURIComponent('flowchart TD\nHello-->World')}`,
config: `data:application/json,${encodeURIComponent(
JSON.stringify({
someOtherSetting: 'Test value',
securityLevel: 'loose',
secure: [],
themeVariables: {
nodeBorder: '</style></svg><script>alert("XSS")</script>'
}
})
)}`
}).toString()}`
);
await editPage.checkTextInView('Hello');
await expect.poll(() => dialogAccepted).toBeTruthy();
const codeStore = await page.evaluate(() => localStorage.getItem('codeStore'));
assert(codeStore);
const parsedStore = JSON.parse(codeStore) as State;
const parsedConfig = JSON.parse(parsedStore.mermaid) as Record<string, unknown>;
expect(parsedConfig).toEqual({
someOtherSetting: 'Test value',
themeVariables: {}
});
// should scrub unsafe securityLevel but keep other settings
expect(parsedConfig.securityLevel).toBeUndefined();
expect(parsedConfig.secure).toBeUndefined();
});
test('should show troubleshooting steps if loading fails', async ({ editPage, page }) => { test('should show troubleshooting steps if loading fails', async ({ editPage, page }) => {
await editPage.start('/#/edit/eyJjb2RlIjoiZ3JhcGggVERcbiAg'); await editPage.start('/#/edit/eyJjb2RlIjoiZ3JhcGggVERcbiAg');
await page.reload({ waitUntil: 'networkidle' }); await page.reload({ waitUntil: 'networkidle' });
-1
View File
@@ -12,7 +12,6 @@
"resolveJsonModule": true, "resolveJsonModule": true,
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"strictNullChecks": true, "strictNullChecks": true,
"skipLibCheck": true,
"types": ["vitest/importMeta", "@playwright/test"] "types": ["vitest/importMeta", "@playwright/test"]
}, },
"extends": "./.svelte-kit/tsconfig.json" "extends": "./.svelte-kit/tsconfig.json"
-2
View File
@@ -33,8 +33,6 @@ export default defineConfig({
envPrefix: 'MERMAID_', envPrefix: 'MERMAID_',
server: { port: 3000, host: true }, server: { port: 3000, host: true },
preview: { port: 3000, host: true }, preview: { port: 3000, host: true },
// Vitest otherwise resolves Svelte's server build, where $effect is a no-op.
resolve: process.env.VITEST ? { conditions: ['browser'] } : undefined,
test: { test: {
environment: 'jsdom', environment: 'jsdom',
// in-source testing // in-source testing