Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1fd4dc244 | ||
|
|
9436f8ddda |
@@ -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'
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -39,8 +39,5 @@ jobs:
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Type check
|
||||
run: pnpm check
|
||||
|
||||
- name: Run unit tests
|
||||
run: pnpm test:unit
|
||||
|
||||
@@ -15,4 +15,3 @@
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
.playwright-mcp/
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
24.16.0
|
||||
22.15.0
|
||||
|
||||
Vendored
-3
@@ -12,14 +12,11 @@
|
||||
"fsegurai",
|
||||
"gantt",
|
||||
"gitgraph",
|
||||
"hugeicons",
|
||||
"KROKI",
|
||||
"localstorage",
|
||||
"mermaidchart",
|
||||
"mindmap",
|
||||
"NEWYEAR",
|
||||
"noopener",
|
||||
"noreferrer",
|
||||
"Pageview",
|
||||
"pako",
|
||||
"panmove",
|
||||
|
||||
+1
-1
@@ -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
@@ -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",
|
||||
|
||||
Generated
+3675
-2917
File diff suppressed because it is too large
Load Diff
+1
-2
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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,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 => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 ? `[](${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 }));
|
||||
};
|
||||
@@ -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
|
||||
? `[](${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);
|
||||
};
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,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
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strictNullChecks": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["vitest/importMeta", "@playwright/test"]
|
||||
},
|
||||
"extends": "./.svelte-kit/tsconfig.json"
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user