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'
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.60.0-jammy
image: mcr.microsoft.com/playwright:v1.52.0-jammy
steps:
- name: Checkout
-3
View File
@@ -39,8 +39,5 @@ jobs:
- name: Lint
run: pnpm lint
- name: Type check
run: pnpm check
- name: Run unit tests
run: pnpm test:unit
-1
View File
@@ -15,4 +15,3 @@
/playwright-report/
/blob-report/
/playwright/.cache/
.playwright-mcp/
+1 -1
View File
@@ -1 +1 @@
24.16.0
22.15.0
-3
View File
@@ -12,14 +12,11 @@
"fsegurai",
"gantt",
"gitgraph",
"hugeicons",
"KROKI",
"localstorage",
"mermaidchart",
"mindmap",
"NEWYEAR",
"noopener",
"noreferrer",
"Pageview",
"pako",
"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 && \
rm -rf /var/cache/apk/*
+74 -77
View File
@@ -9,8 +9,6 @@
"dev:test": "pnpm dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check --cache . && eslint .",
"lint:fix": "prettier --write --cache . && eslint --fix .",
"format": "prettier --write --cache .",
@@ -25,96 +23,95 @@
"test:e2e:debug": "playwright test --debug"
},
"devDependencies": {
"@eslint/compat": "^2.1.0",
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^10.0.1",
"@fortawesome/fontawesome-free": "^7.2.0",
"@iconify-json/hugeicons": "^1.2.29",
"@iconify-json/logos": "^1.2.11",
"@iconify-json/material-symbols": "^1.2.79",
"@eslint/compat": "^1.2.5",
"@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^9.39.2",
"@fortawesome/fontawesome-free": "^6.7.2",
"@iconify-json/material-symbols": "^1.2.20",
"@iconify-json/mdi": "^1.2.3",
"@playwright/test": "^1.60.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.1",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.1",
"@playwright/test": "^1.52.0",
"@sveltejs/adapter-static": "^3.0.9",
"@sveltejs/kit": "^2.37.0",
"@sveltejs/vite-plugin-svelte": "^6.1.3",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
"@types/hammerjs": "^2.0.46",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.13.2",
"@types/pako": "2.0.4",
"@vitest/coverage-v8": "^4.1.9",
"@vitest/ui": "^4.1.9",
"autoprefixer": "^10.5.0",
"bits-ui": "^2.18.1",
"c8": "11.0.0",
"chai": "^6.2.2",
"@types/node": "^22.15.10",
"@types/pako": "2.0.3",
"@types/uuid": "9.0.8",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"autoprefixer": "^10.4.21",
"bits-ui": "^2.9.6",
"c8": "7.14.0",
"chai": "^4.5.0",
"clsx": "^2.1.1",
"cssnano": "^8.0.2",
"dotenv": "^17.4.2",
"eslint": "^10.4.1",
"cssnano": "^6.1.2",
"dotenv": "^17.3.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"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-svelte": "^3.19.0",
"eslint-plugin-tailwindcss": "^3.18.3",
"eslint-plugin-unicorn": "^65.0.1",
"globals": "^17.6.0",
"husky": "^9.1.7",
"jsdom": "^29.1.1",
"lint-staged": "^17.0.7",
"lucide-svelte": "^1.0.1",
"node-html-parser": "^7.1.0",
"paneforge": "1.0.2",
"prettier": "^3.8.4",
"prettier-plugin-svelte": "^4.1.1",
"prettier-plugin-tailwindcss": "^0.8.0",
"svelte": "^5.56.3",
"svelte-check": "^4.6.0",
"svelte-preprocess": "^6.0.5",
"svelte-sonner": "^1.1.1",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.3.1",
"eslint-plugin-svelte": "^3.0.0",
"eslint-plugin-tailwindcss": "^3.18.2",
"eslint-plugin-unicorn": "^60.0.0",
"esserializer": "^1.3.11",
"globals": "^16.0.0",
"husky": "^8.0.3",
"jsdom": "^25.0.1",
"lint-staged": "^15.5.1",
"lucide-svelte": "^0.507.0",
"node-html-parser": "^6.1.13",
"paneforge": "1.0.0-next.6",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.38.6",
"svelte-preprocess": "^6.0.3",
"svelte-sonner": "^1.0.5",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^3.1.0",
"tailwindcss": "^4.1.18",
"tslib": "^2.8.1",
"tw-animate-css": "^1.4.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.1",
"unplugin-icons": "^23.0.1",
"vite": "^8.0.16",
"tw-animate-css": "^1.3.8",
"typescript": "^5.9.2",
"typescript-eslint": "^8.42.0",
"unplugin-icons": "^22.2.0",
"vite": "^7.1.4",
"vite-plugin-devtools-json": "^1.0.0",
"vitest": "^4.1.9",
"vitest": "^3.2.4",
"vitest-dom": "^0.1.1"
},
"dependencies": {
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/language": "^6.12.3",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.1",
"@fontsource-variable/recursive": "^5.2.8",
"@fsegurai/codemirror-theme-vscode-dark": "^6.2.6",
"@fsegurai/codemirror-theme-vscode-light": "^6.2.6",
"@mermaid-js/examples": "^1.2.0",
"@mermaid-js/layout-elk": "^0.2.1",
"@mermaid-js/layout-tidy-tree": "^0.2.2",
"@mermaid-js/mermaid-zenuml": "^0.2.3",
"codemirror": "^6.0.2",
"dayjs": "^1.11.21",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-markdown": "^6.3.2",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.11.0",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.7",
"@fontsource-variable/recursive": "^5.2.5",
"@fsegurai/codemirror-theme-vscode-dark": "^6.1.4",
"@fsegurai/codemirror-theme-vscode-light": "^6.1.4",
"@mermaid-js/examples": "^1.0.0",
"@mermaid-js/layout-elk": "^0.1.9",
"@mermaid-js/layout-tidy-tree": "^0.2.1",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"codemirror": "^6.0.1",
"dayjs": "^1.11.13",
"hammerjs": "^2.0.8",
"js-base64": "3.7.8",
"lodash-es": "^4.18.1",
"mermaid": "^11.15.0",
"mode-watcher": "^1.1.0",
"monaco-editor": "0.55.1",
"js-base64": "3.7.7",
"lodash-es": "^4.17.21",
"mermaid": "^11.13.0",
"mode-watcher": "^0.5.1",
"monaco-editor": "0.52.2",
"pako": "2.1.0",
"plausible-tracker": "^0.3.9",
"random-word-slugs": "0.1.7",
"svg-pan-zoom": "3.6.2",
"svg2roughjs": "^3.2.3",
"uuid": "14.0.0"
"svg2roughjs": "^3.2.1",
"uuid": "9.0.1"
},
"lint-staged": {
"*.{ts,svelte,js,css,md,json}": [
@@ -123,9 +120,9 @@
]
},
"engines": {
"node": ">=24.16.0"
"node": ">=20.19.0"
},
"packageManager": "pnpm@10.34.4+sha512.8768be55200ae3f2226b6527fcca2687e14bc4e5f12d7721a0f25da3df47915177058648db4177baf348120fa0ba2752d8d8d93f6beaf1fe64ae18da8de961af",
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39",
"pnpm": {
"onlyBuiltDependencies": [
"deasync",
+3675 -2917
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -8,8 +8,7 @@
<meta
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." />
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/favicon.png" />
<link rel="icon" type="image/png" href="%sveltekit.assets%/favicon.svg" />
<link rel="mask-icon" href="%sveltekit.assets%/favicon.svg" color="#000000" />
<meta name="theme-color" content="#ff3670" />
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
+15 -19
View File
@@ -11,7 +11,7 @@
import { getDomain } from '$/util/util';
import { browser } from '$app/environment';
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 { version as FAVersion } from '@fortawesome/fontawesome-free/package.json';
import dayjs from 'dayjs';
@@ -80,7 +80,7 @@
svg = getSvgElement();
}
if (validatedState.current.rough) {
if ($stateStore.rough) {
fixForeignObjectClipping(svg);
}
@@ -106,7 +106,7 @@ ${svgString}`);
};
const exportImage = async (event: Event, exporter: Exporter) => {
updateCodeStore({ panZoom: false });
$inputStateStore.panZoom = false;
await new Promise((resolve) => setTimeout(resolve, 1000));
await waitForRender();
const canvas = document.createElement('canvas');
@@ -149,14 +149,14 @@ ${svgString}`);
const image = new Image();
image.addEventListener('load', () => {
exporter(context, image)();
updateCodeStore({ panZoom: true });
$inputStateStore.panZoom = true;
});
image.src = `data:image/svg+xml;base64,${getBase64SVG(svg, canvas.width, canvas.height)}`;
// Fallback to set panZoom to true after 2 seconds
// This is a workaround for the case when the image is not loaded
setTimeout(() => {
if (!inputState.panZoom) {
updateCodeStore({ panZoom: true });
if (!$inputStateStore.panZoom) {
$inputStateStore.panZoom = true;
}
}, 2000);
event.stopPropagation();
@@ -199,10 +199,7 @@ ${svgString}`);
};
};
const onCopyClipboard = async (event?: Event) => {
if (!event) {
return;
}
const onCopyClipboard = async (event: Event) => {
await exportImage(event, clipboardCopy);
logEvent('copyClipboard');
};
@@ -222,8 +219,7 @@ ${svgString}`);
};
let gistURL = $state('');
$effect(() => {
const { loader } = validatedState.current;
stateStore.subscribe(({ loader }) => {
if (loader?.type === 'gist') {
gistURL = loader.config.url;
}
@@ -288,10 +284,10 @@ ${svgString}`);
bind:value={imageSize} />
</div>
<div class="flex gap-2">
{@render dualActionButton('PNG', onDownloadPNG, urls.current.png)}
{@render dualActionButton('SVG', onDownloadSVG, urls.current.svg)}
<ExternalLinkWrapper domain={getDomain(urls.current.kroki)} isVisible={!!urls.current.kroki}>
<a target="_blank" rel="noreferrer" class="flex-grow" href={urls.current.kroki}>
{@render dualActionButton('PNG', onDownloadPNG, $urlsStore.png)}
{@render dualActionButton('SVG', onDownloadSVG, $urlsStore.svg)}
<ExternalLinkWrapper domain={getDomain($urlsStore.kroki)} isVisible={!!$urlsStore.kroki}>
<a target="_blank" rel="noreferrer" class="flex-grow" href={$urlsStore.kroki}>
<Button class="action-btn flex w-full items-center gap-2">
<ExternalLinkIcon /> Kroki
</Button>
@@ -304,9 +300,9 @@ ${svgString}`);
{/if}
<ExternalLinkWrapper
labelPrefix="Thumbnail generated by"
domain={getDomain(urls.current.png)}
isVisible={!!urls.current.mdCode}>
<CopyInput value={urls.current.mdCode} label="Copy Markdown" testID={TID.copyMarkdown} />
domain={getDomain($urlsStore.png)}
isVisible={!!$urlsStore.mdCode}>
<CopyInput value={$urlsStore.mdCode} label="Copy Markdown" testID={TID.copyMarkdown} />
</ExternalLinkWrapper>
<div class="flex w-full items-center gap-2">
<Input type="url" bind:value={gistURL} placeholder="Enter Gist URL" />
+4 -3
View File
@@ -14,8 +14,9 @@
onselect?: (tab: Tab) => void;
} = $props();
// Derive (don't mutate the prop) so the highlight tracks a bound activeTabID.
const effectiveTabID = $derived(activeTabID || tabs[0]?.id);
if (!activeTabID && tabs.length > 0) {
activeTabID = tabs[0].id;
}
const toggleTabs = (tab: Tab) => {
return (event: Event) => {
@@ -33,7 +34,7 @@
variant="ghost"
class={[
'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)}
onkeypress={toggleTabs(tab)}>
+42 -64
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import type { EditorProps } from '$/types';
import { env } from '$/util/env';
import { urls, validatedState } from '$/util/state.svelte';
import { stateStore, urlsStore } from '$/util/state';
import { logMermaidChartClick } from '$/util/stats';
import { AIPromptViewZoneManager } from '$lib/util/AIPromptViewZoneManager';
import { initEditor } from '$lib/util/monacoExtra';
@@ -27,7 +27,6 @@
lineNumbersMinChars: 4
} satisfies monaco.editor.IStandaloneEditorConstructionOptions;
let currentText = '';
let isUpdatingFromState = false;
let showPopup = $state(false);
let popupPosition = $state({ top: 0, lineNumber: 0 });
let decorationsCollection: monaco.editor.IEditorDecorationsCollection | undefined;
@@ -35,16 +34,6 @@
let lastMouseLine = 0;
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(
'',
'json',
@@ -115,7 +104,7 @@
throw new Error('divEl is undefined');
}
monaco.json.jsonDefaults.setDiagnosticsOptions({
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
enableSchemaRequest: true,
schemas: [
@@ -143,13 +132,43 @@
editor.onDidChangeModelContent(({ isFlush }) => {
const newText = editor?.getValue();
if (!newText || currentText === newText || isFlush || isUpdatingFromState) {
if (!newText || currentText === newText || isFlush) {
return;
}
currentText = newText;
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) => {
if (!editor) return;
if (showPopup) return;
@@ -164,8 +183,12 @@
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) => {
editor?.layout({
height: entries[0].contentRect.height,
@@ -180,6 +203,8 @@
renderAIPromptGutterGlyphIcon();
return () => {
unsubscribeState();
unsubscribeMode();
resizeObserver.disconnect();
jsonModel.dispose();
mermaidModel.dispose();
@@ -187,49 +212,6 @@
editor?.dispose();
};
});
$effect(() => {
const { errorMarkers, editorMode, code, mermaid } = validatedState.current;
if (!editor) {
return;
}
const model = editorMode === 'code' ? mermaidModel : jsonModel;
if (editor.getModel()?.id !== model.id) {
editor.setModel(model);
renderAIPromptGutterGlyphIcon();
}
// Clear decorations if not in 'code' mode, or if the model changes
if (editorMode !== 'code' || editor.getModel()?.id !== mermaidModel.id) {
decorationsCollection?.clear();
}
// Update editor text if it's different
const newText = editorMode === 'code' ? code : mermaid;
if (newText !== currentText) {
isUpdatingFromState = true;
try {
editor.setScrollTop(0);
editor.pushUndoStop();
editor.executeEdits('updateCode', [
{
range: model.getFullModelRange(),
text: newText
}
]);
editor.pushUndoStop();
currentText = newText;
} finally {
isUpdatingFromState = false;
}
renderAIPromptGutterGlyphIcon();
}
// Display/clear errors
monaco.editor.setModelMarkers(model, 'mermaid', errorMarkers);
});
</script>
<div class="relative h-full grow overflow-hidden">
@@ -242,11 +224,7 @@
onClose={closePopup}
onTryFree={() => {
logMermaidChartClick('vibeDiagramming');
window.open(
urls.current.mermaidChart({ medium: 'vibe_diagramming' }).save,
'_blank',
'noopener'
);
window.open($urlsStore.mermaidChart({ medium: 'vibe_diagramming' }).save, '_blank');
closePopup();
}} />
</div>
@@ -4,7 +4,7 @@
import type { DocumentationConfig } from '$/types';
import { env } from '$/util/env';
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';
const docURLBase = env.docsUrl;
@@ -92,14 +92,12 @@
} as const satisfies DocumentationConfig;
const doc = $derived.by(() => {
const { editorMode, diagramType } = validatedState.current;
const { editorMode, diagramType } = $stateStore;
if (!diagramType) {
return { key: '', url: docURLBase };
}
const key = standardizeDiagramType(diagramType);
const docConfig: { code: string; config?: string } = docMap[key as keyof typeof docMap] ?? {
code: ''
};
const docConfig = docMap[key] ?? { code: '' };
const url = docURLBase + (docConfig[editorMode] ?? docConfig.code ?? '');
return { key, url };
});
+8 -8
View File
@@ -6,14 +6,14 @@
import { Button } from '$/components/ui/button';
import { TID } from '$/constants';
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 { debounce } from 'lodash-es';
import ExclamationCircleIcon from '~icons/material-symbols/error-outline-rounded';
const { isMobile } = $props<{ isMobile: boolean }>();
const onUpdate = (text: string) => {
if (validatedState.current.editorMode === 'code') {
if ($stateStore.editorMode === 'code') {
updateCode(text);
} else {
updateConfig(text);
@@ -27,7 +27,7 @@
}, 3000);
$effect(() => {
if (validatedState.current.error) {
if ($stateStore.error) {
showErrorDebounced();
} else {
showErrorDebounced.cancel();
@@ -46,27 +46,27 @@
{:else}
<DesktopEditor {onUpdate} />
{/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 items-center justify-between gap-2 bg-slate-900 p-2 text-white">
<div class="flex w-fit items-center gap-2">
<ExclamationCircleIcon class="size-6 text-destructive" aria-hidden="true" />
<div class="flex flex-col">
<p>Syntax error</p>
{#if env.isEnabledMermaidChartLinks && validatedState.current.editorMode === 'code'}
{#if env.isEnabledMermaidChartLinks && $stateStore.editorMode === 'code'}
<p class="text-xs text-white/60" data-testid={TID.aiHelpText}>
Create a free account to repair with AI
</p>
{/if}
</div>
</div>
{#if validatedState.current.editorMode === 'code'}
{#if $stateStore.editorMode === 'code'}
<McWrapper>
<Button
variant="accent"
size="sm"
data-testid={TID.aiRepairButton}
href={urls.current.mermaidChart({ medium: 'ai_repair' }).save}
href={$urlsStore.mermaidChart({ medium: 'ai_repair' }).save}
target="_blank"
onclick={() => logMermaidChartClick('aiRepair')}>
<MermaidChartIcon />
@@ -76,7 +76,7 @@
{/if}
</div>
<output class="max-h-32 overflow-auto bg-muted p-2" name="mermaid-error" for="editor">
<pre>{validatedState.current.error?.toString()}</pre>
<pre>{$stateStore.error?.toString()}</pre>
</output>
</div>
{/if}
+22 -23
View File
@@ -3,7 +3,7 @@
import MermaidChartIcon from '$/components/MermaidChartIcon.svelte';
import { Button } from '$/components/ui/button';
import { standardizeDiagramType } from '$/util/mermaid';
import { validatedState, urls } from '$/util/state.svelte';
import { stateStore, urlsStore } from '$/util/state';
import { logMermaidChartClick } from '$/util/stats';
import { quintInOut } from 'svelte/easing';
import { slide } from 'svelte/transition';
@@ -19,7 +19,7 @@
]);
const diagramType = $derived.by(() => {
const dt = validatedState.current.diagramType;
const dt = $stateStore.diagramType;
return dt ? standardizeDiagramType(dt) : undefined;
});
@@ -34,24 +34,23 @@
source: string;
}
const cycleIntervalMs = 30_000;
let currentActionIndex = $state(0);
let currentActionMedium = $state<EnhancedEditAction['medium'] | undefined>(undefined);
let previousDiagramType = $state<string | undefined>(undefined);
const availableActions = $derived.by<EnhancedEditAction[]>(() => {
if (!validatedState.current.diagramType) {
if (!$stateStore.diagramType) {
return [];
}
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) {
actions.unshift({
campaign: 'visual_1',
label: 'visually',
label: 'Visually',
medium: 'visual_edit',
source: 'visualEdit'
});
@@ -66,30 +65,30 @@
return undefined;
}
return actions[currentActionIndex % actions.length];
return actions.find(({ medium }) => medium === currentActionMedium) ?? actions[0];
});
$effect(() => {
const actionCount = availableActions.length;
const actions = availableActions;
if (actionCount === 0) {
currentActionIndex = 0;
if (!diagramType || actions.length === 0) {
return;
}
if (currentActionIndex >= actionCount) {
currentActionIndex = 0;
}
const hasCurrentAction = actions.some(({ medium }) => medium === currentActionMedium);
if (actionCount <= 1) {
if (diagramType !== previousDiagramType || !hasCurrentAction) {
currentActionMedium = actions[Math.floor(Math.random() * actions.length)].medium;
previousDiagramType = diagramType;
}
});
$effect(() => {
if (diagramType) {
return;
}
const intervalID = setInterval(() => {
currentActionIndex = (currentActionIndex + 1) % actionCount;
}, cycleIntervalMs);
return () => clearInterval(intervalID);
previousDiagramType = undefined;
});
</script>
@@ -98,7 +97,7 @@
<Button
variant="secondary"
size="sm"
href={urls.current.mermaidChart({
href={$urlsStore.mermaidChart({
medium: currentAction.medium,
campaign: currentAction.campaign
}).save}
@@ -1,5 +1,5 @@
<script lang="ts">
import { validatedState } from '$/util/state.svelte';
import { stateStore } from '$/util/state';
import * as Tooltip from '$lib/components/ui/tooltip';
import type { ComponentProps, Snippet } from 'svelte';
import ExternalLinkIcon from '~icons/material-symbols/open-in-new-rounded';
@@ -25,7 +25,7 @@
} = $props();
let shouldDisableComponent = $derived(
shouldCheckDiagramType && validatedState.current.diagramType === 'zenuml'
shouldCheckDiagramType && $stateStore.diagramType === 'zenuml'
);
</script>
+79 -76
View File
@@ -2,11 +2,12 @@
import Card from '$lib/components/Card/Card.svelte';
import type { HistoryEntry, HistoryType, State, Tab } from '$lib/types';
import { notify, prompt } from '$lib/util/notify';
import { serializeState } from '$lib/util/serde';
import { inputState, replaceInputState } from '$lib/util/state.svelte';
import { getStateString, inputStateStore } from '$lib/util/state';
import { logEvent } from '$lib/util/stats';
import dayjs from 'dayjs';
import dayjsRelativeTime from 'dayjs/plugin/relativeTime';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import BookmarkIcon from '~icons/material-symbols/bookmark-outline-rounded';
import TrashAltIcon from '~icons/material-symbols/delete-outline-rounded';
import DownloadIcon from '~icons/material-symbols/download-rounded';
@@ -15,51 +16,41 @@
import UploadIcon from '~icons/material-symbols/upload-rounded';
import HistoryIcon from '~icons/mdi/clock-outline';
import GitAltIcon from '~icons/mdi/git';
import OpenInNewIcon from '~icons/material-symbols/open-in-new-rounded';
import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import {
addManualEntry,
clearActive,
historyState,
removeEntry,
restoreEntries,
setMode
} from './historyState.svelte';
addHistoryEntry,
clearHistoryData,
getPreviousState,
historyModeStore,
historyStore,
loaderHistoryStore,
restoreHistory
} from './history';
dayjs.extend(dayjsRelativeTime);
const baseTabs: Tab[] = [
{ id: 'manual', title: 'Saved', icon: BookmarkIcon },
{ id: 'auto', title: 'Timeline', icon: HistoryIcon }
];
const loaderTab: Tab = { id: 'loader', title: 'Revisions', icon: GitAltIcon };
const tabs = $derived(
historyState.loaderEntries.length > 0 ? [loaderTab, ...baseTabs] : baseTabs
);
// Surface revisions once when they first appear; the user can switch away after.
let revisionsShown = false;
$effect(() => {
if (historyState.loaderEntries.length > 0 && !revisionsShown) {
revisionsShown = true;
setMode('loader');
}
});
const emptyMessage = $derived(
historyState.mode === 'auto'
? 'No timeline snapshots yet.\nThe Timeline is saved automatically every minute.'
: 'No saved states yet.\nClick the Save button to bookmark the current diagram and restore it later.'
);
const HISTORY_SAVE_INTERVAL = 60_000;
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 data = historyState.entries;
const data = get(historyStore);
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -67,7 +58,9 @@
a.download = `mermaid-history-${dayjs().format('YYYY-MM-DD-HHmmss')}.json`;
a.click();
URL.revokeObjectURL(url);
logEvent('history', { action: 'download' });
logEvent('history', {
action: 'download'
});
};
const uploadHistory = () => {
@@ -80,39 +73,59 @@
return;
}
const data: HistoryEntry[] = JSON.parse(await file.text());
const { restored, invalid, duplicates } = restoreEntries(data);
notify(`${restored} restored, ${duplicates} duplicate, ${invalid} invalid.`);
restoreHistory(data);
});
input.click();
};
const saveHistory = () => {
if (!addManualEntry($state.snapshot(inputState))) {
const saveHistory = (auto = false) => {
const currentState: string = getStateString();
const previousState: string = getPreviousState(auto);
if (previousState !== currentState) {
addHistoryEntry({
state: $inputStateStore,
time: Date.now(),
type: auto ? 'auto' : 'manual'
});
} else if (!auto) {
notify('State already saved.');
}
};
const clearAll = () => {
if (prompt('Clear all saved items?')) {
clearActive();
const clearHistory = (id?: string): void => {
if (!id && !prompt('Clear all saved items?')) {
return;
}
clearHistoryData(id);
};
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.
const entryUrl = (state: State): string =>
`${window.location.origin}${window.location.pathname}#${serializeState(state)}`;
onMount(() => {
historyModeStore.set('manual');
setInterval(() => {
saveHistory(true);
}, HISTORY_SAVE_INTERVAL);
});
// Serialize each entry's URL once per change rather than per row on every render.
const entriesWithUrl = $derived(
historyState.entries.map((entry) => ({ ...entry, openUrl: entryUrl(entry.state) }))
);
loaderHistoryStore.subscribe((entries) => {
if (entries.length > 0 && tabs.length === 2) {
tabs = [
{
id: 'loader',
title: 'Revisions',
icon: GitAltIcon
},
...tabs
];
historyModeStore.set('loader');
}
});
</script>
<Card onselect={tabSelectHandler} isOpen isClosable={false} {tabs} activeTabID={historyState.mode}>
<Card onselect={tabSelectHandler} isOpen isClosable={false} {tabs}>
{#snippet actions()}
<div class="flex items-center gap-2">
<Button
@@ -121,7 +134,7 @@
id="uploadHistory"
onclick={uploadHistory}
title="Upload history"><UploadIcon /></Button>
{#if historyState.entries.length > 0}
{#if $historyStore.length > 0}
<Button
id="downloadHistory"
size="icon"
@@ -134,22 +147,22 @@
id="saveHistory"
size="icon"
variant="ghost"
onclick={saveHistory}
onclick={() => saveHistory()}
title="Save current state"><SaveIcon /></Button>
{#if historyState.mode !== 'loader'}
{#if $historyModeStore !== 'loader'}
<Button
id="clearHistory"
size="icon"
variant="ghost"
class="hover:text-destructive"
onclick={clearAll}
onclick={() => clearHistory()}
title="Delete all saved states"><TrashAltIcon /></Button>
{/if}
</div>
{/snippet}
<ul class="flex h-full min-w-fit flex-col gap-2 overflow-auto p-2" id="historyList">
{#if entriesWithUrl.length > 0}
{#each entriesWithUrl as { id, state, time, name, url, type, openUrl } (id)}
{#if $historyStore.length > 0}
{#each $historyStore as { id, state, time, name, url, type } (id)}
<li class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<div class="flex flex-col">
@@ -171,20 +184,7 @@
<span class="text-sm whitespace-nowrap text-primary-foreground/50">
{dayjs(time).fromNow()}
</span>
<Button
href={openUrl}
target="_blank"
rel="noopener"
size="icon"
variant="ghost"
title="Open in new tab">
<OpenInNewIcon />
</Button>
<Button
size="icon"
variant="ghost"
title="Restore this version"
onclick={() => restoreHistoryItem(state)}>
<Button size="icon" variant="ghost" onclick={() => restoreHistoryItem(state)}>
<UndoIcon />
</Button>
{#if type !== 'loader'}
@@ -192,8 +192,7 @@
size="icon"
variant="ghost"
class="hover:text-destructive"
title="Delete this version"
onclick={() => removeEntry(id)}>
onclick={() => clearHistory(id)}>
<TrashAltIcon />
</Button>
{/if}
@@ -203,7 +202,11 @@
</li>
{/each}
{: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}
</ul>
</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 { Switch } from '$/components/ui/switch';
import { env } from '$/util/env';
import { urls } from '$/util/state.svelte';
import { urlsStore } from '$/util/state';
import { logMermaidChartClick } from '$/util/stats';
import { cn } from '$/utils';
import { mode, setMode } from 'mode-watcher';
@@ -28,14 +28,14 @@
sharesData?: boolean;
checkDiagramType?: boolean;
isSectionEnd?: boolean;
renderer: Snippet<[Omit<MenuItem, 'renderer'>]>;
renderer: (item: Omit<MenuItem, 'renderer'>) => ReturnType<Snippet>;
}
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 },
{
href: urls.current.mermaidChart({ medium: 'main_menu' }).playground,
href: $urlsStore.mermaidChart({ medium: 'main_menu' }).playground,
icon: PlaygroundIcon,
isSectionEnd: true,
label: 'Edit in Playground',
@@ -62,7 +62,7 @@
},
{
checkDiagramType: false,
href: urls.current.mermaidChart({ medium: 'main_menu' }).plugins,
href: $urlsStore.mermaidChart({ medium: 'main_menu' }).plugins,
icon: PluginIcon,
label: 'Plugins',
onclick: () => logMermaidChartClick('plugins'),
@@ -79,7 +79,7 @@
{
checkDiagramType: false,
class: 'text-accent border-b-0',
href: urls.current.mermaidChart({ medium: 'main_menu' }).home,
href: $urlsStore.mermaidChart({ medium: 'main_menu' }).home,
icon: MermaidChartIcon,
label: 'Mermaid',
onclick: () => logMermaidChartClick('mermaidHome'),
@@ -89,7 +89,7 @@
]);
</script>
{#snippet menuItem(options: Omit<MenuItem, 'renderer'>)}
{#snippet menuItem(options: MenuItem)}
<a
href={options.href}
target="_blank"
@@ -104,7 +104,7 @@
</a>
{/snippet}
{#snippet mcMenuItem(item: Omit<MenuItem, 'renderer'>)}
{#snippet mcMenuItem(item: MenuItem)}
<McWrapper
side="right"
labelPrefix={item.sharesData === false ? 'Opens a new tab in' : undefined}
@@ -114,7 +114,7 @@
</McWrapper>
{/snippet}
{#snippet darkModeMenuItem(options: Omit<MenuItem, 'renderer'>)}
{#snippet darkModeMenuItem(options: MenuItem)}
<div
class={cn(
'flex cursor-pointer items-center justify-between border-b-2 px-3 py-2 hover:bg-muted',
@@ -126,7 +126,7 @@
Dark Mode
</span>
<Switch
checked={mode.current === 'dark'}
checked={$mode === 'dark'}
onCheckedChange={(dark) => setMode(dark ? 'dark' : 'light')} />
</div>
{/snippet}
+39 -40
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import type { EditorProps } from '$/types';
import { validatedState } from '$/util/state.svelte';
import { stateStore } from '$/util/state';
import { json, jsonLanguage } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
import { yamlFrontmatter } from '@codemirror/lang-yaml';
@@ -15,22 +15,14 @@
let editorView: EditorView | undefined;
let editorContainer: HTMLDivElement;
// Deliberately not $state: the sync effect below both reads and writes it,
// 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();
let currentText = $state('');
const { onUpdate }: EditorProps = $props();
$effect(() => {
editorView?.dispatch({
effects: themeCompartment.reconfigure(mode.current === 'dark' ? vsCodeDark : vsCodeLight)
});
});
onMount(() => {
const themeCompartment = new Compartment();
const languageCompartment = new Compartment();
editorView = new EditorView({
state: EditorState.create({
doc: currentText,
@@ -64,37 +56,44 @@
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 () => {
unsubscribeMode();
unsubscribeState();
editorView?.destroy();
};
});
$effect(() => {
const { editorMode, code, mermaid } = validatedState.current;
const text = editorMode === 'code' ? code : mermaid;
if (currentText === text || !editorView) {
return;
}
currentText = text;
editorView.dispatch({
changes: {
from: 0,
to: editorView.state.doc.length,
insert: text
}
});
const stateLanguage = editorView.state.facet(language);
const isStateJson = stateLanguage === jsonLanguage;
const isCodeJson = editorMode === 'config';
if (stateLanguage && isStateJson === isCodeJson) {
return;
}
editorView.dispatch({
effects: languageCompartment.reconfigure(
isCodeJson ? json() : yamlFrontmatter({ content: markdown() })
)
});
});
</script>
<div bind:this={editorContainer} class="size-full"></div>
+4 -5
View File
@@ -8,12 +8,11 @@
</script>
<script lang="ts">
import { resolve } from '$app/paths';
import MainMenu from '$/components/MainMenu.svelte';
import { Button } from '$/components/ui/button';
import { Separator } from '$/components/ui/separator';
import { dismissPromotion, getActivePromotion } from '$lib/util/promos/promo.svelte';
import { untrack, type ComponentProps, type Snippet } from 'svelte';
import { dismissPromotion, getActivePromotion } from '$lib/util/promos/promo';
import type { ComponentProps, Snippet } from 'svelte';
import MermaidIcon from '~icons/custom/mermaid';
import CloseIcon from '~icons/material-symbols/close-rounded';
import GithubIcon from '~icons/mdi/github';
@@ -41,7 +40,7 @@
}
];
let activePromotion = $state(untrack(() => (hidePromotion ? undefined : getActivePromotion())));
let activePromotion = $state(hidePromotion ? undefined : getActivePromotion());
const trackBannerClick = () => {
if (!activePromotion) {
@@ -84,7 +83,7 @@
<div class="flex flex-1 items-center gap-2">
<MainMenu />
<MermaidIcon class="size-6" />
<a href={resolve('/', {})} class="whitespace-nowrap text-accent">
<a href="/" class="whitespace-nowrap text-accent">
{#if !mobileToggle}
Mermaid
{/if}
+2 -2
View File
@@ -3,7 +3,7 @@
import { Button } from '$/components/ui/button';
import { Separator } from '$/components/ui/separator';
import type { PanZoomState } from '$/util/panZoom';
import { urls } from '$/util/state.svelte';
import { urlsStore } from '$/util/state';
import ExpandIcon from '~icons/material-symbols/open-in-full-rounded';
import ArrowsToCircleIcon from '~icons/material-symbols/screenshot-frame-2';
import MagnifyingGlassPlusIcon from '~icons/material-symbols/zoom-in';
@@ -28,7 +28,7 @@
<MagnifyingGlassPlusIcon />
</Button>
<Separator orientation="vertical" class="hidden sm:block" />
<Button variant="ghost" size="icon" title="Full Screen" href={urls.current.view} target="_blank">
<Button variant="ghost" size="icon" title="Full Screen" href={$urlsStore.view} target="_blank">
<ExpandIcon />
</Button>
</FloatingToolbar>
+17 -54
View File
@@ -1,20 +1,13 @@
<script lang="ts">
import Card from '$/components/Card/Card.svelte';
import { Button, buttonVariants } from '$/components/ui/button';
import * as Popover from '$/components/ui/popover';
import { getSampleDiagrams, type SampleExample } from '$/util/mermaid';
import { updateCode } from '$lib/util/state.svelte';
import { Button } from '$/components/ui/button';
import { getSampleDiagrams } from '$/util/mermaid';
import { updateCode } from '$lib/util/state';
import { logEvent } from '$lib/util/stats';
import { cn } from '$lib/utils';
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[]> = {
ZenUML: [
{
title: 'Order Service',
isDefault: true,
code: `zenuml
const extras = {
ZenUML: `zenuml
title Order Service
@Actor Client #FFEBE6
@Boundary OrderController #0747A6
@@ -32,24 +25,21 @@
if(order != null) {
par {
PurchaseService.createPO(order)
InvoiceService.createInvoice(order)
}
InvoiceService.createInvoice(order)
}
}
}
}
`
}
]
};
const samples = { ...getSampleDiagrams(), ...extras };
const loadSampleDiagram = (diagramType: string, example: SampleExample): void => {
updateCode(example.code, {
const samples = { ...getSampleDiagrams(), ...extras } as const;
const loadSampleDiagram = (diagramType: string): void => {
updateCode(samples[diagramType], {
resetPanZoom: true,
updateDiagram: true
});
logEvent('loadSampleDiagram', { diagramType, exampleTitle: example.title });
logEvent('loadSampleDiagram', { diagramType });
};
const mainDiagrams = [
@@ -72,39 +62,12 @@
<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">
{#each diagramOrder as sample (sample)}
{@const examples = samples[sample]}
<div class="flex min-w-20 flex-grow">
<Button
size="sm"
class={cn('flex-grow normal-case', examples.length > 1 && 'rounded-r-none')}
onclick={() => loadSampleDiagram(sample, examples[0])}>
{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>
<Button
size="sm"
class="w-fit min-w-20 flex-grow normal-case"
onclick={() => loadSampleDiagram(sample)}>
{sample}
</Button>
{/each}
</div>
</Card>
+1 -1
View File
@@ -1,4 +1,4 @@
<script lang="ts">
<script>
import ExternalLinkWrapper from '$/components/ExternalLinkWrapper.svelte';
import * as Dialog from '$/components/ui/dialog';
import { env } from '$/util/env';
+3 -3
View File
@@ -1,9 +1,9 @@
<script lang="ts">
<script>
import { buttonVariants } from '$/components/ui/button';
import * as Dialog from '$/components/ui/dialog';
import { Separator } from '$/components/ui/separator';
import { env } from '$/util/env';
import { urls } from '$/util/state.svelte';
import { urlsStore } from '$/util/state';
import { asset } from '$app/paths';
import ShareIcon from '~icons/material-symbols/share';
import CopyInput from './CopyInput.svelte';
@@ -38,7 +38,7 @@
<MermaidChartIcon class="size-5" />
Mermaid Chart Playground
</h2>
<CopyInput value={urls.current.mermaidChart({ medium: 'share' }).playground} />
<CopyInput value={$urlsStore.mermaidChart({ medium: 'share' }).playground} />
<Dialog.Description>
Opens the Mermaid Chart Playground with Mermaid AI, Visual Editor, and more.
</Dialog.Description>
+5 -11
View File
@@ -1,27 +1,21 @@
<script lang="ts">
import FloatingToolbar from '$/components/FloatingToolbar.svelte';
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 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
updateCodeStore({ grid: defaultState.grid });
$inputStateStore.grid = defaultState.grid;
}
</script>
<FloatingToolbar>
<Toggle
bind:pressed={() => inputState.rough, (rough) => updateCodeStore({ rough })}
size="sm"
title="Hand-Drawn">
<Toggle bind:pressed={$inputStateStore.rough} size="sm" title="Hand-Drawn">
<RoughIcon />
</Toggle>
<Toggle
bind:pressed={() => inputState.grid ?? defaultState.grid, (grid) => updateCodeStore({ grid })}
size="sm"
title="Background Grid">
<Toggle bind:pressed={$inputStateStore.grid} size="sm" title="Background Grid">
<BackgroundIcon />
</Toggle>
</FloatingToolbar>
+2 -2
View File
@@ -24,12 +24,12 @@
</script>
<div class="inline-grid">
{#key mode.current}
{#key $mode}
<div
in:spin={{ clockWise: true }}
out:spin={{ clockWise: false }}
class="col-start-1 row-start-1">
{#if mode.current === 'dark'}
{#if $mode === 'dark'}
<MoonIcon />
{:else}
<SunIcon />
@@ -23,9 +23,9 @@
variant="ghost"
size="icon"
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"
onclick={() => setMode(mode.current === 'dark' ? 'light' : 'dark')}>
onclick={() => setMode($mode === 'dark' ? 'light' : 'dark')}>
<ThemeIcon />
</Button>
</FloatingToolbar>
+9 -11
View File
@@ -3,7 +3,7 @@
import { recordRenderTime, shouldRefreshView } from '$/util/autoSync';
import { render as renderDiagram } from '$/util/mermaid';
import { PanZoomState } from '$/util/panZoom';
import { updateCodeStore, validatedState } from '$/util/state.svelte';
import { inputStateStore, stateStore, updateCodeStore } from '$/util/state';
import { saveStatistics } from '$/util/stats';
import FontAwesome, { mayContainFontAwesome } from '$lib/components/FontAwesome.svelte';
import uniqueID from 'lodash-es/uniqueId';
@@ -133,20 +133,18 @@
const renderTime = Date.now() - startTime;
saveStatistics({ code, diagramType, isRough: state.rough, renderTime });
recordRenderTime(renderTime, () => {
updateCodeStore({ updateDiagram: true });
$inputStateStore.updateDiagram = true;
});
};
onMount(() => {
setupPanZoomObserver();
});
// Queue state changes to avoid race condition
let pendingStateChange = Promise.resolve();
$effect(() => {
const state = validatedState.current;
// eslint-disable-next-line @typescript-eslint/no-empty-function
pendingStateChange = pendingStateChange.then(() => handleStateChange(state).catch(() => {}));
// Queue state changes to avoid race condition
let pendingStateChange = Promise.resolve();
stateStore.subscribe((state) => {
// eslint-disable-next-line @typescript-eslint/no-empty-function
pendingStateChange = pendingStateChange.then(() => handleStateChange(state).catch(() => {}));
});
});
</script>
@@ -155,7 +153,7 @@
<div
id="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>
@@ -1,101 +1,164 @@
<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 * as Dialog from '$/components/ui/dialog';
import { dismissEditorChooser } from '$/util/migration/domainMigration';
import type { Component } from 'svelte';
import AtlassianIcon from '~icons/logos/atlassian';
import AmazonIcon from '~icons/logos/aws';
import GoogleIcon from '~icons/logos/google';
import MicrosoftIcon from '~icons/logos/microsoft';
import { createEditorChooserActions } from './editorChooserActions';
import { logEvent, logMermaidChartClick } from '$/util/stats';
import { getCheckoutUrl } from '$/util/util';
import CodeIcon from '~icons/custom/code';
import ArrowIcon from '~icons/material-symbols/arrow-forward-rounded';
import OpenSourceIcon from '~icons/material-symbols/book-2-outline-rounded';
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 {
open: boolean;
}
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).
let handled = false;
const actions = createEditorChooserActions(() => {
handled = true;
const close = () => {
dismissEditorChooser();
open = false;
});
};
const features = [
{ title: 'AI diagram generation', description: 'Describe what you need, AI builds it' },
{
title: 'Visual drag-and-drop editor',
description: 'Edit diagrams without writing code'
},
{
title: 'Unlimited diagram storage',
description: 'Save and organize all your diagrams'
},
{
title: 'Team collaboration',
description: 'Share, comment, and edit together in real-time'
}
];
const handleStartTrial = () => {
logEvent('chooseEditor', { choice: 'plus' });
logMermaidChartClick('editorPicker');
close();
window.open(
getCheckoutUrl({ utmCampaign: 'start_plus', utmMedium: '2_editor_selection' }),
'_blank'
);
};
const trustedLogos: { name: string; icon: Component }[] = [
{ name: 'Google', icon: GoogleIcon },
{ name: 'Microsoft', icon: MicrosoftIcon },
{ name: 'Atlassian', icon: AtlassianIcon },
{ name: 'Amazon', icon: AmazonIcon }
];
const handleStartFree = () => {
logEvent('chooseEditor', { choice: 'openSource' });
close();
};
const handleContinueToNewHome = () => {
logEvent('chooseEditor', { choice: 'newHome' });
close();
window.open('https://mermaid.ai/live', '_blank');
};
</script>
<Dialog.Root
bind:open
onOpenChange={(v) => {
if (v) return;
if (!handled) actions.log('dismissed');
handled = false;
dismissEditorChooser();
if (!v) handleStartFree();
}}>
<Dialog.Content class="flex max-w-lg flex-col gap-3 bg-background p-8">
<Dialog.Header class="flex-col items-start gap-2 space-y-0 text-left sm:text-left">
<MermaidChartIcon class="size-10" />
<Dialog.Title class="pt-2 text-2xl font-bold">Try the full Mermaid experience</Dialog.Title>
<Dialog.Description class="text-xs font-light text-muted-foreground">
Free forever, with Plus features free for 7 days.
<Dialog.Content class="flex max-w-2xl flex-col gap-6 bg-pink-50 p-6 dark:bg-background">
<Dialog.Header>
<Dialog.Title class="text-center text-2xl font-semibold">Choose your editor</Dialog.Title>
<Dialog.Description class="text-center text-sm font-light">
You'll never see this again
</Dialog.Description>
</Dialog.Header>
<ul class="mt-2 flex flex-col gap-3">
{#each features as feature (feature.title)}
<li class="flex items-center gap-3 rounded-lg border border-border p-3">
<span class="size-2 shrink-0 rounded-full bg-accent"></span>
<div class="flex flex-col gap-0.5">
<p class="text-xs">{feature.title}</p>
<p class="text-xs font-light text-muted-foreground">{feature.description}</p>
<div class="grid gap-4 sm:grid-cols-2">
<!-- Mermaid Plus Card -->
<div
class="relative flex flex-col overflow-hidden rounded-xl border-2 border-accent bg-white shadow dark:bg-card">
<div class="bg-accent px-6 py-2 text-center text-sm font-semibold text-accent-foreground">
Recommended
</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>
</li>
{/each}
</ul>
<div class="mt-2 flex items-center gap-3">
<McWrapper labelPrefix="Opens ">
<Button variant="accent" onclick={() => actions.startTrial()}>Start free trial</Button>
</McWrapper>
<Button variant="outline" onclick={() => actions.dismiss('stayOnLive')}>
Stay on mermaid.live
</Button>
</div>
<div class="flex flex-col gap-2">
<div class="flex justify-center">
<span
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">
10% off with code JS26
</span>
</div>
<div class="mt-3 flex flex-col items-start gap-4">
<p class="text-xs">Trusted by 5M people and over 200k companies</p>
<div class="flex w-full items-center justify-between gap-2">
{#each trustedLogos as logo (logo.name)}
<logo.icon class="h-6 w-auto grayscale" aria-label={logo.name} />
{/each}
<Button variant="accent" class="w-full" onclick={handleStartTrial}>
Start free trial
</Button>
</div>
<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>
<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.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>
<Sonner
theme={mode.current}
theme={$mode}
class="toaster group"
toastOptions={{
classes: {
@@ -24,12 +24,8 @@
}: ToggleGroupPrimitive.RootProps & ToggleVariants = $props();
setToggleGroupCtx({
get variant() {
return variant;
},
get size() {
return size;
}
variant,
size
});
</script>
+11 -13
View File
@@ -1,7 +1,7 @@
/* 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 { defaultState } from '$lib/util/state.svelte';
import { defaultState } from '$lib/util/state';
import { fetchJSON, fetchText } from '$lib/util/util';
const codeFileName = 'code.mmd';
@@ -117,16 +117,14 @@ export const loadGistData = async (gistURL: string): Promise<State> => {
throw new Error('Invalid gist provided');
}
const state = getStateFromGist(entry, gistURL);
setLoaderEntries(
gistHistory
.map((gist) => ({
name: `${gist.author} v${gist.version}`,
state: getStateFromGist(gist),
time: gist.time,
type: 'loader' as const,
url: gist.url
}))
.reverse()
);
for (const gist of gistHistory) {
addHistoryEntry({
name: `${gist.author} v${gist.version}`,
state: getStateFromGist(gist),
time: gist.time,
type: 'loader',
url: gist.url
});
}
return state;
};
+1 -2
View File
@@ -1,5 +1,5 @@
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 { loadGistData } from './gist';
@@ -50,7 +50,6 @@ export const loadDataFromUrl = async (): Promise<void> => {
}
}
if (loaded) {
state.mermaid = sanitizeConfig(state.mermaid || defaultState.mermaid);
updateCodeStore({
...state,
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);
};
/**
* @see https://mermaid.js.org/config/schema-docs/config.html
*/
export const defaultMermaidConfig = mermaid.mermaidAPI.defaultConfig ?? {};
export const standardizeDiagramType = (diagramType: string) => {
switch (diagramType) {
case 'class':
@@ -49,20 +44,20 @@ export const standardizeDiagramType = (diagramType: string) => {
type DiagramDefinition = (typeof diagramData)[number];
export type SampleExample = DiagramDefinition['examples'][number];
const isValidDiagram = (diagram: DiagramDefinition): diagram is Required<DiagramDefinition> => {
return Boolean(diagram.name && diagram.examples && diagram.examples.length > 0);
};
export const getSampleDiagrams = (): Record<string, SampleExample[]> => {
const samples: Record<string, SampleExample[]> = {};
for (const diagram of diagramData.filter((d) => isValidDiagram(d))) {
// The default example comes first, so it is loaded when clicking the
// diagram name and shown at the top of the example dropdown.
samples[diagram.name.replace(/ (Diagram|Chart|Graph)/, '')] = [...diagram.examples].sort(
(a, b) => Number(b.isDefault ?? false) - Number(a.isDefault ?? false)
);
export const getSampleDiagrams = () => {
const diagrams = diagramData
.filter((d) => isValidDiagram(d))
.map(({ examples, ...rest }) => ({
...rest,
example: examples?.filter(({ isDefault }) => isDefault)[0]
}));
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 mermaidLiveDomain = 'mermaid.live';
const netlifyPreviewDomain = 'netlify.app';
/**
* Check if we're on mermaid.ai
@@ -21,13 +20,6 @@ export const isOnMermaidLive = (): boolean => {
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).
*/
@@ -63,15 +55,11 @@ const isReferredFromMermaid = (): boolean => {
* 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.
* 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 => {
if (!env.isEnabledMermaidChartLinks) return false;
if (!isOnMermaidAI() && !isOnMermaidLive()) 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 (hasPakoData()) return false;
if (isReferredFromMermaid()) return false;
+1 -1
View File
@@ -14,7 +14,7 @@ describe('migrations', () => {
});
it('should migrate from v0 to v1', async () => {
const { applyMigrations } = await import('./migrations.svelte');
const { applyMigrations } = await import('./migrations');
let manualHistoryStore: HistoryEntry[] = JSON.parse(
window.localStorage.getItem('manualHistoryStore') ?? '[]'
) as HistoryEntry[];
@@ -1,5 +1,6 @@
import { injectHistoryIDs } from '$lib/components/History/historyState.svelte';
import { persisted } from '$lib/util/persist.svelte';
import { writable, get, type Writable } from 'svelte/store';
import { persist, localStorage } from '$lib/util/persist';
import { injectHistoryIDs } from '$lib/components/History/history';
import { logEvent } from './stats';
interface MigrationState {
@@ -10,10 +11,14 @@ const migrations: Record<string, () => void> = {
injectHistoryIDs
};
const migrationState = persisted<MigrationState>('migrations', { version: -1 });
const migrationStore: Writable<MigrationState> = persist(
writable({ version: -1 }),
localStorage(),
'migrations'
);
export const applyMigrations = (): void => {
const { version } = migrationState.value;
const { version }: MigrationState = get(migrationStore);
const allMigrations = Object.entries(migrations);
if (version === allMigrations.length - 1) {
return;
@@ -24,7 +29,7 @@ export const applyMigrations = (): void => {
console.log(`Applying migration ${i}: ${key}.`);
fn();
logEvent('migration', { key });
migrationState.value = { version: i };
migrationStore.set({ version: i });
}
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(() => {
clearInterval(interval);
});
const taglineHref = $derived(
`${MCBaseURL}${currentTagline.url.path}?${new URLSearchParams({
...commonParams,
...currentTagline.url.params
}).toString()}`
);
</script>
<div
@@ -77,9 +70,11 @@
<div class="grid grow">
{#key currentTagline}
<a
href={taglineHref}
href="{MCBaseURL}{currentTagline.url.path}?{new URLSearchParams({
...commonParams,
...currentTagline.url.params
}).toString()}"
target="_blank"
rel="noopener noreferrer"
class="col-start-1 row-start-1 flex items-center justify-center gap-4 no-underline"
in:fade={{ delay: 800 }}
out:fade={{ duration: 1000 }}>
@@ -1,8 +1,9 @@
import { env } from '$lib/util/env';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import type { Component, Snippet } from 'svelte';
import { persisted } from '../persist.svelte';
import type { Component } from 'svelte';
import { get, writable, type Writable } from 'svelte/store';
import { localStorage, persist } from '../persist';
import April2025 from './April2025.svelte';
import JS2026 from './JS2026.svelte';
@@ -11,7 +12,7 @@ dayjs.extend(duration);
interface Promotion {
startDate: Date;
endDate: Date;
component: Component<{ closeBanner: Snippet }>;
component: Component;
hideDurationMs: number;
}
@@ -34,21 +35,25 @@ export const dismissPromotion = (id?: string): void => {
if (!id || !promotions[id]) {
return;
}
hiddenPromotions.value = {
...hiddenPromotions.value,
[id]: dayjs().add(promotions[id].hideDurationMs).valueOf()
};
hiddenPromotionsStore.update((dismissedIDs) => {
dismissedIDs[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 => {
if (!env.isEnabledMermaidChartLinks) {
return;
}
const hidePromotionsUntil = hiddenPromotions.value;
const now = dayjs();
const hidePromotionsUntil = get(hiddenPromotionsStore);
const now = new Date();
const promotionWithID = Object.entries(promotions)
.filter(
([id, p]) =>
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { serializeState, deserializeState, type SerdeType } from './serde';
import { defaultState } from './state.svelte';
import { defaultState } from './state';
import type { State } from '$lib/types';
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,
data?: Record<string, string | number | boolean>
): void => {
if (browser && window.location.hostname === 'localhost') {
console.log('[plausible]', name, data);
}
if (!plausible) {
return;
}
+8 -15
View File
@@ -1,10 +1,10 @@
import { C } from '$/constants';
import { env } from './env';
import { loadDataFromUrl } from './fileLoaders/loader';
import { initLoading } from './loading.svelte';
import { initLoading } from './loading';
import { isOnMermaidAI } from './migration/domainMigration';
import { applyMigrations } from './migrations.svelte';
import { initURLSubscription, loadState, updateCodeStore, verifyState } from './state.svelte';
import { applyMigrations } from './migrations';
import { initURLSubscription, loadState, updateCodeStore, verifyState } from './state';
import { getAnalyticsSafeUrl, initAnalytics, plausible } from './stats';
export const getDomain = (url?: string): string => {
@@ -42,30 +42,23 @@ export const MCBaseURL = env.isEnabledMermaidChartLinks
? 'https://mermaid.ai' // 'http://localhost:5174'
: 'https://example.com';
const buildUtmParams = ({
export const getCheckoutUrl = ({
utmCampaign,
utmMedium
}: {
utmCampaign: string;
utmMedium: string;
}): URLSearchParams =>
new URLSearchParams({
}): string => {
const params = new URLSearchParams({
coupon: 'arDfyFT8',
tier: 'plus',
utm_campaign: utmCampaign,
utm_medium: utmMedium,
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()}`;
};
export const getMermaidAiLiveUrl = (utm: { utmCampaign: string; utmMedium: string }): string => {
return `${MCBaseURL}/live?${buildUtmParams(utm).toString()}`;
};
let count = 0;
export const errorDebug = (limit = 1000) => {
count += 1;
+7 -8
View File
@@ -1,21 +1,20 @@
<script>
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { page } from '$app/stores';
import { onMount } from 'svelte';
// Only redirect if it's a 404 error
onMount(() => {
if (page.status === 404) {
goto(resolve('/'));
if ($page.status === 404) {
goto('/');
}
});
</script>
{#if page.status !== 404}
{#if $page.status !== 404}
<div class="container mx-auto p-8">
<h1 class="mb-4 text-2xl font-bold">Error {page.status}</h1>
<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>
<h1 class="mb-4 text-2xl font-bold">Error {$page.status}</h1>
<p class="mb-4">{$page.error?.message || 'An unexpected error occurred'}</p>
<a href="/" class="text-blue-500 hover:underline">Return to Home</a>
</div>
{/if}
+5 -5
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { Toaster } from '$/components/ui/sonner/index.js';
import { loadingState } from '$/util/loading.svelte';
import { toggleDarkTheme } from '$/util/state.svelte';
import { loadingStateStore } from '$/util/loading';
import { toggleDarkTheme } from '$/util/state';
import { initHandler } from '$/util/util';
import { base } from '$app/paths';
import { mode, ModeWatcher } from 'mode-watcher';
@@ -34,7 +34,7 @@
});
$effect(() => {
toggleDarkTheme(mode.current === 'dark');
toggleDarkTheme($mode === 'dark');
});
</script>
@@ -45,12 +45,12 @@
{@render children()}
</main>
{#if loadingState.loading}
{#if $loadingStateStore.loading}
<div
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="loader mx-auto"></div>
<div>{loadingState.message}</div>
<div>{$loadingStateStore.message}</div>
</div>
</div>
{/if}
+5 -9
View File
@@ -5,7 +5,6 @@
import Editor from '$/components/Editor.svelte';
import EnhancedEditsButton from '$/components/EnhancedEditsButton.svelte';
import History from '$/components/History/History.svelte';
import { startAutoSave } from '$/components/History/historyState.svelte';
import McWrapper from '$/components/McWrapper.svelte';
import MermaidChartIcon from '$/components/MermaidChartIcon.svelte';
import EditorChooserModal from '$/components/migration/EditorChooserModal.svelte';
@@ -23,7 +22,7 @@
import type { EditorMode, Tab } from '$/types';
import { shouldShowEditorChooser } from '$/util/migration/domainMigration';
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 { initHandler } from '$/util/util';
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 editorPane: Resizable.Pane | undefined;
@@ -91,7 +87,7 @@
{/snippet}
<Navbar mobileToggle={isMobile ? mobileToggle : undefined}>
<Toggle bind:pressed={isHistoryOpen} size="sm" title="History" aria-label="History">
<Toggle bind:pressed={isHistoryOpen} size="sm">
<HistoryIcon />
</Toggle>
<Share />
@@ -99,7 +95,7 @@
<Button
variant="accent"
size="sm"
href={urls.current.mermaidChart({ medium: 'save_diagram' }).save}
href={$urlsStore.mermaidChart({ medium: 'save_diagram' }).save}
target="_blank"
onclick={() => logMermaidChartClick('saveDiagram')}>
<MermaidChartIcon />
@@ -124,7 +120,7 @@
onselect={tabSelectHandler}
isOpen
tabs={editorTabs}
activeTabID={validatedState.current.editorMode}
activeTabID={$stateStore.editorMode}
isClosable={false}>
{#snippet actions()}
<DiagramDocButton />
@@ -140,7 +136,7 @@
</Resizable.Pane>
<Resizable.Handle class="mr-1 hidden opacity-0 sm:block" />
<Resizable.Pane minSize={15} class="relative flex h-full flex-1 flex-col overflow-hidden">
<View {panZoomState} shouldShowGrid={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 right-0"><PanZoomToolbar {panZoomState} /></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}';
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.describe.skip('Save History', () => {
test.beforeEach(async ({ page }) => {
// Freeze time so auto-save snapshots are deterministic.
await page.addInitScript(() => {
Object.defineProperty(Date, 'now', { value: () => new Date(2022, 0, 1).getTime() });
Object.defineProperty(Date, 'now', {
value: () => new Date(2022, 0, 1).getTime()
});
});
await page.goto('/edit');
await page.getByText('History').click();
});
test('loads Saved and Timeline history from localStorage and restores entries', async ({
page
}) => {
await page.evaluate(
([manual, auto]) => {
localStorage.setItem('manualHistoryStore', manual);
localStorage.setItem('autoHistoryStore', auto);
},
[JSON.stringify(manualHistory), JSON.stringify(autoHistory)]
);
test('should load history from localstorage', async ({ page }) => {
await page.evaluate(() => {
localStorage.setItem(
'manualHistoryStore',
'[{"state":{"code":"graph TD\\n A[Halloween] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":false},"time":0,"type":"manual","id":"d7ea820e-21dd-418a-b984-fd58acde09df","name":"hollow-art"},{"state":{"code":"graph TD\\n A[Christmas] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":true},"time":0,"type":"manual","id":"b749ffc6-522b-4a44-86cf-7c1ffc3146b3","name":"helpful-ocean"}]'
);
localStorage.setItem(
'autoHistoryStore',
'[{"state":{"code":"graph TD\\n A[New Year] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":false},"time":0,"type":"auto","id":"69ea820e-522b-4a44-86cf-fd58acde09df","name":"barking-dog"},{"state":{"code":"graph TD\\n A[Christmas] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":true},"time":0,"type":"manual","id":"x749ffc6-21dd-418a-b984-7c1ffc3146b3","name":"needy-mosquito"}]'
);
});
await page.reload();
await openHistory(page);
// Saved tab is active by default.
await page.getByText('History').click();
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('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')).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')).not.toContainText('hollow-art');
await page.getByRole('button', { name: 'Restore this version' }).first().click();
await expect(page.locator('#view')).toContainText('NewYear');
await expect(page.locator('#historyList')).toContainText('barking-dog');
await page.getByText('Restore').first().click();
await expect(page.locator('#view').getByText('New Year')).toBeVisible();
});
test('each entry has a copyable link that opens it in a new tab', async ({ page }) => {
await page.evaluate(
(manual) => localStorage.setItem('manualHistoryStore', manual),
JSON.stringify(manualHistory)
);
await page.reload();
await openHistory(page);
// 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);
test.skip('should save when clicked', async ({ page }) => {
await expect(page.locator('#historyList li')).toHaveCount(0);
await expect(page.locator('#historyList')).toContainText('No items in History');
await page.locator('#saveHistory').click();
await expect(page.locator('#historyList').getByText('No items in History')).not.toBeVisible();
await expect(page.locator('#historyList li')).toHaveCount(1);
// Saving again without changes does not add a duplicate and notifies the user.
const dialogPromise = page.waitForEvent('dialog');
await page.locator('#saveHistory').click();
await expect(page.getByText('State already saved.')).toBeVisible();
await expect(page.locator('#historyList li')).toHaveCount(1);
const dialog = await dialogPromise;
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 page.getByRole('button', { name: 'Sequence', exact: true }).click();
await expect(page.locator('#view')).not.toContainText('Christmas');
await typeInEditor(page, ' C --> HistoryTest');
await page.locator('#saveHistory').click();
await expect(page.locator('#historyList li')).toHaveCount(2);
});
test('auto-saves to the Timeline only, never the Saved list', async ({ page }) => {
await openHistory(page);
test.skip('should be able to restore and delete', async ({ page }) => {
await page.locator('#saveHistory').click();
await typeInEditor(page, ' C --> HistoryTest');
await expect(page.locator('#historyList').getByText('No items in History')).not.toBeVisible();
await expect(page.locator('#historyList li')).toHaveCount(1);
await page.getByRole('tab', { name: 'Timeline' }).click();
// A manual save must not appear under Timeline.
await expect(page.locator('#historyList')).toContainText('No timeline snapshots yet.');
});
test('deletes a single entry and clears all after confirmation', async ({ page }) => {
await openHistory(page);
await expect(page.locator('#view').getByText('HistoryTest')).toBeVisible();
await page.getByText('Restore').click();
await expect(page.locator('#view').getByText('HistoryTest')).not.toBeVisible();
await page.getByText('Delete').click();
await expect(page.locator('#historyList li')).toHaveCount(0);
await expect(page.locator('#historyList')).toContainText('No items in History');
await page.locator('#saveHistory').click();
await page.getByRole('button', { name: 'Sequence', exact: true }).click();
await expect(page.locator('#view')).not.toContainText('Christmas');
await typeInEditor(page, ' C --> HistoryTest');
await page.locator('#saveHistory').click();
await expect(page.locator('#historyList li')).toHaveCount(2);
await page.getByRole('button', { name: 'Delete this version' }).first().click();
await expect(page.locator('#historyList li')).toHaveCount(1);
page.on('dialog', (dialog) => dialog.accept());
await page.locator('#editor').type('ing');
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';
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 }) => {
await editPage.start('/#/edit/eyJjb2RlIjoiZ3JhcGggVERcbiAg');
await page.reload({ waitUntil: 'networkidle' });
-1
View File
@@ -12,7 +12,6 @@
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"strictNullChecks": true,
"skipLibCheck": true,
"types": ["vitest/importMeta", "@playwright/test"]
},
"extends": "./.svelte-kit/tsconfig.json"
-2
View File
@@ -33,8 +33,6 @@ export default defineConfig({
envPrefix: 'MERMAID_',
server: { 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: {
environment: 'jsdom',
// in-source testing