Compare commits
87
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
335f4bbfde | ||
|
|
bc235e7498 | ||
|
|
7f08e5b970 | ||
|
|
a257f78d8e | ||
|
|
9cd92e1fd6 | ||
|
|
c253054916 | ||
|
|
d2f067a540 | ||
|
|
59105628f4 | ||
|
|
144b4dc190 | ||
|
|
6a9a306ed7 | ||
|
|
337e9f86ac | ||
|
|
c9cfaa10e7 | ||
|
|
ed06c07c06 | ||
|
|
a49503a1f0 | ||
|
|
81aaafc085 | ||
|
|
b3c8bd6c28 | ||
|
|
ca96ca8771 | ||
|
|
219d8af74f | ||
|
|
49c5928202 | ||
|
|
459c22cf09 | ||
|
|
f25b3c82cd | ||
|
|
2db9355b7b | ||
|
|
8dd9cb49a0 | ||
|
|
64e8822e32 | ||
|
|
a3a3f8ffbe | ||
|
|
e6333e9d7f | ||
|
|
3628acec02 | ||
|
|
ebced635c9 | ||
|
|
1f0812496e | ||
|
|
c62ebf6d09 | ||
|
|
6438d0074d | ||
|
|
130711a9d0 | ||
|
|
7c6e28e163 | ||
|
|
1b432bcaaa | ||
|
|
da89994440 | ||
|
|
c25675d418 | ||
|
|
a58cde9516 | ||
|
|
ccf5a13018 | ||
|
|
4ab4e022a0 | ||
|
|
52abdccc3a | ||
|
|
d134306482 | ||
|
|
d040d8d84a | ||
|
|
c2cd0c0bfc | ||
|
|
2ff56f67bc | ||
|
|
55d9bd043f | ||
|
|
adf3ddf015 | ||
|
|
bfea7dd4db | ||
|
|
7cd24ddcb3 | ||
|
|
241f99bacf | ||
|
|
472a3283c8 | ||
|
|
7b43e33971 | ||
|
|
6af70cb44c | ||
|
|
1fabea49cf | ||
|
|
1bd6355ea8 | ||
|
|
29eb7b01fc | ||
|
|
b446c37315 | ||
|
|
031477cda9 | ||
|
|
a9becf5509 | ||
|
|
79c4b3bf55 | ||
|
|
a61a69e477 | ||
|
|
1daac45c87 | ||
|
|
e3562168ad | ||
|
|
7fa7a48325 | ||
|
|
4925de94a9 | ||
|
|
679e444bf1 | ||
|
|
46a7a533ab | ||
|
|
67aacdebe4 | ||
|
|
7e9cdfca73 | ||
|
|
48b9560e8e | ||
|
|
f9f1e27bc3 | ||
|
|
62699769d5 | ||
|
|
5608dc5661 | ||
|
|
29e92a9702 | ||
|
|
0e0da3b1e9 | ||
|
|
d273e1d722 | ||
|
|
c799dff042 | ||
|
|
ae7ad1b93e | ||
|
|
c675dc02e9 | ||
|
|
ab760f35fa | ||
|
|
8a436791de | ||
|
|
d16a1776e6 | ||
|
|
ab720e0267 | ||
|
|
d7db71e38a | ||
|
|
b466e8430e | ||
|
|
ea404df79b | ||
|
|
15cdb7b64a | ||
|
|
340911f271 |
@@ -1,4 +1,5 @@
|
|||||||
MERMAID_DOMAIN=''
|
MERMAID_DOMAIN=''
|
||||||
|
MERMAID_BASE_PATH=''
|
||||||
MERMAID_DOCS_URL='https://mermaid.js.org'
|
MERMAID_DOCS_URL='https://mermaid.js.org'
|
||||||
MERMAID_ANALYTICS_URL=''
|
MERMAID_ANALYTICS_URL=''
|
||||||
MERMAID_RENDERER_URL='https://mermaid.ink'
|
MERMAID_RENDERER_URL='https://mermaid.ink'
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
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'
|
name: 'Playwright Tests'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container:
|
container:
|
||||||
image: mcr.microsoft.com/playwright:v1.52.0-jammy
|
image: mcr.microsoft.com/playwright:v1.60.0-jammy
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
|
|||||||
@@ -39,5 +39,8 @@ jobs:
|
|||||||
- name: Lint
|
- name: Lint
|
||||||
run: pnpm lint
|
run: pnpm lint
|
||||||
|
|
||||||
|
- name: Type check
|
||||||
|
run: pnpm check
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: pnpm test:unit
|
run: pnpm test:unit
|
||||||
|
|||||||
@@ -15,3 +15,4 @@
|
|||||||
/playwright-report/
|
/playwright-report/
|
||||||
/blob-report/
|
/blob-report/
|
||||||
/playwright/.cache/
|
/playwright/.cache/
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
22.15.0
|
24.16.0
|
||||||
|
|||||||
Vendored
+3
@@ -12,11 +12,14 @@
|
|||||||
"fsegurai",
|
"fsegurai",
|
||||||
"gantt",
|
"gantt",
|
||||||
"gitgraph",
|
"gitgraph",
|
||||||
|
"hugeicons",
|
||||||
"KROKI",
|
"KROKI",
|
||||||
"localstorage",
|
"localstorage",
|
||||||
"mermaidchart",
|
"mermaidchart",
|
||||||
"mindmap",
|
"mindmap",
|
||||||
"NEWYEAR",
|
"NEWYEAR",
|
||||||
|
"noopener",
|
||||||
|
"noreferrer",
|
||||||
"Pageview",
|
"Pageview",
|
||||||
"pako",
|
"pako",
|
||||||
"panmove",
|
"panmove",
|
||||||
|
|||||||
+4
-1
@@ -1,4 +1,4 @@
|
|||||||
FROM docker.io/library/node:22.15.0-alpine3.21 AS mermaid-live-editor-dependencies
|
FROM docker.io/library/node:24.16.0-alpine3.22 AS mermaid-live-editor-dependencies
|
||||||
|
|
||||||
RUN apk --no-cache add build-base git python3 && \
|
RUN apk --no-cache add build-base git python3 && \
|
||||||
rm -rf /var/cache/apk/*
|
rm -rf /var/cache/apk/*
|
||||||
@@ -19,6 +19,9 @@ ARG MERMAID_KROKI_RENDERER_URL
|
|||||||
ARG MERMAID_ANALYTICS_URL
|
ARG MERMAID_ANALYTICS_URL
|
||||||
ARG MERMAID_DOMAIN
|
ARG MERMAID_DOMAIN
|
||||||
ARG MERMAID_IS_ENABLED_MERMAID_CHART_LINKS
|
ARG MERMAID_IS_ENABLED_MERMAID_CHART_LINKS
|
||||||
|
ARG MERMAID_PRIVACY_POLICY_URL
|
||||||
|
ARG MERMAID_HIDE_PRIVACY_POLICY
|
||||||
|
ARG MERMAID_BASE_PATH
|
||||||
|
|
||||||
COPY . ./
|
COPY . ./
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ If you want to speed up the progress for mermaid-live-editor, join the Discord c
|
|||||||
docker run --platform linux/amd64 --publish 8000:8080 ghcr.io/mermaid-js/mermaid-live-editor
|
docker run --platform linux/amd64 --publish 8000:8080 ghcr.io/mermaid-js/mermaid-live-editor
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The published docker image is built using our default environment variables. You cannot override them when running the image. If you need to customize them, you will need to build the image yourself.
|
||||||
|
|
||||||
### To configure renderer URL
|
### To configure renderer URL
|
||||||
|
|
||||||
When building set the MERMAID_RENDERER_URL build argument to the rendering
|
When building set the MERMAID_RENDERER_URL build argument to the rendering
|
||||||
|
|||||||
+77
-73
@@ -9,6 +9,8 @@
|
|||||||
"dev:test": "pnpm dev",
|
"dev:test": "pnpm dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
"lint": "prettier --check --cache . && eslint .",
|
"lint": "prettier --check --cache . && eslint .",
|
||||||
"lint:fix": "prettier --write --cache . && eslint --fix .",
|
"lint:fix": "prettier --write --cache . && eslint --fix .",
|
||||||
"format": "prettier --write --cache .",
|
"format": "prettier --write --cache .",
|
||||||
@@ -23,94 +25,96 @@
|
|||||||
"test:e2e:debug": "playwright test --debug"
|
"test:e2e:debug": "playwright test --debug"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/compat": "^1.2.5",
|
"@eslint/compat": "^2.1.0",
|
||||||
"@eslint/eslintrc": "^3.3.3",
|
"@eslint/eslintrc": "^3.3.5",
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^10.0.1",
|
||||||
"@fortawesome/fontawesome-free": "^6.7.2",
|
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||||
"@iconify-json/material-symbols": "^1.2.20",
|
"@iconify-json/hugeicons": "^1.2.29",
|
||||||
|
"@iconify-json/logos": "^1.2.11",
|
||||||
|
"@iconify-json/material-symbols": "^1.2.79",
|
||||||
"@iconify-json/mdi": "^1.2.3",
|
"@iconify-json/mdi": "^1.2.3",
|
||||||
"@playwright/test": "^1.52.0",
|
"@playwright/test": "^1.60.0",
|
||||||
"@sveltejs/adapter-static": "^3.0.9",
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
"@sveltejs/kit": "^2.37.0",
|
"@sveltejs/kit": "^2.63.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.1.3",
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.20",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.3.1",
|
||||||
"@types/hammerjs": "^2.0.46",
|
"@types/hammerjs": "^2.0.46",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"@types/node": "^22.15.10",
|
"@types/node": "^24.13.2",
|
||||||
"@types/pako": "2.0.3",
|
"@types/pako": "2.0.4",
|
||||||
"@types/uuid": "9.0.8",
|
"@vitest/coverage-v8": "^4.1.9",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/ui": "^4.1.9",
|
||||||
"@vitest/ui": "^3.2.4",
|
"autoprefixer": "^10.5.0",
|
||||||
"autoprefixer": "^10.4.21",
|
"bits-ui": "^2.18.1",
|
||||||
"bits-ui": "^2.9.6",
|
"c8": "11.0.0",
|
||||||
"c8": "7.14.0",
|
"chai": "^6.2.2",
|
||||||
"chai": "^4.5.0",
|
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cssnano": "^6.1.2",
|
"cssnano": "^8.0.2",
|
||||||
"eslint": "^9.39.2",
|
"dotenv": "^17.4.2",
|
||||||
|
"eslint": "^10.4.1",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"eslint-plugin-es": "^4.1.0",
|
"eslint-plugin-es": "^4.1.0",
|
||||||
"eslint-plugin-no-only-tests": "^3.3.0",
|
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||||
"eslint-plugin-sort-keys": "^2.3.5",
|
"eslint-plugin-sort-keys": "^2.3.5",
|
||||||
"eslint-plugin-svelte": "^3.0.0",
|
"eslint-plugin-svelte": "^3.19.0",
|
||||||
"eslint-plugin-tailwindcss": "^3.18.2",
|
"eslint-plugin-tailwindcss": "^3.18.3",
|
||||||
"eslint-plugin-unicorn": "^60.0.0",
|
"eslint-plugin-unicorn": "^65.0.1",
|
||||||
"esserializer": "^1.3.11",
|
"globals": "^17.6.0",
|
||||||
"globals": "^16.0.0",
|
"husky": "^9.1.7",
|
||||||
"husky": "^8.0.3",
|
"jsdom": "^29.1.1",
|
||||||
"jsdom": "^25.0.1",
|
"lint-staged": "^17.0.7",
|
||||||
"lint-staged": "^15.5.1",
|
"lucide-svelte": "^1.0.1",
|
||||||
"lucide-svelte": "^0.507.0",
|
"node-html-parser": "^7.1.0",
|
||||||
"node-html-parser": "^6.1.13",
|
"paneforge": "1.0.2",
|
||||||
"paneforge": "1.0.0-next.6",
|
"prettier": "^3.8.4",
|
||||||
"prettier": "^3.8.1",
|
"prettier-plugin-svelte": "^4.1.1",
|
||||||
"prettier-plugin-svelte": "^3.4.1",
|
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"svelte": "^5.56.3",
|
||||||
"svelte": "^5.38.6",
|
"svelte-check": "^4.6.0",
|
||||||
"svelte-preprocess": "^6.0.3",
|
"svelte-preprocess": "^6.0.5",
|
||||||
"svelte-sonner": "^1.0.5",
|
"svelte-sonner": "^1.1.1",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwind-variants": "^3.1.0",
|
"tailwind-variants": "^3.2.2",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.3.1",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"tw-animate-css": "^1.3.8",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.2",
|
"typescript": "^6.0.3",
|
||||||
"typescript-eslint": "^8.42.0",
|
"typescript-eslint": "^8.60.1",
|
||||||
"unplugin-icons": "^22.2.0",
|
"unplugin-icons": "^23.0.1",
|
||||||
"vite": "^7.1.4",
|
"vite": "^8.0.16",
|
||||||
"vite-plugin-devtools-json": "^1.0.0",
|
"vite-plugin-devtools-json": "^1.0.0",
|
||||||
"vitest": "^3.2.4",
|
"vitest": "^4.1.9",
|
||||||
"vitest-dom": "^0.1.1"
|
"vitest-dom": "^0.1.1"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@codemirror/lang-json": "^6.0.1",
|
"@codemirror/lang-json": "^6.0.2",
|
||||||
"@codemirror/lang-markdown": "^6.3.2",
|
"@codemirror/lang-markdown": "^6.5.0",
|
||||||
"@codemirror/lang-yaml": "^6.1.2",
|
"@codemirror/lang-yaml": "^6.1.3",
|
||||||
"@codemirror/language": "^6.11.0",
|
"@codemirror/language": "^6.12.3",
|
||||||
"@codemirror/state": "^6.5.2",
|
"@codemirror/state": "^6.6.0",
|
||||||
"@codemirror/view": "^6.36.7",
|
"@codemirror/view": "^6.43.1",
|
||||||
"@fontsource-variable/recursive": "^5.2.5",
|
"@fontsource-variable/recursive": "^5.2.8",
|
||||||
"@fsegurai/codemirror-theme-vscode-dark": "^6.1.4",
|
"@fsegurai/codemirror-theme-vscode-dark": "^6.2.6",
|
||||||
"@fsegurai/codemirror-theme-vscode-light": "^6.1.4",
|
"@fsegurai/codemirror-theme-vscode-light": "^6.2.6",
|
||||||
"@mermaid-js/examples": "^1.0.0",
|
"@mermaid-js/examples": "^1.2.0",
|
||||||
"@mermaid-js/layout-elk": "^0.1.9",
|
"@mermaid-js/layout-elk": "^0.2.1",
|
||||||
"@mermaid-js/layout-tidy-tree": "^0.2.1",
|
"@mermaid-js/layout-tidy-tree": "^0.2.2",
|
||||||
"@mermaid-js/mermaid-zenuml": "^0.2.2",
|
"@mermaid-js/mermaid-zenuml": "^0.2.3",
|
||||||
"codemirror": "^6.0.1",
|
"codemirror": "^6.0.2",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.21",
|
||||||
"hammerjs": "^2.0.8",
|
"hammerjs": "^2.0.8",
|
||||||
"js-base64": "3.7.7",
|
"js-base64": "3.7.8",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.18.1",
|
||||||
"mermaid": "^11.12.0",
|
"mermaid": "^11.15.0",
|
||||||
"mode-watcher": "^0.5.1",
|
"mode-watcher": "^1.1.0",
|
||||||
"monaco-editor": "0.52.2",
|
"monaco-editor": "0.55.1",
|
||||||
"pako": "2.1.0",
|
"pako": "2.1.0",
|
||||||
"plausible-tracker": "^0.3.9",
|
"plausible-tracker": "^0.3.9",
|
||||||
"random-word-slugs": "0.1.7",
|
"random-word-slugs": "0.1.7",
|
||||||
"svg-pan-zoom": "3.6.2",
|
"svg-pan-zoom": "3.6.2",
|
||||||
"svg2roughjs": "^3.2.1",
|
"svg2roughjs": "^3.2.3",
|
||||||
"uuid": "9.0.1"
|
"uuid": "14.0.0"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{ts,svelte,js,css,md,json}": [
|
"*.{ts,svelte,js,css,md,json}": [
|
||||||
@@ -119,9 +123,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.19.0"
|
"node": ">=24.16.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39",
|
"packageManager": "pnpm@10.34.4+sha512.8768be55200ae3f2226b6527fcca2687e14bc4e5f12d7721a0f25da3df47915177058648db4177baf348120fa0ba2752d8d8d93f6beaf1fe64ae18da8de961af",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"deasync",
|
"deasync",
|
||||||
|
|||||||
Generated
+2955
-3680
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -5,11 +5,11 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Online FlowChart & Diagrams Editor - Mermaid Live Editor</title>
|
<title>Online FlowChart & Diagrams Editor - Mermaid Live Editor</title>
|
||||||
<meta name="og:image" content="%sveltekit.assets%/favicon.svg" />
|
<meta name="og:image" content="%sveltekit.assets%/favicon.svg" />
|
||||||
<link rel="canonical" href="https://mermaid.ai/live" />
|
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="Simplify documentation and avoid heavy tools. Open source Visio Alternative. Commonly used for explaining your code! Mermaid is a simple markdown-like script language for generating charts from text via javascript." />
|
content="Simplify documentation and avoid heavy tools. Open source Visio Alternative. Commonly used for explaining your code! Mermaid is a simple markdown-like script language for generating charts from text via javascript." />
|
||||||
<link rel="icon" type="image/png" href="%sveltekit.assets%/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
<link rel="mask-icon" href="%sveltekit.assets%/favicon.svg" color="#000000" />
|
<link rel="mask-icon" href="%sveltekit.assets%/favicon.svg" color="#000000" />
|
||||||
<meta name="theme-color" content="#ff3670" />
|
<meta name="theme-color" content="#ff3670" />
|
||||||
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
|
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
|
||||||
|
|||||||
Vendored
+2
@@ -7,6 +7,8 @@ interface ImportMetaEnv {
|
|||||||
readonly MERMAID_DOCS_URL?: string;
|
readonly MERMAID_DOCS_URL?: string;
|
||||||
readonly MERMAID_DOMAIN?: string;
|
readonly MERMAID_DOMAIN?: string;
|
||||||
readonly MERMAID_IS_ENABLED_MERMAID_CHART_LINKS?: string;
|
readonly MERMAID_IS_ENABLED_MERMAID_CHART_LINKS?: string;
|
||||||
|
readonly MERMAID_PRIVACY_POLICY_URL?: string;
|
||||||
|
readonly MERMAID_HIDE_PRIVACY_POLICY?: string;
|
||||||
// more env variables...
|
// more env variables...
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
import { getDomain } from '$/util/util';
|
import { getDomain } from '$/util/util';
|
||||||
import { browser } from '$app/environment';
|
import { browser } from '$app/environment';
|
||||||
import { waitForRender } from '$lib/util/autoSync';
|
import { waitForRender } from '$lib/util/autoSync';
|
||||||
import { inputStateStore, stateStore, urlsStore } from '$lib/util/state';
|
import { inputState, updateCodeStore, urls, validatedState } from '$lib/util/state.svelte';
|
||||||
import { logEvent } from '$lib/util/stats';
|
import { logEvent } from '$lib/util/stats';
|
||||||
import { version as FAVersion } from '@fortawesome/fontawesome-free/package.json';
|
import { version as FAVersion } from '@fortawesome/fontawesome-free/package.json';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
svg = getSvgElement();
|
svg = getSvgElement();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($stateStore.rough) {
|
if (validatedState.current.rough) {
|
||||||
fixForeignObjectClipping(svg);
|
fixForeignObjectClipping(svg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ ${svgString}`);
|
|||||||
};
|
};
|
||||||
|
|
||||||
const exportImage = async (event: Event, exporter: Exporter) => {
|
const exportImage = async (event: Event, exporter: Exporter) => {
|
||||||
$inputStateStore.panZoom = false;
|
updateCodeStore({ panZoom: false });
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
await waitForRender();
|
await waitForRender();
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
@@ -149,14 +149,14 @@ ${svgString}`);
|
|||||||
const image = new Image();
|
const image = new Image();
|
||||||
image.addEventListener('load', () => {
|
image.addEventListener('load', () => {
|
||||||
exporter(context, image)();
|
exporter(context, image)();
|
||||||
$inputStateStore.panZoom = true;
|
updateCodeStore({ panZoom: true });
|
||||||
});
|
});
|
||||||
image.src = `data:image/svg+xml;base64,${getBase64SVG(svg, canvas.width, canvas.height)}`;
|
image.src = `data:image/svg+xml;base64,${getBase64SVG(svg, canvas.width, canvas.height)}`;
|
||||||
// Fallback to set panZoom to true after 2 seconds
|
// Fallback to set panZoom to true after 2 seconds
|
||||||
// This is a workaround for the case when the image is not loaded
|
// This is a workaround for the case when the image is not loaded
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (!$inputStateStore.panZoom) {
|
if (!inputState.panZoom) {
|
||||||
$inputStateStore.panZoom = true;
|
updateCodeStore({ panZoom: true });
|
||||||
}
|
}
|
||||||
}, 2000);
|
}, 2000);
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -199,7 +199,10 @@ ${svgString}`);
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const onCopyClipboard = async (event: Event) => {
|
const onCopyClipboard = async (event?: Event) => {
|
||||||
|
if (!event) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
await exportImage(event, clipboardCopy);
|
await exportImage(event, clipboardCopy);
|
||||||
logEvent('copyClipboard');
|
logEvent('copyClipboard');
|
||||||
};
|
};
|
||||||
@@ -219,7 +222,8 @@ ${svgString}`);
|
|||||||
};
|
};
|
||||||
|
|
||||||
let gistURL = $state('');
|
let gistURL = $state('');
|
||||||
stateStore.subscribe(({ loader }) => {
|
$effect(() => {
|
||||||
|
const { loader } = validatedState.current;
|
||||||
if (loader?.type === 'gist') {
|
if (loader?.type === 'gist') {
|
||||||
gistURL = loader.config.url;
|
gistURL = loader.config.url;
|
||||||
}
|
}
|
||||||
@@ -284,10 +288,10 @@ ${svgString}`);
|
|||||||
bind:value={imageSize} />
|
bind:value={imageSize} />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
{@render dualActionButton('PNG', onDownloadPNG, $urlsStore.png)}
|
{@render dualActionButton('PNG', onDownloadPNG, urls.current.png)}
|
||||||
{@render dualActionButton('SVG', onDownloadSVG, $urlsStore.svg)}
|
{@render dualActionButton('SVG', onDownloadSVG, urls.current.svg)}
|
||||||
<ExternalLinkWrapper domain={getDomain($urlsStore.kroki)} isVisible={!!$urlsStore.kroki}>
|
<ExternalLinkWrapper domain={getDomain(urls.current.kroki)} isVisible={!!urls.current.kroki}>
|
||||||
<a target="_blank" rel="noreferrer" class="flex-grow" href={$urlsStore.kroki}>
|
<a target="_blank" rel="noreferrer" class="flex-grow" href={urls.current.kroki}>
|
||||||
<Button class="action-btn flex w-full items-center gap-2">
|
<Button class="action-btn flex w-full items-center gap-2">
|
||||||
<ExternalLinkIcon /> Kroki
|
<ExternalLinkIcon /> Kroki
|
||||||
</Button>
|
</Button>
|
||||||
@@ -300,9 +304,9 @@ ${svgString}`);
|
|||||||
{/if}
|
{/if}
|
||||||
<ExternalLinkWrapper
|
<ExternalLinkWrapper
|
||||||
labelPrefix="Thumbnail generated by"
|
labelPrefix="Thumbnail generated by"
|
||||||
domain={getDomain($urlsStore.png)}
|
domain={getDomain(urls.current.png)}
|
||||||
isVisible={!!$urlsStore.mdCode}>
|
isVisible={!!urls.current.mdCode}>
|
||||||
<CopyInput value={$urlsStore.mdCode} label="Copy Markdown" testID={TID.copyMarkdown} />
|
<CopyInput value={urls.current.mdCode} label="Copy Markdown" testID={TID.copyMarkdown} />
|
||||||
</ExternalLinkWrapper>
|
</ExternalLinkWrapper>
|
||||||
<div class="flex w-full items-center gap-2">
|
<div class="flex w-full items-center gap-2">
|
||||||
<Input type="url" bind:value={gistURL} placeholder="Enter Gist URL" />
|
<Input type="url" bind:value={gistURL} placeholder="Enter Gist URL" />
|
||||||
|
|||||||
@@ -14,9 +14,8 @@
|
|||||||
onselect?: (tab: Tab) => void;
|
onselect?: (tab: Tab) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
if (!activeTabID && tabs.length > 0) {
|
// Derive (don't mutate the prop) so the highlight tracks a bound activeTabID.
|
||||||
activeTabID = tabs[0].id;
|
const effectiveTabID = $derived(activeTabID || tabs[0]?.id);
|
||||||
}
|
|
||||||
|
|
||||||
const toggleTabs = (tab: Tab) => {
|
const toggleTabs = (tab: Tab) => {
|
||||||
return (event: Event) => {
|
return (event: Event) => {
|
||||||
@@ -34,7 +33,7 @@
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
class={[
|
class={[
|
||||||
'px-2',
|
'px-2',
|
||||||
activeTabID === tab.id && 'rounded-b-none border-b-2 border-b-primary-foreground/50'
|
effectiveTabID === tab.id && 'rounded-b-none border-b-2 border-b-primary-foreground/50'
|
||||||
]}
|
]}
|
||||||
onclick={toggleTabs(tab)}
|
onclick={toggleTabs(tab)}
|
||||||
onkeypress={toggleTabs(tab)}>
|
onkeypress={toggleTabs(tab)}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { EditorProps } from '$/types';
|
import type { EditorProps } from '$/types';
|
||||||
import { env } from '$/util/env';
|
import { env } from '$/util/env';
|
||||||
import { stateStore, urlsStore } from '$/util/state';
|
import { urls, validatedState } from '$/util/state.svelte';
|
||||||
import { logMermaidChartClick } from '$/util/stats';
|
import { logMermaidChartClick } from '$/util/stats';
|
||||||
import { AIPromptViewZoneManager } from '$lib/util/AIPromptViewZoneManager';
|
import { AIPromptViewZoneManager } from '$lib/util/AIPromptViewZoneManager';
|
||||||
import { initEditor } from '$lib/util/monacoExtra';
|
import { initEditor } from '$lib/util/monacoExtra';
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
lineNumbersMinChars: 4
|
lineNumbersMinChars: 4
|
||||||
} satisfies monaco.editor.IStandaloneEditorConstructionOptions;
|
} satisfies monaco.editor.IStandaloneEditorConstructionOptions;
|
||||||
let currentText = '';
|
let currentText = '';
|
||||||
|
let isUpdatingFromState = false;
|
||||||
let showPopup = $state(false);
|
let showPopup = $state(false);
|
||||||
let popupPosition = $state({ top: 0, lineNumber: 0 });
|
let popupPosition = $state({ top: 0, lineNumber: 0 });
|
||||||
let decorationsCollection: monaco.editor.IEditorDecorationsCollection | undefined;
|
let decorationsCollection: monaco.editor.IEditorDecorationsCollection | undefined;
|
||||||
@@ -34,6 +35,16 @@
|
|||||||
let lastMouseLine = 0;
|
let lastMouseLine = 0;
|
||||||
const aiPromptManager = new AIPromptViewZoneManager();
|
const aiPromptManager = new AIPromptViewZoneManager();
|
||||||
|
|
||||||
|
const applyEditorTheme = (currentMode: typeof mode.current) => {
|
||||||
|
if (!editor) return;
|
||||||
|
monaco.editor.setTheme(`mermaid${currentMode === 'dark' ? '-dark' : ''}`);
|
||||||
|
divElement?.classList.toggle('mermaid-dark', currentMode === 'dark');
|
||||||
|
};
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
applyEditorTheme(mode.current);
|
||||||
|
});
|
||||||
|
|
||||||
const jsonModel = monaco.editor.createModel(
|
const jsonModel = monaco.editor.createModel(
|
||||||
'',
|
'',
|
||||||
'json',
|
'json',
|
||||||
@@ -104,7 +115,7 @@
|
|||||||
throw new Error('divEl is undefined');
|
throw new Error('divEl is undefined');
|
||||||
}
|
}
|
||||||
|
|
||||||
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
|
monaco.json.jsonDefaults.setDiagnosticsOptions({
|
||||||
validate: true,
|
validate: true,
|
||||||
enableSchemaRequest: true,
|
enableSchemaRequest: true,
|
||||||
schemas: [
|
schemas: [
|
||||||
@@ -132,43 +143,13 @@
|
|||||||
|
|
||||||
editor.onDidChangeModelContent(({ isFlush }) => {
|
editor.onDidChangeModelContent(({ isFlush }) => {
|
||||||
const newText = editor?.getValue();
|
const newText = editor?.getValue();
|
||||||
if (!newText || currentText === newText || isFlush) {
|
if (!newText || currentText === newText || isFlush || isUpdatingFromState) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
currentText = newText;
|
currentText = newText;
|
||||||
onUpdate(currentText);
|
onUpdate(currentText);
|
||||||
});
|
});
|
||||||
|
|
||||||
const unsubscribeState = stateStore.subscribe(({ errorMarkers, editorMode, code, mermaid }) => {
|
|
||||||
if (!editor) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const model = editorMode === 'code' ? mermaidModel : jsonModel;
|
|
||||||
|
|
||||||
if (editor.getModel()?.id !== model.id) {
|
|
||||||
editor.setModel(model);
|
|
||||||
renderAIPromptGutterGlyphIcon();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear decorations if not in 'code' mode, or if the model changes
|
|
||||||
if (editorMode !== 'code' || editor.getModel()?.id !== mermaidModel.id) {
|
|
||||||
decorationsCollection?.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update editor text if it's different
|
|
||||||
const newText = editorMode === 'code' ? code : mermaid;
|
|
||||||
if (newText !== currentText) {
|
|
||||||
editor.setScrollTop(0);
|
|
||||||
editor.setValue(newText);
|
|
||||||
currentText = newText;
|
|
||||||
renderAIPromptGutterGlyphIcon();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display/clear errors
|
|
||||||
monaco.editor.setModelMarkers(model, 'mermaid', errorMarkers);
|
|
||||||
});
|
|
||||||
|
|
||||||
editor.onMouseMove((e) => {
|
editor.onMouseMove((e) => {
|
||||||
if (!editor) return;
|
if (!editor) return;
|
||||||
if (showPopup) return;
|
if (showPopup) return;
|
||||||
@@ -183,12 +164,8 @@
|
|||||||
renderAIPromptGutterGlyphIcon();
|
renderAIPromptGutterGlyphIcon();
|
||||||
});
|
});
|
||||||
|
|
||||||
const unsubscribeMode = mode.subscribe((mode) => {
|
applyEditorTheme(mode.current);
|
||||||
if (editor) {
|
|
||||||
monaco.editor.setTheme(`mermaid${mode === 'dark' ? '-dark' : ''}`);
|
|
||||||
divElement?.classList.toggle('mermaid-dark', mode === 'dark');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const resizeObserver = new ResizeObserver((entries) => {
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
editor?.layout({
|
editor?.layout({
|
||||||
height: entries[0].contentRect.height,
|
height: entries[0].contentRect.height,
|
||||||
@@ -203,8 +180,6 @@
|
|||||||
renderAIPromptGutterGlyphIcon();
|
renderAIPromptGutterGlyphIcon();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribeState();
|
|
||||||
unsubscribeMode();
|
|
||||||
resizeObserver.disconnect();
|
resizeObserver.disconnect();
|
||||||
jsonModel.dispose();
|
jsonModel.dispose();
|
||||||
mermaidModel.dispose();
|
mermaidModel.dispose();
|
||||||
@@ -212,6 +187,49 @@
|
|||||||
editor?.dispose();
|
editor?.dispose();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const { errorMarkers, editorMode, code, mermaid } = validatedState.current;
|
||||||
|
if (!editor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const model = editorMode === 'code' ? mermaidModel : jsonModel;
|
||||||
|
|
||||||
|
if (editor.getModel()?.id !== model.id) {
|
||||||
|
editor.setModel(model);
|
||||||
|
renderAIPromptGutterGlyphIcon();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear decorations if not in 'code' mode, or if the model changes
|
||||||
|
if (editorMode !== 'code' || editor.getModel()?.id !== mermaidModel.id) {
|
||||||
|
decorationsCollection?.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update editor text if it's different
|
||||||
|
const newText = editorMode === 'code' ? code : mermaid;
|
||||||
|
if (newText !== currentText) {
|
||||||
|
isUpdatingFromState = true;
|
||||||
|
try {
|
||||||
|
editor.setScrollTop(0);
|
||||||
|
editor.pushUndoStop();
|
||||||
|
editor.executeEdits('updateCode', [
|
||||||
|
{
|
||||||
|
range: model.getFullModelRange(),
|
||||||
|
text: newText
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
editor.pushUndoStop();
|
||||||
|
currentText = newText;
|
||||||
|
} finally {
|
||||||
|
isUpdatingFromState = false;
|
||||||
|
}
|
||||||
|
renderAIPromptGutterGlyphIcon();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display/clear errors
|
||||||
|
monaco.editor.setModelMarkers(model, 'mermaid', errorMarkers);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="relative h-full grow overflow-hidden">
|
<div class="relative h-full grow overflow-hidden">
|
||||||
@@ -224,7 +242,11 @@
|
|||||||
onClose={closePopup}
|
onClose={closePopup}
|
||||||
onTryFree={() => {
|
onTryFree={() => {
|
||||||
logMermaidChartClick('vibeDiagramming');
|
logMermaidChartClick('vibeDiagramming');
|
||||||
window.open($urlsStore.mermaidChart({ medium: 'vibe_diagramming' }).save, '_blank');
|
window.open(
|
||||||
|
urls.current.mermaidChart({ medium: 'vibe_diagramming' }).save,
|
||||||
|
'_blank',
|
||||||
|
'noopener'
|
||||||
|
);
|
||||||
closePopup();
|
closePopup();
|
||||||
}} />
|
}} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import type { DocumentationConfig } from '$/types';
|
import type { DocumentationConfig } from '$/types';
|
||||||
import { env } from '$/util/env';
|
import { env } from '$/util/env';
|
||||||
import { standardizeDiagramType } from '$/util/mermaid';
|
import { standardizeDiagramType } from '$/util/mermaid';
|
||||||
import { stateStore } from '$/util/state';
|
import { validatedState } from '$/util/state.svelte';
|
||||||
import BookIcon from '~icons/material-symbols/book-2-outline-rounded';
|
import BookIcon from '~icons/material-symbols/book-2-outline-rounded';
|
||||||
|
|
||||||
const docURLBase = env.docsUrl;
|
const docURLBase = env.docsUrl;
|
||||||
@@ -92,12 +92,14 @@
|
|||||||
} as const satisfies DocumentationConfig;
|
} as const satisfies DocumentationConfig;
|
||||||
|
|
||||||
const doc = $derived.by(() => {
|
const doc = $derived.by(() => {
|
||||||
const { editorMode, diagramType } = $stateStore;
|
const { editorMode, diagramType } = validatedState.current;
|
||||||
if (!diagramType) {
|
if (!diagramType) {
|
||||||
return { key: '', url: docURLBase };
|
return { key: '', url: docURLBase };
|
||||||
}
|
}
|
||||||
const key = standardizeDiagramType(diagramType);
|
const key = standardizeDiagramType(diagramType);
|
||||||
const docConfig = docMap[key] ?? { code: '' };
|
const docConfig: { code: string; config?: string } = docMap[key as keyof typeof docMap] ?? {
|
||||||
|
code: ''
|
||||||
|
};
|
||||||
const url = docURLBase + (docConfig[editorMode] ?? docConfig.code ?? '');
|
const url = docURLBase + (docConfig[editorMode] ?? docConfig.code ?? '');
|
||||||
return { key, url };
|
return { key, url };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
import { Button } from '$/components/ui/button';
|
import { Button } from '$/components/ui/button';
|
||||||
import { TID } from '$/constants';
|
import { TID } from '$/constants';
|
||||||
import { env } from '$/util/env';
|
import { env } from '$/util/env';
|
||||||
import { stateStore, updateCode, updateConfig, urlsStore } from '$lib/util/state';
|
import { updateCode, updateConfig, urls, validatedState } from '$lib/util/state.svelte';
|
||||||
import { logMermaidChartClick } from '$lib/util/stats';
|
import { logMermaidChartClick } from '$lib/util/stats';
|
||||||
import { debounce } from 'lodash-es';
|
import { debounce } from 'lodash-es';
|
||||||
import ExclamationCircleIcon from '~icons/material-symbols/error-outline-rounded';
|
import ExclamationCircleIcon from '~icons/material-symbols/error-outline-rounded';
|
||||||
|
|
||||||
const { isMobile } = $props<{ isMobile: boolean }>();
|
const { isMobile } = $props<{ isMobile: boolean }>();
|
||||||
const onUpdate = (text: string) => {
|
const onUpdate = (text: string) => {
|
||||||
if ($stateStore.editorMode === 'code') {
|
if (validatedState.current.editorMode === 'code') {
|
||||||
updateCode(text);
|
updateCode(text);
|
||||||
} else {
|
} else {
|
||||||
updateConfig(text);
|
updateConfig(text);
|
||||||
@@ -24,10 +24,10 @@
|
|||||||
|
|
||||||
const showErrorDebounced = debounce(() => {
|
const showErrorDebounced = debounce(() => {
|
||||||
showError = true;
|
showError = true;
|
||||||
}, 5000);
|
}, 3000);
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if ($stateStore.error) {
|
if (validatedState.current.error) {
|
||||||
showErrorDebounced();
|
showErrorDebounced();
|
||||||
} else {
|
} else {
|
||||||
showErrorDebounced.cancel();
|
showErrorDebounced.cancel();
|
||||||
@@ -46,27 +46,27 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<DesktopEditor {onUpdate} />
|
<DesktopEditor {onUpdate} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if showError && $stateStore.error instanceof Error}
|
{#if showError && validatedState.current.error instanceof Error}
|
||||||
<div class="flex flex-col text-sm" data-testid={TID.errorContainer}>
|
<div class="flex flex-col text-sm" data-testid={TID.errorContainer}>
|
||||||
<div class="flex items-center justify-between gap-2 bg-slate-900 p-2 text-white">
|
<div class="flex items-center justify-between gap-2 bg-slate-900 p-2 text-white">
|
||||||
<div class="flex w-fit items-center gap-2">
|
<div class="flex w-fit items-center gap-2">
|
||||||
<ExclamationCircleIcon class="size-6 text-destructive" aria-hidden="true" />
|
<ExclamationCircleIcon class="size-6 text-destructive" aria-hidden="true" />
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<p>Syntax error</p>
|
<p>Syntax error</p>
|
||||||
{#if env.isEnabledMermaidChartLinks && $stateStore.editorMode === 'code'}
|
{#if env.isEnabledMermaidChartLinks && validatedState.current.editorMode === 'code'}
|
||||||
<p class="text-xs text-white/60" data-testid={TID.aiHelpText}>
|
<p class="text-xs text-white/60" data-testid={TID.aiHelpText}>
|
||||||
Create a free account to repair with AI
|
Create a free account to repair with AI
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{#if $stateStore.editorMode === 'code'}
|
{#if validatedState.current.editorMode === 'code'}
|
||||||
<McWrapper>
|
<McWrapper>
|
||||||
<Button
|
<Button
|
||||||
variant="accent"
|
variant="accent"
|
||||||
size="sm"
|
size="sm"
|
||||||
data-testid={TID.aiRepairButton}
|
data-testid={TID.aiRepairButton}
|
||||||
href={$urlsStore.mermaidChart({ medium: 'ai_repair' }).save}
|
href={urls.current.mermaidChart({ medium: 'ai_repair' }).save}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
onclick={() => logMermaidChartClick('aiRepair')}>
|
onclick={() => logMermaidChartClick('aiRepair')}>
|
||||||
<MermaidChartIcon />
|
<MermaidChartIcon />
|
||||||
@@ -76,7 +76,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<output class="max-h-32 overflow-auto bg-muted p-2" name="mermaid-error" for="editor">
|
<output class="max-h-32 overflow-auto bg-muted p-2" name="mermaid-error" for="editor">
|
||||||
<pre>{$stateStore.error?.toString()}</pre>
|
<pre>{validatedState.current.error?.toString()}</pre>
|
||||||
</output>
|
</output>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import McWrapper from '$/components/McWrapper.svelte';
|
||||||
|
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 { logMermaidChartClick } from '$/util/stats';
|
||||||
|
import { quintInOut } from 'svelte/easing';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
|
||||||
|
const visualEditDiagramTypes = new Set([
|
||||||
|
'flowchart',
|
||||||
|
'stateDiagram',
|
||||||
|
'classDiagram',
|
||||||
|
'sequenceDiagram',
|
||||||
|
'er',
|
||||||
|
'requirement',
|
||||||
|
'mindmap'
|
||||||
|
]);
|
||||||
|
|
||||||
|
const diagramType = $derived.by(() => {
|
||||||
|
const dt = validatedState.current.diagramType;
|
||||||
|
return dt ? standardizeDiagramType(dt) : undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
const showVisualEdit = $derived.by(() => {
|
||||||
|
return diagramType ? visualEditDiagramTypes.has(diagramType) : false;
|
||||||
|
});
|
||||||
|
|
||||||
|
interface EnhancedEditAction {
|
||||||
|
campaign: string;
|
||||||
|
label: string;
|
||||||
|
medium: 'ai_edit' | 'visual_edit' | 'voice_edit';
|
||||||
|
source: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cycleIntervalMs = 30_000;
|
||||||
|
|
||||||
|
let currentActionIndex = $state(0);
|
||||||
|
|
||||||
|
const availableActions = $derived.by<EnhancedEditAction[]>(() => {
|
||||||
|
if (!validatedState.current.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' }
|
||||||
|
];
|
||||||
|
|
||||||
|
if (showVisualEdit) {
|
||||||
|
actions.unshift({
|
||||||
|
campaign: 'visual_1',
|
||||||
|
label: 'visually',
|
||||||
|
medium: 'visual_edit',
|
||||||
|
source: 'visualEdit'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions;
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentAction = $derived.by(() => {
|
||||||
|
const actions = availableActions;
|
||||||
|
if (actions.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions[currentActionIndex % actions.length];
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const actionCount = availableActions.length;
|
||||||
|
|
||||||
|
if (actionCount === 0) {
|
||||||
|
currentActionIndex = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentActionIndex >= actionCount) {
|
||||||
|
currentActionIndex = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (actionCount <= 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervalID = setInterval(() => {
|
||||||
|
currentActionIndex = (currentActionIndex + 1) % actionCount;
|
||||||
|
}, cycleIntervalMs);
|
||||||
|
|
||||||
|
return () => clearInterval(intervalID);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if currentAction}
|
||||||
|
<McWrapper>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
href={urls.current.mermaidChart({
|
||||||
|
medium: currentAction.medium,
|
||||||
|
campaign: currentAction.campaign
|
||||||
|
}).save}
|
||||||
|
target="_blank"
|
||||||
|
onclick={() => logMermaidChartClick(currentAction.source)}>
|
||||||
|
<MermaidChartIcon />
|
||||||
|
Edit
|
||||||
|
{#key currentAction.label}
|
||||||
|
<span
|
||||||
|
class="-ml-1"
|
||||||
|
in:slide={{ axis: 'x', easing: quintInOut, delay: 400 }}
|
||||||
|
out:slide={{ axis: 'x', easing: quintInOut }}>
|
||||||
|
{currentAction.label}
|
||||||
|
</span>
|
||||||
|
{/key}
|
||||||
|
</Button>
|
||||||
|
</McWrapper>
|
||||||
|
{/if}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { stateStore } from '$/util/state';
|
import { validatedState } from '$/util/state.svelte';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
import type { ComponentProps, Snippet } from 'svelte';
|
import type { ComponentProps, Snippet } from 'svelte';
|
||||||
import ExternalLinkIcon from '~icons/material-symbols/open-in-new-rounded';
|
import ExternalLinkIcon from '~icons/material-symbols/open-in-new-rounded';
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let shouldDisableComponent = $derived(
|
let shouldDisableComponent = $derived(
|
||||||
shouldCheckDiagramType && $stateStore.diagramType === 'zenuml'
|
shouldCheckDiagramType && validatedState.current.diagramType === 'zenuml'
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,11 @@
|
|||||||
import Card from '$lib/components/Card/Card.svelte';
|
import Card from '$lib/components/Card/Card.svelte';
|
||||||
import type { HistoryEntry, HistoryType, State, Tab } from '$lib/types';
|
import type { HistoryEntry, HistoryType, State, Tab } from '$lib/types';
|
||||||
import { notify, prompt } from '$lib/util/notify';
|
import { notify, prompt } from '$lib/util/notify';
|
||||||
import { getStateString, inputStateStore } from '$lib/util/state';
|
import { serializeState } from '$lib/util/serde';
|
||||||
|
import { inputState, replaceInputState } from '$lib/util/state.svelte';
|
||||||
import { logEvent } from '$lib/util/stats';
|
import { logEvent } from '$lib/util/stats';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import dayjsRelativeTime from 'dayjs/plugin/relativeTime';
|
import dayjsRelativeTime from 'dayjs/plugin/relativeTime';
|
||||||
import { onMount } from 'svelte';
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
import BookmarkIcon from '~icons/material-symbols/bookmark-outline-rounded';
|
import BookmarkIcon from '~icons/material-symbols/bookmark-outline-rounded';
|
||||||
import TrashAltIcon from '~icons/material-symbols/delete-outline-rounded';
|
import TrashAltIcon from '~icons/material-symbols/delete-outline-rounded';
|
||||||
import DownloadIcon from '~icons/material-symbols/download-rounded';
|
import DownloadIcon from '~icons/material-symbols/download-rounded';
|
||||||
@@ -16,41 +15,51 @@
|
|||||||
import UploadIcon from '~icons/material-symbols/upload-rounded';
|
import UploadIcon from '~icons/material-symbols/upload-rounded';
|
||||||
import HistoryIcon from '~icons/mdi/clock-outline';
|
import HistoryIcon from '~icons/mdi/clock-outline';
|
||||||
import GitAltIcon from '~icons/mdi/git';
|
import GitAltIcon from '~icons/mdi/git';
|
||||||
|
import OpenInNewIcon from '~icons/material-symbols/open-in-new-rounded';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
import { Separator } from '../ui/separator';
|
import { Separator } from '../ui/separator';
|
||||||
import {
|
import {
|
||||||
addHistoryEntry,
|
addManualEntry,
|
||||||
clearHistoryData,
|
clearActive,
|
||||||
getPreviousState,
|
historyState,
|
||||||
historyModeStore,
|
removeEntry,
|
||||||
historyStore,
|
restoreEntries,
|
||||||
loaderHistoryStore,
|
setMode
|
||||||
restoreHistory
|
} from './historyState.svelte';
|
||||||
} from './history';
|
|
||||||
|
|
||||||
dayjs.extend(dayjsRelativeTime);
|
dayjs.extend(dayjsRelativeTime);
|
||||||
|
|
||||||
const HISTORY_SAVE_INTERVAL = 60_000;
|
const baseTabs: Tab[] = [
|
||||||
|
{ id: 'manual', title: 'Saved', icon: BookmarkIcon },
|
||||||
|
{ id: 'auto', title: 'Timeline', icon: HistoryIcon }
|
||||||
|
];
|
||||||
|
const loaderTab: Tab = { id: 'loader', title: 'Revisions', icon: GitAltIcon };
|
||||||
|
|
||||||
|
const tabs = $derived(
|
||||||
|
historyState.loaderEntries.length > 0 ? [loaderTab, ...baseTabs] : baseTabs
|
||||||
|
);
|
||||||
|
|
||||||
|
// Surface revisions once when they first appear; the user can switch away after.
|
||||||
|
let revisionsShown = false;
|
||||||
|
$effect(() => {
|
||||||
|
if (historyState.loaderEntries.length > 0 && !revisionsShown) {
|
||||||
|
revisionsShown = true;
|
||||||
|
setMode('loader');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emptyMessage = $derived(
|
||||||
|
historyState.mode === 'auto'
|
||||||
|
? 'No timeline snapshots yet.\nThe Timeline is saved automatically every minute.'
|
||||||
|
: 'No saved states yet.\nClick the Save button to bookmark the current diagram and restore it later.'
|
||||||
|
);
|
||||||
|
|
||||||
const tabSelectHandler = (tab: Tab) => {
|
const tabSelectHandler = (tab: Tab) => {
|
||||||
historyModeStore.set(tab.id as HistoryType);
|
setMode(tab.id as HistoryType);
|
||||||
};
|
};
|
||||||
|
|
||||||
let tabs: Tab[] = $state([
|
|
||||||
{
|
|
||||||
id: 'manual',
|
|
||||||
title: 'Saved',
|
|
||||||
icon: BookmarkIcon
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'auto',
|
|
||||||
title: 'Timeline',
|
|
||||||
icon: HistoryIcon
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
const downloadHistory = () => {
|
const downloadHistory = () => {
|
||||||
const data = get(historyStore);
|
const data = historyState.entries;
|
||||||
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
|
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
@@ -58,9 +67,7 @@
|
|||||||
a.download = `mermaid-history-${dayjs().format('YYYY-MM-DD-HHmmss')}.json`;
|
a.download = `mermaid-history-${dayjs().format('YYYY-MM-DD-HHmmss')}.json`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
logEvent('history', {
|
logEvent('history', { action: 'download' });
|
||||||
action: 'download'
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadHistory = () => {
|
const uploadHistory = () => {
|
||||||
@@ -73,59 +80,39 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data: HistoryEntry[] = JSON.parse(await file.text());
|
const data: HistoryEntry[] = JSON.parse(await file.text());
|
||||||
restoreHistory(data);
|
const { restored, invalid, duplicates } = restoreEntries(data);
|
||||||
|
notify(`${restored} restored, ${duplicates} duplicate, ${invalid} invalid.`);
|
||||||
});
|
});
|
||||||
input.click();
|
input.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveHistory = (auto = false) => {
|
const saveHistory = () => {
|
||||||
const currentState: string = getStateString();
|
if (!addManualEntry($state.snapshot(inputState))) {
|
||||||
const previousState: string = getPreviousState(auto);
|
|
||||||
if (previousState !== currentState) {
|
|
||||||
addHistoryEntry({
|
|
||||||
state: $inputStateStore,
|
|
||||||
time: Date.now(),
|
|
||||||
type: auto ? 'auto' : 'manual'
|
|
||||||
});
|
|
||||||
} else if (!auto) {
|
|
||||||
notify('State already saved.');
|
notify('State already saved.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearHistory = (id?: string): void => {
|
const clearAll = () => {
|
||||||
if (!id && !prompt('Clear all saved items?')) {
|
if (prompt('Clear all saved items?')) {
|
||||||
return;
|
clearActive();
|
||||||
}
|
}
|
||||||
clearHistoryData(id);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const restoreHistoryItem = (state: State): void => {
|
const restoreHistoryItem = (state: State): void => {
|
||||||
inputStateStore.set({ ...state, updateDiagram: true });
|
replaceInputState({ ...state, updateDiagram: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => {
|
// Absolute editor URL for an entry, so the link can be opened in a new tab or copied.
|
||||||
historyModeStore.set('manual');
|
const entryUrl = (state: State): string =>
|
||||||
setInterval(() => {
|
`${window.location.origin}${window.location.pathname}#${serializeState(state)}`;
|
||||||
saveHistory(true);
|
|
||||||
}, HISTORY_SAVE_INTERVAL);
|
|
||||||
});
|
|
||||||
|
|
||||||
loaderHistoryStore.subscribe((entries) => {
|
// Serialize each entry's URL once per change rather than per row on every render.
|
||||||
if (entries.length > 0 && tabs.length === 2) {
|
const entriesWithUrl = $derived(
|
||||||
tabs = [
|
historyState.entries.map((entry) => ({ ...entry, openUrl: entryUrl(entry.state) }))
|
||||||
{
|
);
|
||||||
id: 'loader',
|
|
||||||
title: 'Revisions',
|
|
||||||
icon: GitAltIcon
|
|
||||||
},
|
|
||||||
...tabs
|
|
||||||
];
|
|
||||||
historyModeStore.set('loader');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card onselect={tabSelectHandler} isOpen isClosable={false} {tabs}>
|
<Card onselect={tabSelectHandler} isOpen isClosable={false} {tabs} activeTabID={historyState.mode}>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
@@ -134,7 +121,7 @@
|
|||||||
id="uploadHistory"
|
id="uploadHistory"
|
||||||
onclick={uploadHistory}
|
onclick={uploadHistory}
|
||||||
title="Upload history"><UploadIcon /></Button>
|
title="Upload history"><UploadIcon /></Button>
|
||||||
{#if $historyStore.length > 0}
|
{#if historyState.entries.length > 0}
|
||||||
<Button
|
<Button
|
||||||
id="downloadHistory"
|
id="downloadHistory"
|
||||||
size="icon"
|
size="icon"
|
||||||
@@ -147,22 +134,22 @@
|
|||||||
id="saveHistory"
|
id="saveHistory"
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onclick={() => saveHistory()}
|
onclick={saveHistory}
|
||||||
title="Save current state"><SaveIcon /></Button>
|
title="Save current state"><SaveIcon /></Button>
|
||||||
{#if $historyModeStore !== 'loader'}
|
{#if historyState.mode !== 'loader'}
|
||||||
<Button
|
<Button
|
||||||
id="clearHistory"
|
id="clearHistory"
|
||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="hover:text-destructive"
|
class="hover:text-destructive"
|
||||||
onclick={() => clearHistory()}
|
onclick={clearAll}
|
||||||
title="Delete all saved states"><TrashAltIcon /></Button>
|
title="Delete all saved states"><TrashAltIcon /></Button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
<ul class="flex h-full min-w-fit flex-col gap-2 overflow-auto p-2" id="historyList">
|
<ul class="flex h-full min-w-fit flex-col gap-2 overflow-auto p-2" id="historyList">
|
||||||
{#if $historyStore.length > 0}
|
{#if entriesWithUrl.length > 0}
|
||||||
{#each $historyStore as { id, state, time, name, url, type } (id)}
|
{#each entriesWithUrl as { id, state, time, name, url, type, openUrl } (id)}
|
||||||
<li class="flex flex-col gap-2">
|
<li class="flex flex-col gap-2">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
@@ -184,7 +171,20 @@
|
|||||||
<span class="text-sm whitespace-nowrap text-primary-foreground/50">
|
<span class="text-sm whitespace-nowrap text-primary-foreground/50">
|
||||||
{dayjs(time).fromNow()}
|
{dayjs(time).fromNow()}
|
||||||
</span>
|
</span>
|
||||||
<Button size="icon" variant="ghost" onclick={() => restoreHistoryItem(state)}>
|
<Button
|
||||||
|
href={openUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
title="Open in new tab">
|
||||||
|
<OpenInNewIcon />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
title="Restore this version"
|
||||||
|
onclick={() => restoreHistoryItem(state)}>
|
||||||
<UndoIcon />
|
<UndoIcon />
|
||||||
</Button>
|
</Button>
|
||||||
{#if type !== 'loader'}
|
{#if type !== 'loader'}
|
||||||
@@ -192,7 +192,8 @@
|
|||||||
size="icon"
|
size="icon"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
class="hover:text-destructive"
|
class="hover:text-destructive"
|
||||||
onclick={() => clearHistory(id)}>
|
title="Delete this version"
|
||||||
|
onclick={() => removeEntry(id)}>
|
||||||
<TrashAltIcon />
|
<TrashAltIcon />
|
||||||
</Button>
|
</Button>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -202,11 +203,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
{:else}
|
{:else}
|
||||||
<div class="m-2 text-center">
|
<div class="m-2 text-center whitespace-pre-line">{emptyMessage}</div>
|
||||||
No items in History<br />
|
|
||||||
Click the Save button to save current state and restore it later.<br />
|
|
||||||
Timeline will automatically be saved every minute.
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</ul>
|
</ul>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
import type { HistoryEntry } from '$lib/types';
|
|
||||||
import { defaultState } from '$lib/util/state';
|
|
||||||
import { get } from 'svelte/store';
|
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import {
|
|
||||||
addHistoryEntry,
|
|
||||||
clearHistoryData,
|
|
||||||
historyModeStore,
|
|
||||||
historyStore,
|
|
||||||
injectHistoryIDs
|
|
||||||
} from './history';
|
|
||||||
|
|
||||||
describe('history', () => {
|
|
||||||
it('should handle saving individual history entry', () => {
|
|
||||||
expect(window.localStorage.getItem('manualHistoryStore')).toBe('[]');
|
|
||||||
expect(window.localStorage.getItem('autoHistoryStore')).toBe('[]');
|
|
||||||
|
|
||||||
addHistoryEntry({
|
|
||||||
state: defaultState,
|
|
||||||
time: 12_345,
|
|
||||||
type: 'manual'
|
|
||||||
});
|
|
||||||
|
|
||||||
const [manualEntry] = JSON.parse(
|
|
||||||
window.localStorage.getItem('manualHistoryStore') ?? '[]'
|
|
||||||
) as HistoryEntry[];
|
|
||||||
|
|
||||||
expect(manualEntry.time).toBe(12_345);
|
|
||||||
expect(manualEntry.type).toBe('manual');
|
|
||||||
expect(manualEntry.name).not.toBeNull();
|
|
||||||
expect(manualEntry.state).not.toBeNull();
|
|
||||||
|
|
||||||
addHistoryEntry({
|
|
||||||
state: defaultState,
|
|
||||||
time: 54_321,
|
|
||||||
type: 'auto'
|
|
||||||
});
|
|
||||||
|
|
||||||
const [autoEntry] = JSON.parse(
|
|
||||||
window.localStorage.getItem('autoHistoryStore') ?? '[]'
|
|
||||||
) as HistoryEntry[];
|
|
||||||
|
|
||||||
expect(autoEntry.time).toBe(54_321);
|
|
||||||
expect(autoEntry.type).toBe('auto');
|
|
||||||
expect(autoEntry.name).not.toBeNull();
|
|
||||||
expect(autoEntry.state).not.toBeNull();
|
|
||||||
|
|
||||||
historyModeStore.set('manual');
|
|
||||||
clearHistoryData();
|
|
||||||
historyModeStore.set('auto');
|
|
||||||
clearHistoryData();
|
|
||||||
expect(window.localStorage.getItem('manualHistoryStore')).toBe('[]');
|
|
||||||
expect(window.localStorage.getItem('autoHistoryStore')).toBe('[]');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should clear history entries', () => {
|
|
||||||
addHistoryEntry({
|
|
||||||
state: defaultState,
|
|
||||||
time: 12_345,
|
|
||||||
type: 'manual'
|
|
||||||
});
|
|
||||||
addHistoryEntry({
|
|
||||||
state: { ...defaultState, code: 'graph TD\\n A[Christmas] -->|Get money| B(Go shopping)' },
|
|
||||||
time: 123_456,
|
|
||||||
type: 'manual'
|
|
||||||
});
|
|
||||||
|
|
||||||
historyModeStore.set('manual');
|
|
||||||
const store: HistoryEntry[] = get(historyStore);
|
|
||||||
expect(store.length).toBe(2);
|
|
||||||
clearHistoryData(store[1].id);
|
|
||||||
expect(get(historyStore).length).toBe(1);
|
|
||||||
clearHistoryData();
|
|
||||||
expect(get(historyStore).length).toBe(0);
|
|
||||||
|
|
||||||
historyModeStore.set('auto');
|
|
||||||
addHistoryEntry({
|
|
||||||
state: defaultState,
|
|
||||||
time: 54_321,
|
|
||||||
type: 'auto'
|
|
||||||
});
|
|
||||||
addHistoryEntry({
|
|
||||||
state: { ...defaultState, code: 'graph TD\\n A[Christmas] -->|Get money| B(Go shopping)' },
|
|
||||||
time: 654_321,
|
|
||||||
type: 'auto'
|
|
||||||
});
|
|
||||||
expect(get(historyStore).length).toBe(2);
|
|
||||||
clearHistoryData();
|
|
||||||
expect(get(historyStore).length).toBe(0);
|
|
||||||
// Test calling when history is empty
|
|
||||||
clearHistoryData();
|
|
||||||
expect(get(historyStore).length).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('history migration', () => {
|
|
||||||
it('should inject history IDs as migration', () => {
|
|
||||||
window.localStorage.setItem(
|
|
||||||
'manualHistoryStore',
|
|
||||||
'[{"state":{"code":"graph TD\\n A[Halloween] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":false},"time":0,"type":"manual","name":"hollow-art"},{"state":{"code":"graph TD\\n A[Christmas] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":true},"time":0,"type":"manual","name":"helpful-ocean"}]'
|
|
||||||
);
|
|
||||||
window.localStorage.setItem(
|
|
||||||
'autoHistoryStore',
|
|
||||||
'[{"state":{"code":"graph TD\\n A[New Year] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":false},"time":0,"type":"auto","name":"barking-dog"},{"state":{"code":"graph TD\\n A[Christmas] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":true},"time":0,"type":"manual","name":"needy-mosquito"}]'
|
|
||||||
);
|
|
||||||
let manualHistoryStore = JSON.parse(
|
|
||||||
window.localStorage.getItem('manualHistoryStore') ?? '[]'
|
|
||||||
) as HistoryEntry[],
|
|
||||||
autoHistoryStore = JSON.parse(
|
|
||||||
window.localStorage.getItem('autoHistoryStore') ?? '[]'
|
|
||||||
) as HistoryEntry[];
|
|
||||||
expect(manualHistoryStore.every(({ id }) => id !== undefined)).toBe(false);
|
|
||||||
expect(autoHistoryStore.every(({ id }) => id !== undefined)).toBe(false);
|
|
||||||
|
|
||||||
injectHistoryIDs();
|
|
||||||
|
|
||||||
manualHistoryStore = JSON.parse(
|
|
||||||
window.localStorage.getItem('manualHistoryStore') ?? '[]'
|
|
||||||
) as HistoryEntry[];
|
|
||||||
autoHistoryStore = JSON.parse(
|
|
||||||
window.localStorage.getItem('autoHistoryStore') ?? '[]'
|
|
||||||
) as HistoryEntry[];
|
|
||||||
expect(manualHistoryStore.every(({ id }) => id !== undefined)).toBe(true);
|
|
||||||
expect(autoHistoryStore.every(({ id }) => id !== undefined)).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
import type { HistoryEntry, HistoryType, Optional } from '$lib/types';
|
|
||||||
import { localStorage, persist } from '$lib/util/persist';
|
|
||||||
import { logEvent } from '$lib/util/stats';
|
|
||||||
import { generateSlug } from 'random-word-slugs';
|
|
||||||
import type { Readable, Writable } from 'svelte/store';
|
|
||||||
import { derived, get, writable } from 'svelte/store';
|
|
||||||
import { v4 as uuidV4 } from 'uuid';
|
|
||||||
|
|
||||||
const MAX_AUTO_HISTORY_LENGTH = 30;
|
|
||||||
|
|
||||||
export const historyModeStore: Writable<HistoryType> = persist(
|
|
||||||
writable('manual'),
|
|
||||||
localStorage(),
|
|
||||||
'autoHistoryMode'
|
|
||||||
);
|
|
||||||
|
|
||||||
const autoHistoryStore: Writable<HistoryEntry[]> = persist(
|
|
||||||
writable([]),
|
|
||||||
localStorage(),
|
|
||||||
'autoHistoryStore'
|
|
||||||
);
|
|
||||||
|
|
||||||
const manualHistoryStore: Writable<HistoryEntry[]> = persist(
|
|
||||||
writable([]),
|
|
||||||
localStorage(),
|
|
||||||
'manualHistoryStore'
|
|
||||||
);
|
|
||||||
|
|
||||||
export const loaderHistoryStore: Writable<HistoryEntry[]> = writable([]);
|
|
||||||
|
|
||||||
export const historyStore: Readable<HistoryEntry[]> = derived(
|
|
||||||
[historyModeStore, autoHistoryStore, manualHistoryStore, loaderHistoryStore],
|
|
||||||
([historyMode, autoHistories, manualHistories, loadedHistories], set) => {
|
|
||||||
switch (historyMode) {
|
|
||||||
case 'auto': {
|
|
||||||
set(autoHistories);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'manual': {
|
|
||||||
set(manualHistories);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'loader': {
|
|
||||||
set(loadedHistories);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
set(autoHistories);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export const addHistoryEntry = (entryToAdd: Optional<HistoryEntry, 'id'>): void => {
|
|
||||||
const entry: HistoryEntry = {
|
|
||||||
...entryToAdd,
|
|
||||||
id: uuidV4()
|
|
||||||
};
|
|
||||||
|
|
||||||
if (entry.type === 'loader') {
|
|
||||||
loaderHistoryStore.update((entries) => [entry, ...entries]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!entry.name) {
|
|
||||||
entry.name = generateSlug(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.type === 'auto') {
|
|
||||||
autoHistoryStore.update((entries) => {
|
|
||||||
if (entries.length >= MAX_AUTO_HISTORY_LENGTH) {
|
|
||||||
entries = entries.slice(0, MAX_AUTO_HISTORY_LENGTH - 1);
|
|
||||||
}
|
|
||||||
return [entry, ...entries];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
manualHistoryStore.update((entries) => [entry, ...entries]);
|
|
||||||
logEvent('history', { action: 'save' });
|
|
||||||
};
|
|
||||||
|
|
||||||
export const clearHistoryData = (idToClear?: string): void => {
|
|
||||||
(get(historyModeStore) === 'auto' ? autoHistoryStore : manualHistoryStore).update((entries) => {
|
|
||||||
if (get(historyModeStore) !== 'loader') {
|
|
||||||
entries = entries.filter(({ id }) => idToClear && id != idToClear);
|
|
||||||
logEvent('history', { action: 'clear', type: idToClear ? 'single' : 'all' });
|
|
||||||
}
|
|
||||||
return entries;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getPreviousState = (auto: boolean): string => {
|
|
||||||
const entries = get(auto ? autoHistoryStore : manualHistoryStore);
|
|
||||||
if (entries.length > 0) {
|
|
||||||
return JSON.stringify(entries[0].state);
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
};
|
|
||||||
|
|
||||||
export const restoreHistory = (data: HistoryEntry[]) => {
|
|
||||||
const entries = data.filter((element) => validateEntry(element));
|
|
||||||
const invalidEntryCount = data.length - entries.length;
|
|
||||||
if (invalidEntryCount > 0) {
|
|
||||||
console.error(`${invalidEntryCount} invalid history entries were removed.`);
|
|
||||||
console.error(data);
|
|
||||||
}
|
|
||||||
if (entries.length > 0) {
|
|
||||||
let entryCount = 0;
|
|
||||||
(entries[0].type === 'auto' ? autoHistoryStore : manualHistoryStore).update((existing) => {
|
|
||||||
const existingIDs = new Set(existing.map(({ id }) => id));
|
|
||||||
const newEntries = entries.filter(({ id }) => !existingIDs.has(id));
|
|
||||||
entryCount = newEntries.length;
|
|
||||||
const combined = [...existing, ...newEntries];
|
|
||||||
combined.sort((a, b) => b.time - a.time);
|
|
||||||
return combined;
|
|
||||||
});
|
|
||||||
|
|
||||||
alert(
|
|
||||||
`${entryCount} entries restored. ${invalidEntryCount} invalid, ${
|
|
||||||
entries.length - entryCount
|
|
||||||
} duplicates.`
|
|
||||||
);
|
|
||||||
logEvent('history', {
|
|
||||||
action: 'restore',
|
|
||||||
success: entryCount,
|
|
||||||
invalid: invalidEntryCount,
|
|
||||||
duplicates: entries.length - entryCount
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
alert('No valid entries found.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const setIDs = (entries: HistoryEntry[]) => {
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (!entry.id) {
|
|
||||||
entry.id = uuidV4();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return entries;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const injectHistoryIDs = (): void => {
|
|
||||||
autoHistoryStore.update(setIDs);
|
|
||||||
manualHistoryStore.update(setIDs);
|
|
||||||
};
|
|
||||||
|
|
||||||
const validateEntry = (entry: HistoryEntry): boolean => {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-expect-error
|
|
||||||
return entry.type && entry.state && entry.time && true;
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
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 * as Popover from '$/components/ui/popover';
|
||||||
import { Switch } from '$/components/ui/switch';
|
import { Switch } from '$/components/ui/switch';
|
||||||
import { env } from '$/util/env';
|
import { env } from '$/util/env';
|
||||||
import { urlsStore } from '$/util/state';
|
import { urls } from '$/util/state.svelte';
|
||||||
import { logMermaidChartClick } from '$/util/stats';
|
import { logMermaidChartClick } from '$/util/stats';
|
||||||
import { cn } from '$/utils';
|
import { cn } from '$/utils';
|
||||||
import { mode, setMode } from 'mode-watcher';
|
import { mode, setMode } from 'mode-watcher';
|
||||||
@@ -28,14 +28,14 @@
|
|||||||
sharesData?: boolean;
|
sharesData?: boolean;
|
||||||
checkDiagramType?: boolean;
|
checkDiagramType?: boolean;
|
||||||
isSectionEnd?: boolean;
|
isSectionEnd?: boolean;
|
||||||
renderer: (item: Omit<MenuItem, 'renderer'>) => ReturnType<Snippet>;
|
renderer: Snippet<[Omit<MenuItem, 'renderer'>]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const menuItems: MenuItem[] = $derived([
|
const menuItems: MenuItem[] = $derived([
|
||||||
{ label: 'New', icon: AddIcon, href: $urlsStore.new, renderer: menuItem },
|
{ label: 'New', icon: AddIcon, href: urls.current.new, renderer: menuItem },
|
||||||
{ label: 'Duplicate', icon: DuplicateIcon, href: window.location.href, renderer: menuItem },
|
{ label: 'Duplicate', icon: DuplicateIcon, href: window.location.href, renderer: menuItem },
|
||||||
{
|
{
|
||||||
href: $urlsStore.mermaidChart({ medium: 'main_menu' }).playground,
|
href: urls.current.mermaidChart({ medium: 'main_menu' }).playground,
|
||||||
icon: PlaygroundIcon,
|
icon: PlaygroundIcon,
|
||||||
isSectionEnd: true,
|
isSectionEnd: true,
|
||||||
label: 'Edit in Playground',
|
label: 'Edit in Playground',
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
checkDiagramType: false,
|
checkDiagramType: false,
|
||||||
href: $urlsStore.mermaidChart({ medium: 'main_menu' }).plugins,
|
href: urls.current.mermaidChart({ medium: 'main_menu' }).plugins,
|
||||||
icon: PluginIcon,
|
icon: PluginIcon,
|
||||||
label: 'Plugins',
|
label: 'Plugins',
|
||||||
onclick: () => logMermaidChartClick('plugins'),
|
onclick: () => logMermaidChartClick('plugins'),
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
{
|
{
|
||||||
checkDiagramType: false,
|
checkDiagramType: false,
|
||||||
class: 'text-accent border-b-0',
|
class: 'text-accent border-b-0',
|
||||||
href: $urlsStore.mermaidChart({ medium: 'main_menu' }).home,
|
href: urls.current.mermaidChart({ medium: 'main_menu' }).home,
|
||||||
icon: MermaidChartIcon,
|
icon: MermaidChartIcon,
|
||||||
label: 'Mermaid',
|
label: 'Mermaid',
|
||||||
onclick: () => logMermaidChartClick('mermaidHome'),
|
onclick: () => logMermaidChartClick('mermaidHome'),
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#snippet menuItem(options: MenuItem)}
|
{#snippet menuItem(options: Omit<MenuItem, 'renderer'>)}
|
||||||
<a
|
<a
|
||||||
href={options.href}
|
href={options.href}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
</a>
|
</a>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet mcMenuItem(item: MenuItem)}
|
{#snippet mcMenuItem(item: Omit<MenuItem, 'renderer'>)}
|
||||||
<McWrapper
|
<McWrapper
|
||||||
side="right"
|
side="right"
|
||||||
labelPrefix={item.sharesData === false ? 'Opens a new tab in' : undefined}
|
labelPrefix={item.sharesData === false ? 'Opens a new tab in' : undefined}
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
</McWrapper>
|
</McWrapper>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet darkModeMenuItem(options: MenuItem)}
|
{#snippet darkModeMenuItem(options: Omit<MenuItem, 'renderer'>)}
|
||||||
<div
|
<div
|
||||||
class={cn(
|
class={cn(
|
||||||
'flex cursor-pointer items-center justify-between border-b-2 px-3 py-2 hover:bg-muted',
|
'flex cursor-pointer items-center justify-between border-b-2 px-3 py-2 hover:bg-muted',
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
Dark Mode
|
Dark Mode
|
||||||
</span>
|
</span>
|
||||||
<Switch
|
<Switch
|
||||||
checked={$mode === 'dark'}
|
checked={mode.current === 'dark'}
|
||||||
onCheckedChange={(dark) => setMode(dark ? 'dark' : 'light')} />
|
onCheckedChange={(dark) => setMode(dark ? 'dark' : 'light')} />
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { asset } from '$app/paths';
|
||||||
import type { ClassValue } from 'svelte/elements';
|
import type { ClassValue } from 'svelte/elements';
|
||||||
|
|
||||||
let { class: className }: { class?: ClassValue } = $props();
|
let { class: className }: { class?: ClassValue } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<img class={['size-4', className]} src="/mermaidchart-logo.svg" alt="Mermaid Chart" />
|
<img class={['size-4', className]} src={asset('/mermaidchart-logo.svg')} alt="Mermaid Chart" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { EditorProps } from '$/types';
|
import type { EditorProps } from '$/types';
|
||||||
import { stateStore } from '$/util/state';
|
import { validatedState } from '$/util/state.svelte';
|
||||||
import { json, jsonLanguage } from '@codemirror/lang-json';
|
import { json, jsonLanguage } from '@codemirror/lang-json';
|
||||||
import { markdown } from '@codemirror/lang-markdown';
|
import { markdown } from '@codemirror/lang-markdown';
|
||||||
import { yamlFrontmatter } from '@codemirror/lang-yaml';
|
import { yamlFrontmatter } from '@codemirror/lang-yaml';
|
||||||
@@ -15,14 +15,22 @@
|
|||||||
|
|
||||||
let editorView: EditorView | undefined;
|
let editorView: EditorView | undefined;
|
||||||
let editorContainer: HTMLDivElement;
|
let editorContainer: HTMLDivElement;
|
||||||
let currentText = $state('');
|
// 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();
|
||||||
|
|
||||||
const { onUpdate }: EditorProps = $props();
|
const { onUpdate }: EditorProps = $props();
|
||||||
|
|
||||||
onMount(() => {
|
$effect(() => {
|
||||||
const themeCompartment = new Compartment();
|
editorView?.dispatch({
|
||||||
const languageCompartment = new Compartment();
|
effects: themeCompartment.reconfigure(mode.current === 'dark' ? vsCodeDark : vsCodeLight)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
editorView = new EditorView({
|
editorView = new EditorView({
|
||||||
state: EditorState.create({
|
state: EditorState.create({
|
||||||
doc: currentText,
|
doc: currentText,
|
||||||
@@ -56,44 +64,37 @@
|
|||||||
parent: editorContainer
|
parent: editorContainer
|
||||||
});
|
});
|
||||||
|
|
||||||
const unsubscribeMode = mode.subscribe((mode) => {
|
|
||||||
editorView?.dispatch({
|
|
||||||
effects: themeCompartment.reconfigure(mode === 'dark' ? vsCodeDark : vsCodeLight)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const unsubscribeState = stateStore.subscribe(({ editorMode, code, mermaid }) => {
|
|
||||||
const text = editorMode === 'code' ? code : mermaid;
|
|
||||||
if (currentText === text || !editorView) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
currentText = text;
|
|
||||||
editorView.dispatch({
|
|
||||||
changes: {
|
|
||||||
from: 0,
|
|
||||||
to: editorView.state.doc.length,
|
|
||||||
insert: text
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const stateLanguage = editorView.state.facet(language);
|
|
||||||
const isStateJson = stateLanguage === jsonLanguage;
|
|
||||||
const isCodeJson = editorMode === 'config';
|
|
||||||
if (stateLanguage && isStateJson === isCodeJson) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
editorView.dispatch({
|
|
||||||
effects: languageCompartment.reconfigure(
|
|
||||||
isCodeJson ? json() : yamlFrontmatter({ content: markdown() })
|
|
||||||
)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubscribeMode();
|
|
||||||
unsubscribeState();
|
|
||||||
editorView?.destroy();
|
editorView?.destroy();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const { editorMode, code, mermaid } = validatedState.current;
|
||||||
|
const text = editorMode === 'code' ? code : mermaid;
|
||||||
|
if (currentText === text || !editorView) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentText = text;
|
||||||
|
editorView.dispatch({
|
||||||
|
changes: {
|
||||||
|
from: 0,
|
||||||
|
to: editorView.state.doc.length,
|
||||||
|
insert: text
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const stateLanguage = editorView.state.facet(language);
|
||||||
|
const isStateJson = stateLanguage === jsonLanguage;
|
||||||
|
const isCodeJson = editorMode === 'config';
|
||||||
|
if (stateLanguage && isStateJson === isCodeJson) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editorView.dispatch({
|
||||||
|
effects: languageCompartment.reconfigure(
|
||||||
|
isCodeJson ? json() : yamlFrontmatter({ content: markdown() })
|
||||||
|
)
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div bind:this={editorContainer} class="size-full"></div>
|
<div bind:this={editorContainer} class="size-full"></div>
|
||||||
|
|||||||
@@ -8,11 +8,12 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import MainMenu from '$/components/MainMenu.svelte';
|
import MainMenu from '$/components/MainMenu.svelte';
|
||||||
import { Button } from '$/components/ui/button';
|
import { Button } from '$/components/ui/button';
|
||||||
import { Separator } from '$/components/ui/separator';
|
import { Separator } from '$/components/ui/separator';
|
||||||
import { dismissPromotion, getActivePromotion } from '$lib/util/promos/promo';
|
import { dismissPromotion, getActivePromotion } from '$lib/util/promos/promo.svelte';
|
||||||
import type { ComponentProps, Snippet } from 'svelte';
|
import { untrack, type ComponentProps, type Snippet } from 'svelte';
|
||||||
import MermaidIcon from '~icons/custom/mermaid';
|
import MermaidIcon from '~icons/custom/mermaid';
|
||||||
import CloseIcon from '~icons/material-symbols/close-rounded';
|
import CloseIcon from '~icons/material-symbols/close-rounded';
|
||||||
import GithubIcon from '~icons/mdi/github';
|
import GithubIcon from '~icons/mdi/github';
|
||||||
@@ -40,7 +41,7 @@
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
let activePromotion = $state(hidePromotion ? undefined : getActivePromotion());
|
let activePromotion = $state(untrack(() => (hidePromotion ? undefined : getActivePromotion())));
|
||||||
|
|
||||||
const trackBannerClick = () => {
|
const trackBannerClick = () => {
|
||||||
if (!activePromotion) {
|
if (!activePromotion) {
|
||||||
@@ -83,7 +84,7 @@
|
|||||||
<div class="flex flex-1 items-center gap-2">
|
<div class="flex flex-1 items-center gap-2">
|
||||||
<MainMenu />
|
<MainMenu />
|
||||||
<MermaidIcon class="size-6" />
|
<MermaidIcon class="size-6" />
|
||||||
<a href="/" class="whitespace-nowrap text-accent">
|
<a href={resolve('/', {})} class="whitespace-nowrap text-accent">
|
||||||
{#if !mobileToggle}
|
{#if !mobileToggle}
|
||||||
Mermaid
|
Mermaid
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { Button } from '$/components/ui/button';
|
import { Button } from '$/components/ui/button';
|
||||||
import { Separator } from '$/components/ui/separator';
|
import { Separator } from '$/components/ui/separator';
|
||||||
import type { PanZoomState } from '$/util/panZoom';
|
import type { PanZoomState } from '$/util/panZoom';
|
||||||
import { urlsStore } from '$/util/state';
|
import { urls } from '$/util/state.svelte';
|
||||||
import ExpandIcon from '~icons/material-symbols/open-in-full-rounded';
|
import ExpandIcon from '~icons/material-symbols/open-in-full-rounded';
|
||||||
import ArrowsToCircleIcon from '~icons/material-symbols/screenshot-frame-2';
|
import ArrowsToCircleIcon from '~icons/material-symbols/screenshot-frame-2';
|
||||||
import MagnifyingGlassPlusIcon from '~icons/material-symbols/zoom-in';
|
import MagnifyingGlassPlusIcon from '~icons/material-symbols/zoom-in';
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
<MagnifyingGlassPlusIcon />
|
<MagnifyingGlassPlusIcon />
|
||||||
</Button>
|
</Button>
|
||||||
<Separator orientation="vertical" class="hidden sm:block" />
|
<Separator orientation="vertical" class="hidden sm:block" />
|
||||||
<Button variant="ghost" size="icon" title="Full Screen" href={$urlsStore.view} target="_blank">
|
<Button variant="ghost" size="icon" title="Full Screen" href={urls.current.view} target="_blank">
|
||||||
<ExpandIcon />
|
<ExpandIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</FloatingToolbar>
|
</FloatingToolbar>
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Card from '$/components/Card/Card.svelte';
|
import Card from '$/components/Card/Card.svelte';
|
||||||
import { Button } from '$/components/ui/button';
|
import { Button, buttonVariants } from '$/components/ui/button';
|
||||||
import { getSampleDiagrams } from '$/util/mermaid';
|
import * as Popover from '$/components/ui/popover';
|
||||||
import { updateCode } from '$lib/util/state';
|
import { getSampleDiagrams, type SampleExample } from '$/util/mermaid';
|
||||||
|
import { updateCode } from '$lib/util/state.svelte';
|
||||||
import { logEvent } from '$lib/util/stats';
|
import { logEvent } from '$lib/util/stats';
|
||||||
|
import { cn } from '$lib/utils';
|
||||||
import ShapesIcon from '~icons/material-symbols/account-tree-outline-rounded';
|
import ShapesIcon from '~icons/material-symbols/account-tree-outline-rounded';
|
||||||
|
import ChevronDownIcon from '~icons/material-symbols/keyboard-arrow-down-rounded';
|
||||||
|
|
||||||
const extras = {
|
const extras: Record<string, SampleExample[]> = {
|
||||||
ZenUML: `zenuml
|
ZenUML: [
|
||||||
|
{
|
||||||
|
title: 'Order Service',
|
||||||
|
isDefault: true,
|
||||||
|
code: `zenuml
|
||||||
title Order Service
|
title Order Service
|
||||||
@Actor Client #FFEBE6
|
@Actor Client #FFEBE6
|
||||||
@Boundary OrderController #0747A6
|
@Boundary OrderController #0747A6
|
||||||
@@ -25,21 +32,24 @@
|
|||||||
if(order != null) {
|
if(order != null) {
|
||||||
par {
|
par {
|
||||||
PurchaseService.createPO(order)
|
PurchaseService.createPO(order)
|
||||||
InvoiceService.createInvoice(order)
|
InvoiceService.createInvoice(order)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
}
|
||||||
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
const samples = { ...getSampleDiagrams(), ...extras } as const;
|
const samples = { ...getSampleDiagrams(), ...extras };
|
||||||
const loadSampleDiagram = (diagramType: string): void => {
|
|
||||||
updateCode(samples[diagramType], {
|
const loadSampleDiagram = (diagramType: string, example: SampleExample): void => {
|
||||||
|
updateCode(example.code, {
|
||||||
resetPanZoom: true,
|
resetPanZoom: true,
|
||||||
updateDiagram: true
|
updateDiagram: true
|
||||||
});
|
});
|
||||||
logEvent('loadSampleDiagram', { diagramType });
|
logEvent('loadSampleDiagram', { diagramType, exampleTitle: example.title });
|
||||||
};
|
};
|
||||||
|
|
||||||
const mainDiagrams = [
|
const mainDiagrams = [
|
||||||
@@ -62,12 +72,39 @@
|
|||||||
<Card title="Sample Diagrams" isOpen isStackable icon={{ component: ShapesIcon }}>
|
<Card title="Sample Diagrams" isOpen isStackable icon={{ component: ShapesIcon }}>
|
||||||
<div class="flex h-fit max-h-52 flex-wrap gap-2 overflow-y-auto p-2">
|
<div class="flex h-fit max-h-52 flex-wrap gap-2 overflow-y-auto p-2">
|
||||||
{#each diagramOrder as sample (sample)}
|
{#each diagramOrder as sample (sample)}
|
||||||
<Button
|
{@const examples = samples[sample]}
|
||||||
size="sm"
|
<div class="flex min-w-20 flex-grow">
|
||||||
class="w-fit min-w-20 flex-grow normal-case"
|
<Button
|
||||||
onclick={() => loadSampleDiagram(sample)}>
|
size="sm"
|
||||||
{sample}
|
class={cn('flex-grow normal-case', examples.length > 1 && 'rounded-r-none')}
|
||||||
</Button>
|
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>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,45 +1,66 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import ExternalLinkWrapper from '$/components/ExternalLinkWrapper.svelte';
|
import ExternalLinkWrapper from '$/components/ExternalLinkWrapper.svelte';
|
||||||
import * as Dialog from '$/components/ui/dialog';
|
import * as Dialog from '$/components/ui/dialog';
|
||||||
|
import { env } from '$/util/env';
|
||||||
|
import { isOnMermaidLive } from '$/util/migration/domainMigration';
|
||||||
import ShieldIcon from '~icons/material-symbols/shield-lock-outline-rounded';
|
import ShieldIcon from '~icons/material-symbols/shield-lock-outline-rounded';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root>
|
{#if env.privacyPolicyUrl}
|
||||||
<Dialog.Trigger>
|
<a href={env.privacyPolicyUrl} target="_blank">
|
||||||
<ShieldIcon />
|
<ShieldIcon />
|
||||||
</Dialog.Trigger>
|
</a>
|
||||||
<Dialog.Content class="max-h-full overflow-hidden overflow-y-auto p-12">
|
{:else}
|
||||||
<Dialog.Header>
|
<Dialog.Root>
|
||||||
<Dialog.Title class="flex items-center gap-2 text-xl">
|
<Dialog.Trigger>
|
||||||
<ShieldIcon class="size-8 text-green-700" />
|
<ShieldIcon />
|
||||||
Data security
|
</Dialog.Trigger>
|
||||||
</Dialog.Title>
|
<Dialog.Content class="max-h-full overflow-hidden overflow-y-auto p-12">
|
||||||
</Dialog.Header>
|
<Dialog.Header>
|
||||||
|
<Dialog.Title class="flex items-center gap-2 text-xl">
|
||||||
|
<ShieldIcon class="size-8 text-green-700" />
|
||||||
|
Data security
|
||||||
|
</Dialog.Title>
|
||||||
|
</Dialog.Header>
|
||||||
|
|
||||||
<p class="text-xl font-semibold">Your diagrams never leave your browser.</p>
|
{#if isOnMermaidLive()}
|
||||||
<p>They're only stored in the URL and your browser's local storage.</p>
|
<p class="text-xl font-semibold">Your diagrams never leave your browser.</p>
|
||||||
<p>
|
<p>They're only stored in the URL and your browser's local storage.</p>
|
||||||
This is a fully open source, client-side app deployed on <a
|
<p>
|
||||||
href="https://github.com/mermaid-js/mermaid-live-editor/deployments"
|
This is a fully open source, client-side app deployed on <a
|
||||||
class="underline"
|
href="https://github.com/mermaid-js/mermaid-live-editor/deployments"
|
||||||
target="_blank">GitHub Pages</a>
|
class="underline"
|
||||||
that works offline as a
|
target="_blank">GitHub Pages</a>
|
||||||
<a href="https://web.dev/explore/progressive-web-apps" target="_blank">Progressive Web App</a
|
that works offline as a
|
||||||
>.
|
<a href="https://web.dev/explore/progressive-web-apps" target="_blank"
|
||||||
</p>
|
>Progressive Web App</a
|
||||||
<p>
|
>.
|
||||||
We use self hosted, privacy-friendly Plausible Analytics to collect anonymous usage metadata
|
</p>
|
||||||
(diagram types, feature usage, etc.). All data is <a
|
<p>
|
||||||
href="https://p.mermaid.live/mermaid.live"
|
We use self hosted, privacy-friendly Plausible Analytics to collect anonymous usage
|
||||||
class="underline"
|
metadata (diagram types, feature usage, etc.). All data is <a
|
||||||
target="_blank">publicly available</a
|
href="https://p.mermaid.live/mermaid.live"
|
||||||
>.
|
class="underline"
|
||||||
</p>
|
target="_blank">publicly available</a
|
||||||
<ExternalLinkWrapper domain="example.com" isVisible>
|
>.
|
||||||
<p class="text-left">
|
</p>
|
||||||
External services (PNG/SVG/Kroki exports, "Save to Mermaid Chart", "Repair with AI", etc)
|
<ExternalLinkWrapper domain="example.com" isVisible>
|
||||||
will share your diagram with those 3rd parties, and are highlighted in the UI on hover.
|
<p class="text-left">
|
||||||
</p>
|
External services (PNG/SVG/Kroki exports, "Save to Mermaid Chart", "Repair with AI",
|
||||||
</ExternalLinkWrapper>
|
etc) will share your diagram with those 3rd parties, and are highlighted in the UI on
|
||||||
</Dialog.Content>
|
hover.
|
||||||
</Dialog.Root>
|
</p>
|
||||||
|
</ExternalLinkWrapper>
|
||||||
|
{:else}
|
||||||
|
<p>No privacy policy has been configured for this deployment.</p>
|
||||||
|
<p>
|
||||||
|
If you are self-hosting the Mermaid Live Editor, set the
|
||||||
|
<code class="rounded bg-muted px-1.5 py-0.5 text-sm">MERMAID_PRIVACY_POLICY_URL</code>
|
||||||
|
environment variable at build time to link to your privacy policy, or set
|
||||||
|
<code class="rounded bg-muted px-1.5 py-0.5 text-sm">MERMAID_HIDE_PRIVACY_POLICY</code>
|
||||||
|
to <code class="rounded bg-muted px-1.5 py-0.5 text-sm">true</code> to hide this button.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Root>
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
import { buttonVariants } from '$/components/ui/button';
|
import { buttonVariants } from '$/components/ui/button';
|
||||||
import * as Dialog from '$/components/ui/dialog';
|
import * as Dialog from '$/components/ui/dialog';
|
||||||
import { Separator } from '$/components/ui/separator';
|
import { Separator } from '$/components/ui/separator';
|
||||||
import { env } from '$/util/env';
|
import { env } from '$/util/env';
|
||||||
import { urlsStore } from '$/util/state';
|
import { urls } from '$/util/state.svelte';
|
||||||
|
import { asset } from '$app/paths';
|
||||||
import ShareIcon from '~icons/material-symbols/share';
|
import ShareIcon from '~icons/material-symbols/share';
|
||||||
import CopyInput from './CopyInput.svelte';
|
import CopyInput from './CopyInput.svelte';
|
||||||
import MermaidChartIcon from './MermaidChartIcon.svelte';
|
import MermaidChartIcon from './MermaidChartIcon.svelte';
|
||||||
@@ -22,7 +23,7 @@
|
|||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<h2 class="flex items-center gap-2">
|
<h2 class="flex items-center gap-2">
|
||||||
<img class="size-5" src="/favicon.svg" alt="Mermaid Live Editor" />
|
<img class="size-5" src={asset('/favicon.svg')} alt="Mermaid Live Editor" />
|
||||||
Mermaid Live Editor
|
Mermaid Live Editor
|
||||||
</h2>
|
</h2>
|
||||||
<CopyInput value={window.location.href} />
|
<CopyInput value={window.location.href} />
|
||||||
@@ -37,7 +38,7 @@
|
|||||||
<MermaidChartIcon class="size-5" />
|
<MermaidChartIcon class="size-5" />
|
||||||
Mermaid Chart Playground
|
Mermaid Chart Playground
|
||||||
</h2>
|
</h2>
|
||||||
<CopyInput value={$urlsStore.mermaidChart({ medium: 'share' }).playground} />
|
<CopyInput value={urls.current.mermaidChart({ medium: 'share' }).playground} />
|
||||||
<Dialog.Description>
|
<Dialog.Description>
|
||||||
Opens the Mermaid Chart Playground with Mermaid AI, Visual Editor, and more.
|
Opens the Mermaid Chart Playground with Mermaid AI, Visual Editor, and more.
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
|
|||||||
@@ -1,21 +1,27 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import FloatingToolbar from '$/components/FloatingToolbar.svelte';
|
import FloatingToolbar from '$/components/FloatingToolbar.svelte';
|
||||||
import { Toggle } from '$/components/ui/toggle';
|
import { Toggle } from '$/components/ui/toggle';
|
||||||
import { defaultState, inputStateStore } from '$/util/state';
|
import { defaultState, inputState, updateCodeStore } from '$/util/state.svelte';
|
||||||
import RoughIcon from '~icons/material-symbols/draw-outline-rounded';
|
import RoughIcon from '~icons/material-symbols/draw-outline-rounded';
|
||||||
import BackgroundIcon from '~icons/material-symbols/grid-4x4-rounded';
|
import BackgroundIcon from '~icons/material-symbols/grid-4x4-rounded';
|
||||||
|
|
||||||
if ($inputStateStore.grid === undefined) {
|
if (inputState.grid === undefined) {
|
||||||
// Handle cases where old states were saved without grid option
|
// Handle cases where old states were saved without grid option
|
||||||
$inputStateStore.grid = defaultState.grid;
|
updateCodeStore({ grid: defaultState.grid });
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<FloatingToolbar>
|
<FloatingToolbar>
|
||||||
<Toggle bind:pressed={$inputStateStore.rough} size="sm" title="Hand-Drawn">
|
<Toggle
|
||||||
|
bind:pressed={() => inputState.rough, (rough) => updateCodeStore({ rough })}
|
||||||
|
size="sm"
|
||||||
|
title="Hand-Drawn">
|
||||||
<RoughIcon />
|
<RoughIcon />
|
||||||
</Toggle>
|
</Toggle>
|
||||||
<Toggle bind:pressed={$inputStateStore.grid} size="sm" title="Background Grid">
|
<Toggle
|
||||||
|
bind:pressed={() => inputState.grid ?? defaultState.grid, (grid) => updateCodeStore({ grid })}
|
||||||
|
size="sm"
|
||||||
|
title="Background Grid">
|
||||||
<BackgroundIcon />
|
<BackgroundIcon />
|
||||||
</Toggle>
|
</Toggle>
|
||||||
</FloatingToolbar>
|
</FloatingToolbar>
|
||||||
|
|||||||
@@ -24,12 +24,12 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="inline-grid">
|
<div class="inline-grid">
|
||||||
{#key $mode}
|
{#key mode.current}
|
||||||
<div
|
<div
|
||||||
in:spin={{ clockWise: true }}
|
in:spin={{ clockWise: true }}
|
||||||
out:spin={{ clockWise: false }}
|
out:spin={{ clockWise: false }}
|
||||||
class="col-start-1 row-start-1">
|
class="col-start-1 row-start-1">
|
||||||
{#if $mode === 'dark'}
|
{#if mode.current === 'dark'}
|
||||||
<MoonIcon />
|
<MoonIcon />
|
||||||
{:else}
|
{:else}
|
||||||
<SunIcon />
|
<SunIcon />
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { Button } from '$/components/ui/button';
|
import { Button } from '$/components/ui/button';
|
||||||
import { Separator } from '$/components/ui/separator';
|
import { Separator } from '$/components/ui/separator';
|
||||||
import { TID } from '$/constants';
|
import { TID } from '$/constants';
|
||||||
|
import { env } from '$/util/env';
|
||||||
import { version } from 'mermaid/package.json';
|
import { version } from 'mermaid/package.json';
|
||||||
import { mode, setMode } from 'mode-watcher';
|
import { mode, setMode } from 'mode-watcher';
|
||||||
import ThemeIcon from './ThemeIcon.svelte';
|
import ThemeIcon from './ThemeIcon.svelte';
|
||||||
@@ -11,18 +12,20 @@
|
|||||||
|
|
||||||
<FloatingToolbar>
|
<FloatingToolbar>
|
||||||
<span class="text-sm font-semibold opacity-60">v{version}</span>
|
<span class="text-sm font-semibold opacity-60">v{version}</span>
|
||||||
<Button variant="ghost" size="icon" title="Privacy & Security">
|
{#if !env.hidePrivacyPolicy}
|
||||||
<Privacy />
|
<Button variant="ghost" size="icon" title="Privacy & Security">
|
||||||
</Button>
|
<Privacy />
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Separator orientation="vertical" />
|
<Separator orientation="vertical" />
|
||||||
|
{/if}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
data-testid={TID.themeToggleButton}
|
data-testid={TID.themeToggleButton}
|
||||||
title="Switch to {$mode === 'dark' ? 'light' : 'dark'} theme"
|
title="Switch to {mode.current === 'dark' ? 'light' : 'dark'} theme"
|
||||||
class="[&_svg]:size-5"
|
class="[&_svg]:size-5"
|
||||||
onclick={() => setMode($mode === 'dark' ? 'light' : 'dark')}>
|
onclick={() => setMode(mode.current === 'dark' ? 'light' : 'dark')}>
|
||||||
<ThemeIcon />
|
<ThemeIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</FloatingToolbar>
|
</FloatingToolbar>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import { recordRenderTime, shouldRefreshView } from '$/util/autoSync';
|
import { recordRenderTime, shouldRefreshView } from '$/util/autoSync';
|
||||||
import { render as renderDiagram } from '$/util/mermaid';
|
import { render as renderDiagram } from '$/util/mermaid';
|
||||||
import { PanZoomState } from '$/util/panZoom';
|
import { PanZoomState } from '$/util/panZoom';
|
||||||
import { inputStateStore, stateStore, updateCodeStore } from '$/util/state';
|
import { updateCodeStore, validatedState } from '$/util/state.svelte';
|
||||||
import { logEvent, saveStatistics } from '$/util/stats';
|
import { saveStatistics } from '$/util/stats';
|
||||||
import FontAwesome, { mayContainFontAwesome } from '$lib/components/FontAwesome.svelte';
|
import FontAwesome, { mayContainFontAwesome } from '$lib/components/FontAwesome.svelte';
|
||||||
import uniqueID from 'lodash-es/uniqueId';
|
import uniqueID from 'lodash-es/uniqueId';
|
||||||
import type { MermaidConfig } from 'mermaid';
|
import type { MermaidConfig } from 'mermaid';
|
||||||
@@ -30,7 +30,6 @@
|
|||||||
const setupPanZoomObserver = () => {
|
const setupPanZoomObserver = () => {
|
||||||
panZoomState.onPanZoomChange = (pan, zoom) => {
|
panZoomState.onPanZoomChange = (pan, zoom) => {
|
||||||
updateCodeStore({ pan, zoom });
|
updateCodeStore({ pan, zoom });
|
||||||
logEvent('panZoom');
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -134,18 +133,20 @@
|
|||||||
const renderTime = Date.now() - startTime;
|
const renderTime = Date.now() - startTime;
|
||||||
saveStatistics({ code, diagramType, isRough: state.rough, renderTime });
|
saveStatistics({ code, diagramType, isRough: state.rough, renderTime });
|
||||||
recordRenderTime(renderTime, () => {
|
recordRenderTime(renderTime, () => {
|
||||||
$inputStateStore.updateDiagram = true;
|
updateCodeStore({ updateDiagram: true });
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
setupPanZoomObserver();
|
setupPanZoomObserver();
|
||||||
// Queue state changes to avoid race condition
|
});
|
||||||
let pendingStateChange = Promise.resolve();
|
|
||||||
stateStore.subscribe((state) => {
|
// Queue state changes to avoid race condition
|
||||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
let pendingStateChange = Promise.resolve();
|
||||||
pendingStateChange = pendingStateChange.then(() => handleStateChange(state).catch(() => {}));
|
$effect(() => {
|
||||||
});
|
const state = validatedState.current;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||||
|
pendingStateChange = pendingStateChange.then(() => handleStateChange(state).catch(() => {}));
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -154,7 +155,7 @@
|
|||||||
<div
|
<div
|
||||||
id="view"
|
id="view"
|
||||||
bind:this={view}
|
bind:this={view}
|
||||||
class={['h-full w-full', shouldShowGrid && `grid-bg-${$mode}`, error && 'opacity-50']}>
|
class={['h-full w-full', shouldShowGrid && `grid-bg-${mode.current}`, error && 'opacity-50']}>
|
||||||
<div id="container" bind:this={container} class="h-full overflow-auto"></div>
|
<div id="container" bind:this={container} class="h-full overflow-auto"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,135 +1,101 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import McWrapper from '$/components/McWrapper.svelte';
|
||||||
|
import MermaidChartIcon from '$/components/MermaidChartIcon.svelte';
|
||||||
|
import PrivacyPolicyLink from '$/components/migration/PrivacyPolicyLink.svelte';
|
||||||
import { Button } from '$/components/ui/button';
|
import { Button } from '$/components/ui/button';
|
||||||
import * as Dialog from '$/components/ui/dialog';
|
import * as Dialog from '$/components/ui/dialog';
|
||||||
import { dismissEditorChooser } from '$/util/migration/domainMigration';
|
import { dismissEditorChooser } from '$/util/migration/domainMigration';
|
||||||
import { logEvent, logMermaidChartClick } from '$/util/stats';
|
import type { Component } from 'svelte';
|
||||||
import { getCheckoutUrl } from '$/util/util';
|
import AtlassianIcon from '~icons/logos/atlassian';
|
||||||
import CodeIcon from '~icons/custom/code';
|
import AmazonIcon from '~icons/logos/aws';
|
||||||
import OpenSourceIcon from '~icons/material-symbols/book-2-outline-rounded';
|
import GoogleIcon from '~icons/logos/google';
|
||||||
import ChatIcon from '~icons/material-symbols/chat-outline-rounded';
|
import MicrosoftIcon from '~icons/logos/microsoft';
|
||||||
import EditIcon from '~icons/material-symbols/edit-outline-rounded';
|
import { createEditorChooserActions } from './editorChooserActions';
|
||||||
import HistoryIcon from '~icons/material-symbols/history';
|
|
||||||
import HomeIcon from '~icons/material-symbols/home-storage-outline-rounded';
|
|
||||||
import SparklesIcon from '~icons/material-symbols/kid-star-outline';
|
|
||||||
import LanguageIcon from '~icons/material-symbols/language';
|
|
||||||
import WidthIcon from '~icons/material-symbols/width-rounded';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { open = $bindable() }: Props = $props();
|
let { open = $bindable() }: Props = $props();
|
||||||
|
// Tracks whether the current close was triggered by an explicit action
|
||||||
const close = () => {
|
// (button click logs its own event) vs. implicit dismissal (ESC/click-outside).
|
||||||
dismissEditorChooser();
|
let handled = false;
|
||||||
|
const actions = createEditorChooserActions(() => {
|
||||||
|
handled = true;
|
||||||
open = false;
|
open = false;
|
||||||
};
|
});
|
||||||
|
|
||||||
const handleStartTrial = () => {
|
const features = [
|
||||||
logEvent('chooseEditor', { choice: 'plus' });
|
{ title: 'AI diagram generation', description: 'Describe what you need, AI builds it' },
|
||||||
logMermaidChartClick('editorPicker');
|
{
|
||||||
close();
|
title: 'Visual drag-and-drop editor',
|
||||||
window.open(
|
description: 'Edit diagrams without writing code'
|
||||||
getCheckoutUrl({ utmCampaign: 'start_plus', utmMedium: '2_editor_selection' }),
|
},
|
||||||
'_blank'
|
{
|
||||||
);
|
title: 'Unlimited diagram storage',
|
||||||
};
|
description: 'Save and organize all your diagrams'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Team collaboration',
|
||||||
|
description: 'Share, comment, and edit together in real-time'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
const handleStartFree = () => {
|
const trustedLogos: { name: string; icon: Component }[] = [
|
||||||
logEvent('chooseEditor', { choice: 'openSource' });
|
{ name: 'Google', icon: GoogleIcon },
|
||||||
close();
|
{ name: 'Microsoft', icon: MicrosoftIcon },
|
||||||
};
|
{ name: 'Atlassian', icon: AtlassianIcon },
|
||||||
|
{ name: 'Amazon', icon: AmazonIcon }
|
||||||
|
];
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog.Root
|
<Dialog.Root
|
||||||
bind:open
|
bind:open
|
||||||
onOpenChange={(v) => {
|
onOpenChange={(v) => {
|
||||||
if (!v) handleStartFree();
|
if (v) return;
|
||||||
|
if (!handled) actions.log('dismissed');
|
||||||
|
handled = false;
|
||||||
|
dismissEditorChooser();
|
||||||
}}>
|
}}>
|
||||||
<Dialog.Content class="max-w-2xl bg-pink-50 p-0 dark:bg-background">
|
<Dialog.Content class="flex max-w-lg flex-col gap-3 bg-background p-8">
|
||||||
<Dialog.Header class="px-8 pt-8 pb-0">
|
<Dialog.Header class="flex-col items-start gap-2 space-y-0 text-left sm:text-left">
|
||||||
<Dialog.Title class="text-center text-2xl font-semibold">Choose your editor</Dialog.Title>
|
<MermaidChartIcon class="size-10" />
|
||||||
<Dialog.Description class="-mt-2 text-center text-sm font-light">
|
<Dialog.Title class="pt-2 text-2xl font-bold">Try the full Mermaid experience</Dialog.Title>
|
||||||
You'll never see this again
|
<Dialog.Description class="text-xs font-light text-muted-foreground">
|
||||||
|
Free forever, with Plus features free for 7 days.
|
||||||
</Dialog.Description>
|
</Dialog.Description>
|
||||||
</Dialog.Header>
|
</Dialog.Header>
|
||||||
|
|
||||||
<div class="grid gap-4 px-6 pt-4 pb-8 sm:grid-cols-2">
|
<ul class="mt-2 flex flex-col gap-3">
|
||||||
<!-- Mermaid Plus Card -->
|
{#each features as feature (feature.title)}
|
||||||
<div
|
<li class="flex items-center gap-3 rounded-lg border border-border p-3">
|
||||||
class="relative flex flex-col overflow-hidden rounded-xl border-2 border-accent bg-white shadow dark:bg-card">
|
<span class="size-2 shrink-0 rounded-full bg-accent"></span>
|
||||||
<div class="bg-accent px-6 py-2 text-center text-sm font-semibold text-accent-foreground">
|
<div class="flex flex-col gap-0.5">
|
||||||
Recommended
|
<p class="text-xs">{feature.title}</p>
|
||||||
</div>
|
<p class="text-xs font-light text-muted-foreground">{feature.description}</p>
|
||||||
|
|
||||||
<div class="flex flex-col p-6">
|
|
||||||
<h3 class="text-xl font-bold">Mermaid Plus</h3>
|
|
||||||
<p class="mb-4 text-sm text-muted-foreground">Unlock AI, storage and collaboration</p>
|
|
||||||
|
|
||||||
<div class="mb-2 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>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
|
||||||
<Button variant="accent" class="mb-6 w-full" onclick={handleStartTrial}>
|
<div class="mt-2 flex items-center gap-3">
|
||||||
Start free trial
|
<McWrapper labelPrefix="Opens ">
|
||||||
</Button>
|
<Button variant="accent" onclick={() => actions.startTrial()}>Start free trial</Button>
|
||||||
|
</McWrapper>
|
||||||
|
<Button variant="outline" onclick={() => actions.dismiss('stayOnLive')}>
|
||||||
|
Stay on mermaid.live
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ul class="space-y-3 text-sm">
|
<div class="mt-3 flex flex-col items-start gap-4">
|
||||||
<li class="flex items-center gap-2">
|
<p class="text-xs">Trusted by 5M people and over 200k companies</p>
|
||||||
<EditIcon class="size-4 shrink-0 text-muted-foreground" />
|
<div class="flex w-full items-center justify-between gap-2">
|
||||||
Visual editor
|
{#each trustedLogos as logo (logo.name)}
|
||||||
</li>
|
<logo.icon class="h-6 w-auto grayscale" aria-label={logo.name} />
|
||||||
<li class="flex items-center gap-2">
|
{/each}
|
||||||
<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 rounded-xl border bg-white p-6 shadow dark:bg-card">
|
|
||||||
<h3 class="mt-10 text-xl font-bold">Open Source</h3>
|
|
||||||
<p class="mb-4 text-sm text-muted-foreground">Code only, no login, always free</p>
|
|
||||||
|
|
||||||
<Button variant="outline" class="mb-6 w-full border-accent" onclick={handleStartFree}>
|
|
||||||
Start free
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<ul class="space-y-3 text-sm">
|
|
||||||
<li class="flex items-center gap-2">
|
|
||||||
<LanguageIcon class="size-4 shrink-0 text-muted-foreground" />
|
|
||||||
Diagram stored in URL
|
|
||||||
</li>
|
|
||||||
<li class="flex items-center gap-2">
|
|
||||||
<CodeIcon class="size-4 shrink-0 text-muted-foreground" />
|
|
||||||
Code editor
|
|
||||||
</li>
|
|
||||||
<li class="flex items-center gap-2">
|
|
||||||
<OpenSourceIcon class="size-4 shrink-0 text-muted-foreground" />
|
|
||||||
Open source
|
|
||||||
</li>
|
|
||||||
<li class="flex items-center gap-2">
|
|
||||||
<HistoryIcon class="size-4 shrink-0 text-muted-foreground" />
|
|
||||||
Version history
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PrivacyPolicyLink />
|
||||||
</Dialog.Content>
|
</Dialog.Content>
|
||||||
</Dialog.Root>
|
</Dialog.Root>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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>
|
</script>
|
||||||
|
|
||||||
<Sonner
|
<Sonner
|
||||||
theme={$mode}
|
theme={mode.current}
|
||||||
class="toaster group"
|
class="toaster group"
|
||||||
toastOptions={{
|
toastOptions={{
|
||||||
classes: {
|
classes: {
|
||||||
|
|||||||
@@ -24,8 +24,12 @@
|
|||||||
}: ToggleGroupPrimitive.RootProps & ToggleVariants = $props();
|
}: ToggleGroupPrimitive.RootProps & ToggleVariants = $props();
|
||||||
|
|
||||||
setToggleGroupCtx({
|
setToggleGroupCtx({
|
||||||
variant,
|
get variant() {
|
||||||
size
|
return variant;
|
||||||
|
},
|
||||||
|
get size() {
|
||||||
|
return size;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ export const env = {
|
|||||||
analyticsUrl: import.meta.env.MERMAID_ANALYTICS_URL ?? '',
|
analyticsUrl: import.meta.env.MERMAID_ANALYTICS_URL ?? '',
|
||||||
docsUrl: import.meta.env.MERMAID_DOCS_URL ?? 'https://mermaid.js.org',
|
docsUrl: import.meta.env.MERMAID_DOCS_URL ?? 'https://mermaid.js.org',
|
||||||
domain: import.meta.env.MERMAID_DOMAIN ?? '',
|
domain: import.meta.env.MERMAID_DOMAIN ?? '',
|
||||||
|
hidePrivacyPolicy: import.meta.env.MERMAID_HIDE_PRIVACY_POLICY === 'true',
|
||||||
isEnabledMermaidChartLinks: import.meta.env.MERMAID_IS_ENABLED_MERMAID_CHART_LINKS === 'true',
|
isEnabledMermaidChartLinks: import.meta.env.MERMAID_IS_ENABLED_MERMAID_CHART_LINKS === 'true',
|
||||||
krokiRendererUrl: import.meta.env.MERMAID_KROKI_RENDERER_URL ?? '',
|
krokiRendererUrl: import.meta.env.MERMAID_KROKI_RENDERER_URL ?? '',
|
||||||
|
privacyPolicyUrl: import.meta.env.MERMAID_PRIVACY_POLICY_URL ?? '',
|
||||||
rendererUrl: import.meta.env.MERMAID_RENDERER_URL ?? ''
|
rendererUrl: import.meta.env.MERMAID_RENDERER_URL ?? ''
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
import { addHistoryEntry } from '$lib/components/History/history';
|
import { setLoaderEntries } from '$lib/components/History/historyState.svelte';
|
||||||
import type { State } from '$lib/types';
|
import type { State } from '$lib/types';
|
||||||
import { defaultState } from '$lib/util/state';
|
import { defaultState } from '$lib/util/state.svelte';
|
||||||
import { fetchJSON, fetchText } from '$lib/util/util';
|
import { fetchJSON, fetchText } from '$lib/util/util';
|
||||||
|
|
||||||
const codeFileName = 'code.mmd';
|
const codeFileName = 'code.mmd';
|
||||||
@@ -117,14 +117,16 @@ export const loadGistData = async (gistURL: string): Promise<State> => {
|
|||||||
throw new Error('Invalid gist provided');
|
throw new Error('Invalid gist provided');
|
||||||
}
|
}
|
||||||
const state = getStateFromGist(entry, gistURL);
|
const state = getStateFromGist(entry, gistURL);
|
||||||
for (const gist of gistHistory) {
|
setLoaderEntries(
|
||||||
addHistoryEntry({
|
gistHistory
|
||||||
name: `${gist.author} v${gist.version}`,
|
.map((gist) => ({
|
||||||
state: getStateFromGist(gist),
|
name: `${gist.author} v${gist.version}`,
|
||||||
time: gist.time,
|
state: getStateFromGist(gist),
|
||||||
type: 'loader',
|
time: gist.time,
|
||||||
url: gist.url
|
type: 'loader' as const,
|
||||||
});
|
url: gist.url
|
||||||
}
|
}))
|
||||||
|
.reverse()
|
||||||
|
);
|
||||||
return state;
|
return state;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Loader, State } from '$lib/types';
|
import type { Loader, State } from '$lib/types';
|
||||||
import { defaultState, updateCodeStore } from '$lib/util/state';
|
import { defaultState, sanitizeConfig, updateCodeStore } from '$lib/util/state.svelte';
|
||||||
import { fetchText } from '$lib/util/util';
|
import { fetchText } from '$lib/util/util';
|
||||||
import { loadGistData } from './gist';
|
import { loadGistData } from './gist';
|
||||||
|
|
||||||
@@ -50,6 +50,7 @@ export const loadDataFromUrl = async (): Promise<void> => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (loaded) {
|
if (loaded) {
|
||||||
|
state.mermaid = sanitizeConfig(state.mermaid || defaultState.mermaid);
|
||||||
updateCodeStore({
|
updateCodeStore({
|
||||||
...state,
|
...state,
|
||||||
updateDiagram: true
|
updateDiagram: true
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
+16
-11
@@ -24,6 +24,11 @@ export const parse = async (code: string) => {
|
|||||||
return await mermaid.parse(code);
|
return await mermaid.parse(code);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see https://mermaid.js.org/config/schema-docs/config.html
|
||||||
|
*/
|
||||||
|
export const defaultMermaidConfig = mermaid.mermaidAPI.defaultConfig ?? {};
|
||||||
|
|
||||||
export const standardizeDiagramType = (diagramType: string) => {
|
export const standardizeDiagramType = (diagramType: string) => {
|
||||||
switch (diagramType) {
|
switch (diagramType) {
|
||||||
case 'class':
|
case 'class':
|
||||||
@@ -44,20 +49,20 @@ export const standardizeDiagramType = (diagramType: string) => {
|
|||||||
|
|
||||||
type DiagramDefinition = (typeof diagramData)[number];
|
type DiagramDefinition = (typeof diagramData)[number];
|
||||||
|
|
||||||
|
export type SampleExample = DiagramDefinition['examples'][number];
|
||||||
|
|
||||||
const isValidDiagram = (diagram: DiagramDefinition): diagram is Required<DiagramDefinition> => {
|
const isValidDiagram = (diagram: DiagramDefinition): diagram is Required<DiagramDefinition> => {
|
||||||
return Boolean(diagram.name && diagram.examples && diagram.examples.length > 0);
|
return Boolean(diagram.name && diagram.examples && diagram.examples.length > 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSampleDiagrams = () => {
|
export const getSampleDiagrams = (): Record<string, SampleExample[]> => {
|
||||||
const diagrams = diagramData
|
const samples: Record<string, SampleExample[]> = {};
|
||||||
.filter((d) => isValidDiagram(d))
|
for (const diagram of diagramData.filter((d) => isValidDiagram(d))) {
|
||||||
.map(({ examples, ...rest }) => ({
|
// The default example comes first, so it is loaded when clicking the
|
||||||
...rest,
|
// diagram name and shown at the top of the example dropdown.
|
||||||
example: examples?.filter(({ isDefault }) => isDefault)[0]
|
samples[diagram.name.replace(/ (Diagram|Chart|Graph)/, '')] = [...diagram.examples].sort(
|
||||||
}));
|
(a, b) => Number(b.isDefault ?? false) - Number(a.isDefault ?? false)
|
||||||
const examples: Record<string, string> = {};
|
);
|
||||||
for (const diagram of diagrams) {
|
|
||||||
examples[diagram.name.replace(/ (Diagram|Chart|Graph)/, '')] = diagram.example.code;
|
|
||||||
}
|
}
|
||||||
return examples;
|
return samples;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { C } from '$/constants';
|
|||||||
import { env } from '$/util/env';
|
import { env } from '$/util/env';
|
||||||
|
|
||||||
const mermaidAiDomain = 'mermaid.ai';
|
const mermaidAiDomain = 'mermaid.ai';
|
||||||
|
const mermaidLiveDomain = 'mermaid.live';
|
||||||
|
const netlifyPreviewDomain = 'netlify.app';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if we're on mermaid.ai
|
* Check if we're on mermaid.ai
|
||||||
@@ -11,25 +13,19 @@ export const isOnMermaidAI = (): boolean => {
|
|||||||
return domain === mermaidAiDomain || domain.endsWith(`.${mermaidAiDomain}`);
|
return domain === mermaidAiDomain || domain.endsWith(`.${mermaidAiDomain}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
// localStorage keys that indicate a returning user.
|
/**
|
||||||
// Note: codeStore is excluded because it's always populated with the default state on first load.
|
* Check if we're on mermaid.live
|
||||||
const userDataStorageKeys = [
|
*/
|
||||||
'manualHistoryStore', // Manual history entries
|
export const isOnMermaidLive = (): boolean => {
|
||||||
'autoHistoryStore' // Auto history entries
|
const domain = window.location.hostname;
|
||||||
];
|
return domain === mermaidLiveDomain || domain.endsWith(`.${mermaidLiveDomain}`);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if user has any stored data in localStorage.
|
* Check if we're on a Netlify preview/staging deploy (*.netlify.app).
|
||||||
* This includes saved diagrams, history entries, etc.
|
|
||||||
*/
|
*/
|
||||||
const hasStoredUserData = (): boolean => {
|
const isOnNetlifyPreview = (): boolean => {
|
||||||
for (const key of userDataStorageKeys) {
|
return window.location.hostname.endsWith(`.${netlifyPreviewDomain}`);
|
||||||
const value = window.localStorage.getItem(key);
|
|
||||||
if (value && value !== '[]' && value !== 'null' && value !== '{}') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -54,7 +50,9 @@ const isReferredFromMermaid = (): boolean => {
|
|||||||
hostname === 'mermaid.ai' ||
|
hostname === 'mermaid.ai' ||
|
||||||
hostname.endsWith('.mermaid.ai') ||
|
hostname.endsWith('.mermaid.ai') ||
|
||||||
hostname === 'mermaid.js.org' ||
|
hostname === 'mermaid.js.org' ||
|
||||||
hostname.endsWith('.mermaid.js.org')
|
hostname.endsWith('.mermaid.js.org') ||
|
||||||
|
hostname === 'mermaid.live' ||
|
||||||
|
hostname.endsWith('.mermaid.live')
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
@@ -65,12 +63,16 @@ const isReferredFromMermaid = (): boolean => {
|
|||||||
* Check if the editor chooser modal should be shown.
|
* Check if the editor chooser modal should be shown.
|
||||||
* Shows for new users who haven't dismissed it and aren't viewing a shared link.
|
* Shows for new users who haven't dismissed it and aren't viewing a shared link.
|
||||||
* Not shown on mobile (viewport width < 640px).
|
* Not shown on mobile (viewport width < 640px).
|
||||||
|
* Can be forced open for QA via the `?editorChooser=1` query flag, which bypasses
|
||||||
|
* the hostname and dismissed checks.
|
||||||
*/
|
*/
|
||||||
export const shouldShowEditorChooser = (): boolean => {
|
export const shouldShowEditorChooser = (): boolean => {
|
||||||
if (!env.isEnabledMermaidChartLinks) return false;
|
if (!env.isEnabledMermaidChartLinks) return false;
|
||||||
if (window.innerWidth < 640) return false;
|
if (window.innerWidth < 640) return false;
|
||||||
|
const forced = new URLSearchParams(window.location.search).get('editorChooser') === '1';
|
||||||
|
if (forced) return true;
|
||||||
|
if (!isOnMermaidAI() && !isOnMermaidLive() && !isOnNetlifyPreview()) return false;
|
||||||
if (window.localStorage.getItem(C.editorChooserDismissedKey) === 'true') return false;
|
if (window.localStorage.getItem(C.editorChooserDismissedKey) === 'true') return false;
|
||||||
if (hasStoredUserData()) return false;
|
|
||||||
if (hasPakoData()) return false;
|
if (hasPakoData()) return false;
|
||||||
if (isReferredFromMermaid()) return false;
|
if (isReferredFromMermaid()) return false;
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { writable, get, type Writable } from 'svelte/store';
|
import { injectHistoryIDs } from '$lib/components/History/historyState.svelte';
|
||||||
import { persist, localStorage } from '$lib/util/persist';
|
import { persisted } from '$lib/util/persist.svelte';
|
||||||
import { injectHistoryIDs } from '$lib/components/History/history';
|
|
||||||
import { logEvent } from './stats';
|
import { logEvent } from './stats';
|
||||||
|
|
||||||
interface MigrationState {
|
interface MigrationState {
|
||||||
@@ -11,14 +10,10 @@ const migrations: Record<string, () => void> = {
|
|||||||
injectHistoryIDs
|
injectHistoryIDs
|
||||||
};
|
};
|
||||||
|
|
||||||
const migrationStore: Writable<MigrationState> = persist(
|
const migrationState = persisted<MigrationState>('migrations', { version: -1 });
|
||||||
writable({ version: -1 }),
|
|
||||||
localStorage(),
|
|
||||||
'migrations'
|
|
||||||
);
|
|
||||||
|
|
||||||
export const applyMigrations = (): void => {
|
export const applyMigrations = (): void => {
|
||||||
const { version }: MigrationState = get(migrationStore);
|
const { version } = migrationState.value;
|
||||||
const allMigrations = Object.entries(migrations);
|
const allMigrations = Object.entries(migrations);
|
||||||
if (version === allMigrations.length - 1) {
|
if (version === allMigrations.length - 1) {
|
||||||
return;
|
return;
|
||||||
@@ -29,7 +24,7 @@ export const applyMigrations = (): void => {
|
|||||||
console.log(`Applying migration ${i}: ${key}.`);
|
console.log(`Applying migration ${i}: ${key}.`);
|
||||||
fn();
|
fn();
|
||||||
logEvent('migration', { key });
|
logEvent('migration', { key });
|
||||||
migrationStore.set({ version: i });
|
migrationState.value = { version: i };
|
||||||
}
|
}
|
||||||
logEvent('migration', { status: 'complete', from: version, to: allMigrations.length - 1 });
|
logEvent('migration', { status: 'complete', from: version, to: allMigrations.length - 1 });
|
||||||
};
|
};
|
||||||
@@ -14,7 +14,7 @@ describe('migrations', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should migrate from v0 to v1', async () => {
|
it('should migrate from v0 to v1', async () => {
|
||||||
const { applyMigrations } = await import('./migrations');
|
const { applyMigrations } = await import('./migrations.svelte');
|
||||||
let manualHistoryStore: HistoryEntry[] = JSON.parse(
|
let manualHistoryStore: HistoryEntry[] = JSON.parse(
|
||||||
window.localStorage.getItem('manualHistoryStore') ?? '[]'
|
window.localStorage.getItem('manualHistoryStore') ?? '[]'
|
||||||
) as HistoryEntry[];
|
) as HistoryEntry[];
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,254 +0,0 @@
|
|||||||
/* 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,6 +60,13 @@
|
|||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const taglineHref = $derived(
|
||||||
|
`${MCBaseURL}${currentTagline.url.path}?${new URLSearchParams({
|
||||||
|
...commonParams,
|
||||||
|
...currentTagline.url.params
|
||||||
|
}).toString()}`
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -70,11 +77,9 @@
|
|||||||
<div class="grid grow">
|
<div class="grid grow">
|
||||||
{#key currentTagline}
|
{#key currentTagline}
|
||||||
<a
|
<a
|
||||||
href="{MCBaseURL}{currentTagline.url.path}?{new URLSearchParams({
|
href={taglineHref}
|
||||||
...commonParams,
|
|
||||||
...currentTagline.url.params
|
|
||||||
}).toString()}"
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
class="col-start-1 row-start-1 flex items-center justify-center gap-4 no-underline"
|
class="col-start-1 row-start-1 flex items-center justify-center gap-4 no-underline"
|
||||||
in:fade={{ delay: 800 }}
|
in:fade={{ delay: 800 }}
|
||||||
out:fade={{ duration: 1000 }}>
|
out:fade={{ duration: 1000 }}>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { env } from '$lib/util/env';
|
import { env } from '$lib/util/env';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import duration from 'dayjs/plugin/duration';
|
import duration from 'dayjs/plugin/duration';
|
||||||
import type { Component } from 'svelte';
|
import type { Component, Snippet } from 'svelte';
|
||||||
import { get, writable, type Writable } from 'svelte/store';
|
import { persisted } from '../persist.svelte';
|
||||||
import { localStorage, persist } from '../persist';
|
|
||||||
import April2025 from './April2025.svelte';
|
import April2025 from './April2025.svelte';
|
||||||
import JS2026 from './JS2026.svelte';
|
import JS2026 from './JS2026.svelte';
|
||||||
|
|
||||||
@@ -12,7 +11,7 @@ dayjs.extend(duration);
|
|||||||
interface Promotion {
|
interface Promotion {
|
||||||
startDate: Date;
|
startDate: Date;
|
||||||
endDate: Date;
|
endDate: Date;
|
||||||
component: Component;
|
component: Component<{ closeBanner: Snippet }>;
|
||||||
hideDurationMs: number;
|
hideDurationMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,25 +34,21 @@ export const dismissPromotion = (id?: string): void => {
|
|||||||
if (!id || !promotions[id]) {
|
if (!id || !promotions[id]) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
hiddenPromotionsStore.update((dismissedIDs) => {
|
hiddenPromotions.value = {
|
||||||
dismissedIDs[id] = dayjs().add(promotions[id].hideDurationMs).valueOf();
|
...hiddenPromotions.value,
|
||||||
return dismissedIDs;
|
[id]: dayjs().add(promotions[id].hideDurationMs).valueOf()
|
||||||
});
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const hiddenPromotionsStore: Writable<Record<string, number>> = persist(
|
const hiddenPromotions = persisted<Record<string, number>>('hiddenPromotions', {});
|
||||||
writable({}),
|
|
||||||
localStorage(),
|
|
||||||
'hiddenPromotions'
|
|
||||||
);
|
|
||||||
|
|
||||||
export const getActivePromotion = (): (Promotion & { id: string }) | undefined => {
|
export const getActivePromotion = (): (Promotion & { id: string }) | undefined => {
|
||||||
if (!env.isEnabledMermaidChartLinks) {
|
if (!env.isEnabledMermaidChartLinks) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hidePromotionsUntil = get(hiddenPromotionsStore);
|
const hidePromotionsUntil = hiddenPromotions.value;
|
||||||
const now = new Date();
|
const now = dayjs();
|
||||||
const promotionWithID = Object.entries(promotions)
|
const promotionWithID = Object.entries(promotions)
|
||||||
.filter(
|
.filter(
|
||||||
([id, p]) =>
|
([id, p]) =>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildRedirectUrl } from './redirect';
|
||||||
|
|
||||||
|
const mockLocation = (url: string): Location => {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return { hash: parsed.hash, search: parsed.search } as Location;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('buildRedirectUrl', () => {
|
||||||
|
it('should redirect to /edit by default when hash is empty', () => {
|
||||||
|
expect(buildRedirectUrl(mockLocation('https://mermaid.live/'))).toBe('/edit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should preserve search params', () => {
|
||||||
|
expect(buildRedirectUrl(mockLocation('https://mermaid.live/?utm_source=github'))).toBe(
|
||||||
|
'/edit?utm_source=github'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should extract route and fragment from old hash format', () => {
|
||||||
|
expect(buildRedirectUrl(mockLocation('https://mermaid.live/#/edit/pako:abc123'))).toBe(
|
||||||
|
'/edit#pako:abc123'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should place search params before the hash fragment', () => {
|
||||||
|
expect(
|
||||||
|
buildRedirectUrl(mockLocation('https://mermaid.live/?utm_source=twitter#/edit/pako:abc123'))
|
||||||
|
).toBe('/edit?utm_source=twitter#pako:abc123');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle hash with view route', () => {
|
||||||
|
expect(buildRedirectUrl(mockLocation('https://mermaid.live/#/view/pako:xyz'))).toBe(
|
||||||
|
'/view#pako:xyz'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default to edit when hash has no route', () => {
|
||||||
|
expect(buildRedirectUrl(mockLocation('https://mermaid.live/#somethingelse'))).toBe('/edit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple search params with hash', () => {
|
||||||
|
expect(
|
||||||
|
buildRedirectUrl(
|
||||||
|
mockLocation(
|
||||||
|
'https://mermaid.live/?utm_source=gh&utm_medium=link&utm_campaign=test#/edit/pako:data'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).toBe('/edit?utm_source=gh&utm_medium=link&utm_campaign=test#pako:data');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { resolve } from '$app/paths';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the redirect URL for legacy root-path links.
|
||||||
|
* Extracts the route and fragment from the old hash-based URL format,
|
||||||
|
* and ensures search params (e.g. UTM) come before the hash fragment.
|
||||||
|
*/
|
||||||
|
export const buildRedirectUrl = (location: Location): string => {
|
||||||
|
const parts = location.hash.split('/');
|
||||||
|
let path = 'edit';
|
||||||
|
let fragment = '';
|
||||||
|
if (parts.length > 2) {
|
||||||
|
path = parts[1];
|
||||||
|
fragment = `#${parts[2]}`;
|
||||||
|
}
|
||||||
|
return `${resolve(`/${path}`, {})}${location.search}${fragment}`;
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { serializeState, deserializeState, type SerdeType } from './serde';
|
import { serializeState, deserializeState, type SerdeType } from './serde';
|
||||||
import { defaultState } from './state';
|
import { defaultState } from './state.svelte';
|
||||||
import type { State } from '$lib/types';
|
import type { State } from '$lib/types';
|
||||||
|
|
||||||
const verifySerde = (state: State, serde?: SerdeType): string => {
|
const verifySerde = (state: State, serde?: SerdeType): string => {
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
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 }));
|
||||||
|
};
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
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 ('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
|
|
||||||
}: {
|
|
||||||
medium: 'ai_repair' | 'main_menu' | 'save_diagram' | 'share' | 'vibe_diagramming';
|
|
||||||
}) => {
|
|
||||||
const utmSource = getUTMSource();
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
utm_source: utmSource,
|
|
||||||
utm_medium: medium
|
|
||||||
}).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);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { getAnalyticsSafeUrl } from './stats';
|
||||||
|
|
||||||
|
describe('getAnalyticsUrl', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset location to a clean state before each test
|
||||||
|
window.history.replaceState(null, '', '/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return origin and pathname for a simple URL', () => {
|
||||||
|
window.history.replaceState(null, '', '/edit');
|
||||||
|
const url = getAnalyticsSafeUrl();
|
||||||
|
expect(url).toBe(`${window.location.origin}/edit`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include search/query params (UTM parameters)', () => {
|
||||||
|
window.history.replaceState(null, '', '/edit?utm_source=github&utm_medium=docs');
|
||||||
|
const url = getAnalyticsSafeUrl();
|
||||||
|
expect(url).toBe(`${window.location.origin}/edit?utm_source=github&utm_medium=docs`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should never include the hash', () => {
|
||||||
|
window.history.replaceState(null, '', '/edit#pako:someDiagramData');
|
||||||
|
// replaceState doesn't set hash, so set it via location
|
||||||
|
window.location.hash = '#pako:someDiagramData';
|
||||||
|
const url = getAnalyticsSafeUrl();
|
||||||
|
expect(url).not.toContain('#');
|
||||||
|
expect(url).not.toContain('pako:');
|
||||||
|
expect(url).toBe(`${window.location.origin}/edit`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include search params but exclude hash when both are present', () => {
|
||||||
|
window.history.replaceState(null, '', '/edit?utm_campaign=launch');
|
||||||
|
window.location.hash = '#pako:diagramDataHere';
|
||||||
|
const url = getAnalyticsSafeUrl();
|
||||||
|
expect(url).not.toContain('#');
|
||||||
|
expect(url).not.toContain('pako:');
|
||||||
|
expect(url).toContain('utm_campaign=launch');
|
||||||
|
expect(url).toBe(`${window.location.origin}/edit?utm_campaign=launch`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return just origin for root path with no params', () => {
|
||||||
|
window.history.replaceState(null, '', '/');
|
||||||
|
const url = getAnalyticsSafeUrl();
|
||||||
|
expect(url).toBe(`${window.location.origin}/`);
|
||||||
|
});
|
||||||
|
});
|
||||||
+13
-6
@@ -23,6 +23,15 @@ export const initAnalytics = async (): Promise<void> => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the current page URL for analytics tracking.
|
||||||
|
* Includes origin, pathname, and search (for UTM params),
|
||||||
|
* but never the hash (which contains diagram data).
|
||||||
|
*/
|
||||||
|
export const getAnalyticsSafeUrl = (): string => {
|
||||||
|
return window.location.origin + window.location.pathname + window.location.search;
|
||||||
|
};
|
||||||
|
|
||||||
export const countLines = (code: string): number => {
|
export const countLines = (code: string): number => {
|
||||||
return (code.match(/\n/g)?.length ?? 0) + 1;
|
return (code.match(/\n/g)?.length ?? 0) + 1;
|
||||||
};
|
};
|
||||||
@@ -91,7 +100,6 @@ const delaysPerEvent = {
|
|||||||
mermaidChartClick: noDelay,
|
mermaidChartClick: noDelay,
|
||||||
migration: defaultDelay,
|
migration: defaultDelay,
|
||||||
mobileViewToggle: defaultDelay,
|
mobileViewToggle: defaultDelay,
|
||||||
panZoom: minutesToMilliSeconds(10),
|
|
||||||
pwaInstalled: defaultDelay,
|
pwaInstalled: defaultDelay,
|
||||||
render: minutesToMilliSeconds(5),
|
render: minutesToMilliSeconds(5),
|
||||||
renderDiagram: defaultDelay,
|
renderDiagram: defaultDelay,
|
||||||
@@ -105,6 +113,9 @@ export const logEvent = (
|
|||||||
name: AnalyticsEvent,
|
name: AnalyticsEvent,
|
||||||
data?: Record<string, string | number | boolean>
|
data?: Record<string, string | number | boolean>
|
||||||
): void => {
|
): void => {
|
||||||
|
if (browser && window.location.hostname === 'localhost') {
|
||||||
|
console.log('[plausible]', name, data);
|
||||||
|
}
|
||||||
if (!plausible) {
|
if (!plausible) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -112,11 +123,7 @@ export const logEvent = (
|
|||||||
if (timeouts.has(key)) {
|
if (timeouts.has(key)) {
|
||||||
clearTimeout(timeouts.get(key));
|
clearTimeout(timeouts.get(key));
|
||||||
} else {
|
} else {
|
||||||
plausible.trackEvent(
|
plausible.trackEvent(name, { props: data }, { url: getAnalyticsSafeUrl() });
|
||||||
name,
|
|
||||||
{ props: data },
|
|
||||||
{ url: window.location.origin + window.location.pathname }
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
timeouts.set(
|
timeouts.set(
|
||||||
key,
|
key,
|
||||||
|
|||||||
+19
-10
@@ -1,11 +1,11 @@
|
|||||||
import { C } from '$/constants';
|
import { C } from '$/constants';
|
||||||
import { env } from './env';
|
import { env } from './env';
|
||||||
import { loadDataFromUrl } from './fileLoaders/loader';
|
import { loadDataFromUrl } from './fileLoaders/loader';
|
||||||
import { initLoading } from './loading';
|
import { initLoading } from './loading.svelte';
|
||||||
import { isOnMermaidAI } from './migration/domainMigration';
|
import { isOnMermaidAI } from './migration/domainMigration';
|
||||||
import { applyMigrations } from './migrations';
|
import { applyMigrations } from './migrations.svelte';
|
||||||
import { initURLSubscription, loadState, updateCodeStore, verifyState } from './state';
|
import { initURLSubscription, loadState, updateCodeStore, verifyState } from './state.svelte';
|
||||||
import { initAnalytics, plausible } from './stats';
|
import { getAnalyticsSafeUrl, initAnalytics, plausible } from './stats';
|
||||||
|
|
||||||
export const getDomain = (url?: string): string => {
|
export const getDomain = (url?: string): string => {
|
||||||
if (!url) return '';
|
if (!url) return '';
|
||||||
@@ -30,7 +30,9 @@ export const initHandler = async (): Promise<void> => {
|
|||||||
syncDiagram();
|
syncDiagram();
|
||||||
initURLSubscription();
|
initURLSubscription();
|
||||||
await initAnalytics();
|
await initAnalytics();
|
||||||
plausible?.trackPageview({ url: window.location.origin + window.location.pathname });
|
plausible?.trackPageview({
|
||||||
|
url: getAnalyticsSafeUrl()
|
||||||
|
});
|
||||||
verifyState();
|
verifyState();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,23 +42,30 @@ export const MCBaseURL = env.isEnabledMermaidChartLinks
|
|||||||
? 'https://mermaid.ai' // 'http://localhost:5174'
|
? 'https://mermaid.ai' // 'http://localhost:5174'
|
||||||
: 'https://example.com';
|
: 'https://example.com';
|
||||||
|
|
||||||
export const getCheckoutUrl = ({
|
const buildUtmParams = ({
|
||||||
utmCampaign,
|
utmCampaign,
|
||||||
utmMedium
|
utmMedium
|
||||||
}: {
|
}: {
|
||||||
utmCampaign: string;
|
utmCampaign: string;
|
||||||
utmMedium: string;
|
utmMedium: string;
|
||||||
}): string => {
|
}): URLSearchParams =>
|
||||||
const params = new URLSearchParams({
|
new URLSearchParams({
|
||||||
coupon: 'arDfyFT8',
|
|
||||||
tier: 'plus',
|
|
||||||
utm_campaign: utmCampaign,
|
utm_campaign: utmCampaign,
|
||||||
utm_medium: utmMedium,
|
utm_medium: utmMedium,
|
||||||
utm_source: getUTMSource()
|
utm_source: getUTMSource()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const getCheckoutUrl = (utm: { utmCampaign: string; utmMedium: string }): string => {
|
||||||
|
const params = buildUtmParams(utm);
|
||||||
|
params.set('coupon', 'arDfyFT8');
|
||||||
|
params.set('tier', 'plus');
|
||||||
return `${MCBaseURL}/app/user/billing/checkout?${params.toString()}`;
|
return `${MCBaseURL}/app/user/billing/checkout?${params.toString()}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getMermaidAiLiveUrl = (utm: { utmCampaign: string; utmMedium: string }): string => {
|
||||||
|
return `${MCBaseURL}/live?${buildUtmParams(utm).toString()}`;
|
||||||
|
};
|
||||||
|
|
||||||
let count = 0;
|
let count = 0;
|
||||||
export const errorDebug = (limit = 1000) => {
|
export const errorDebug = (limit = 1000) => {
|
||||||
count += 1;
|
count += 1;
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
<script>
|
<script>
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { resolve } from '$app/paths';
|
||||||
|
import { page } from '$app/state';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
|
||||||
// Only redirect if it's a 404 error
|
// Only redirect if it's a 404 error
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
if ($page.status === 404) {
|
if (page.status === 404) {
|
||||||
goto('/');
|
goto(resolve('/'));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $page.status !== 404}
|
{#if page.status !== 404}
|
||||||
<div class="container mx-auto p-8">
|
<div class="container mx-auto p-8">
|
||||||
<h1 class="mb-4 text-2xl font-bold">Error {$page.status}</h1>
|
<h1 class="mb-4 text-2xl font-bold">Error {page.status}</h1>
|
||||||
<p class="mb-4">{$page.error?.message || 'An unexpected error occurred'}</p>
|
<p class="mb-4">{page.error?.message || 'An unexpected error occurred'}</p>
|
||||||
<a href="/" class="text-blue-500 hover:underline">Return to Home</a>
|
<a href={resolve('/')} class="text-blue-500 hover:underline">Return to Home</a>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Toaster } from '$/components/ui/sonner/index.js';
|
import { Toaster } from '$/components/ui/sonner/index.js';
|
||||||
import { loadingStateStore } from '$/util/loading';
|
import { loadingState } from '$/util/loading.svelte';
|
||||||
import { toggleDarkTheme } from '$/util/state';
|
import { toggleDarkTheme } from '$/util/state.svelte';
|
||||||
import { initHandler } from '$/util/util';
|
import { initHandler } from '$/util/util';
|
||||||
import { base } from '$app/paths';
|
import { base } from '$app/paths';
|
||||||
import { mode, ModeWatcher } from 'mode-watcher';
|
import { mode, ModeWatcher } from 'mode-watcher';
|
||||||
@@ -34,7 +34,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
toggleDarkTheme($mode === 'dark');
|
toggleDarkTheme(mode.current === 'dark');
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -45,12 +45,12 @@
|
|||||||
{@render children()}
|
{@render children()}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{#if $loadingStateStore.loading}
|
{#if loadingState.loading}
|
||||||
<div
|
<div
|
||||||
class="absolute top-0 left-0 z-50 flex h-screen w-screen justify-center bg-gray-600 align-middle opacity-50">
|
class="absolute top-0 left-0 z-50 flex h-screen w-screen justify-center bg-gray-600 align-middle opacity-50">
|
||||||
<div class="my-auto text-4xl font-bold text-indigo-100">
|
<div class="my-auto text-4xl font-bold text-indigo-100">
|
||||||
<div class="loader mx-auto"></div>
|
<div class="loader mx-auto"></div>
|
||||||
<div>{$loadingStateStore.message}</div>
|
<div>{loadingState.message}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { buildRedirectUrl } from '$lib/util/redirect';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { base } from '$app/paths';
|
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
// Handle old live editor links and redirect to new version
|
await goto(buildRedirectUrl(window.location), {
|
||||||
const hash = window.location.hash.split('/');
|
|
||||||
let newURL = 'edit';
|
|
||||||
if (hash.length > 2) {
|
|
||||||
newURL = `${hash[1]}#${hash[2]}`;
|
|
||||||
}
|
|
||||||
await goto(`${base}/${newURL}`, {
|
|
||||||
replaceState: true
|
replaceState: true
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
import Card from '$/components/Card/Card.svelte';
|
import Card from '$/components/Card/Card.svelte';
|
||||||
import DiagramDocButton from '$/components/DiagramDocumentationButton.svelte';
|
import DiagramDocButton from '$/components/DiagramDocumentationButton.svelte';
|
||||||
import Editor from '$/components/Editor.svelte';
|
import Editor from '$/components/Editor.svelte';
|
||||||
|
import EnhancedEditsButton from '$/components/EnhancedEditsButton.svelte';
|
||||||
import History from '$/components/History/History.svelte';
|
import History from '$/components/History/History.svelte';
|
||||||
|
import { startAutoSave } from '$/components/History/historyState.svelte';
|
||||||
import McWrapper from '$/components/McWrapper.svelte';
|
import McWrapper from '$/components/McWrapper.svelte';
|
||||||
import MermaidChartIcon from '$/components/MermaidChartIcon.svelte';
|
import MermaidChartIcon from '$/components/MermaidChartIcon.svelte';
|
||||||
import EditorChooserModal from '$/components/migration/EditorChooserModal.svelte';
|
import EditorChooserModal from '$/components/migration/EditorChooserModal.svelte';
|
||||||
@@ -21,7 +23,7 @@
|
|||||||
import type { EditorMode, Tab } from '$/types';
|
import type { EditorMode, Tab } from '$/types';
|
||||||
import { shouldShowEditorChooser } from '$/util/migration/domainMigration';
|
import { shouldShowEditorChooser } from '$/util/migration/domainMigration';
|
||||||
import { PanZoomState } from '$/util/panZoom';
|
import { PanZoomState } from '$/util/panZoom';
|
||||||
import { stateStore, updateCodeStore, urlsStore } from '$/util/state';
|
import { validatedState, updateCodeStore, urls } from '$/util/state.svelte';
|
||||||
import { logEvent, logMermaidChartClick } from '$/util/stats';
|
import { logEvent, logMermaidChartClick } from '$/util/stats';
|
||||||
import { initHandler } from '$/util/util';
|
import { initHandler } from '$/util/util';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
@@ -62,6 +64,9 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Record the Timeline for the whole session, not just while the panel is open.
|
||||||
|
onMount(() => startAutoSave());
|
||||||
|
|
||||||
let isHistoryOpen = $state(false);
|
let isHistoryOpen = $state(false);
|
||||||
|
|
||||||
let editorPane: Resizable.Pane | undefined;
|
let editorPane: Resizable.Pane | undefined;
|
||||||
@@ -86,7 +91,7 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
<Navbar mobileToggle={isMobile ? mobileToggle : undefined}>
|
<Navbar mobileToggle={isMobile ? mobileToggle : undefined}>
|
||||||
<Toggle bind:pressed={isHistoryOpen} size="sm">
|
<Toggle bind:pressed={isHistoryOpen} size="sm" title="History" aria-label="History">
|
||||||
<HistoryIcon />
|
<HistoryIcon />
|
||||||
</Toggle>
|
</Toggle>
|
||||||
<Share />
|
<Share />
|
||||||
@@ -94,7 +99,7 @@
|
|||||||
<Button
|
<Button
|
||||||
variant="accent"
|
variant="accent"
|
||||||
size="sm"
|
size="sm"
|
||||||
href={$urlsStore.mermaidChart({ medium: 'save_diagram' }).save}
|
href={urls.current.mermaidChart({ medium: 'save_diagram' }).save}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
onclick={() => logMermaidChartClick('saveDiagram')}>
|
onclick={() => logMermaidChartClick('saveDiagram')}>
|
||||||
<MermaidChartIcon />
|
<MermaidChartIcon />
|
||||||
@@ -119,7 +124,7 @@
|
|||||||
onselect={tabSelectHandler}
|
onselect={tabSelectHandler}
|
||||||
isOpen
|
isOpen
|
||||||
tabs={editorTabs}
|
tabs={editorTabs}
|
||||||
activeTabID={$stateStore.editorMode}
|
activeTabID={validatedState.current.editorMode}
|
||||||
isClosable={false}>
|
isClosable={false}>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<DiagramDocButton />
|
<DiagramDocButton />
|
||||||
@@ -135,7 +140,8 @@
|
|||||||
</Resizable.Pane>
|
</Resizable.Pane>
|
||||||
<Resizable.Handle class="mr-1 hidden opacity-0 sm:block" />
|
<Resizable.Handle class="mr-1 hidden opacity-0 sm:block" />
|
||||||
<Resizable.Pane minSize={15} class="relative flex h-full flex-1 flex-col overflow-hidden">
|
<Resizable.Pane minSize={15} class="relative flex h-full flex-1 flex-col overflow-hidden">
|
||||||
<View {panZoomState} shouldShowGrid={$stateStore.grid} />
|
<View {panZoomState} shouldShowGrid={validatedState.current.grid} />
|
||||||
|
<div class="absolute top-0 left-5 hidden md:block"><EnhancedEditsButton /></div>
|
||||||
<div class="absolute top-0 right-0"><PanZoomToolbar {panZoomState} /></div>
|
<div class="absolute top-0 right-0"><PanZoomToolbar {panZoomState} /></div>
|
||||||
<div class="absolute right-0 bottom-0"><VersionSecurityToolbar /></div>
|
<div class="absolute right-0 bottom-0"><VersionSecurityToolbar /></div>
|
||||||
<div class="absolute bottom-0 left-0 sm:left-5"><SyncRoughToolbar /></div>
|
<div class="absolute bottom-0 left-0 sm:left-5"><SyncRoughToolbar /></div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import adapter from '@sveltejs/adapter-static';
|
import adapter from '@sveltejs/adapter-static';
|
||||||
|
import 'dotenv/config';
|
||||||
import { sveltePreprocess } from 'svelte-preprocess';
|
import { sveltePreprocess } from 'svelte-preprocess';
|
||||||
|
|
||||||
/** @type {import('@sveltejs/kit').Config} */
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
@@ -10,6 +11,9 @@ const config = {
|
|||||||
alias: {
|
alias: {
|
||||||
'$/*': './src/lib/*'
|
'$/*': './src/lib/*'
|
||||||
},
|
},
|
||||||
|
paths: {
|
||||||
|
base: process.env.MERMAID_BASE_PATH ?? ''
|
||||||
|
},
|
||||||
adapter: adapter({
|
adapter: adapter({
|
||||||
pages: 'docs',
|
pages: 'docs',
|
||||||
fallback: '404.html'
|
fallback: '404.html'
|
||||||
|
|||||||
+120
-57
@@ -1,84 +1,147 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
import { typeInEditor } from './utils';
|
|
||||||
|
|
||||||
test.describe.skip('Save History', () => {
|
const config = '{\n "theme": "default"\n}';
|
||||||
|
|
||||||
|
const entry = (id: string, name: string, type: 'manual' | 'auto', label: string) => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
time: Number(id.slice(2)),
|
||||||
|
state: {
|
||||||
|
code: `flowchart TD\n A[${label}]`,
|
||||||
|
mermaid: config,
|
||||||
|
autoSync: true,
|
||||||
|
updateDiagram: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const manualHistory = [
|
||||||
|
entry('m-2', 'hollow-art', 'manual', 'Halloween'),
|
||||||
|
entry('m-1', 'helpful-ocean', 'manual', 'Pumpkin')
|
||||||
|
];
|
||||||
|
const autoHistory = [
|
||||||
|
entry('a-2', 'barking-dog', 'auto', 'NewYear'),
|
||||||
|
entry('a-1', 'needy-mosquito', 'auto', 'Fireworks')
|
||||||
|
];
|
||||||
|
|
||||||
|
const openHistory = (page: Page) => page.getByRole('button', { name: 'History' }).click();
|
||||||
|
|
||||||
|
test.describe('History', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Freeze time so auto-save snapshots are deterministic.
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
Object.defineProperty(Date, 'now', {
|
Object.defineProperty(Date, 'now', { value: () => new Date(2022, 0, 1).getTime() });
|
||||||
value: () => new Date(2022, 0, 1).getTime()
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
await page.goto('/edit');
|
await page.goto('/edit');
|
||||||
await page.getByText('History').click();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should load history from localstorage', async ({ page }) => {
|
test('loads Saved and Timeline history from localStorage and restores entries', async ({
|
||||||
await page.evaluate(() => {
|
page
|
||||||
localStorage.setItem(
|
}) => {
|
||||||
'manualHistoryStore',
|
await page.evaluate(
|
||||||
'[{"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"}]'
|
([manual, auto]) => {
|
||||||
);
|
localStorage.setItem('manualHistoryStore', manual);
|
||||||
localStorage.setItem(
|
localStorage.setItem('autoHistoryStore', auto);
|
||||||
'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"}]'
|
[JSON.stringify(manualHistory), JSON.stringify(autoHistory)]
|
||||||
);
|
);
|
||||||
});
|
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await page.getByText('History').click();
|
await openHistory(page);
|
||||||
|
|
||||||
|
// Saved tab is active by default.
|
||||||
await expect(page.locator('#historyList li')).toHaveCount(2);
|
await expect(page.locator('#historyList li')).toHaveCount(2);
|
||||||
await expect(page.locator('#historyList').getByText('No items in History')).not.toBeVisible();
|
|
||||||
await expect(page.locator('#historyList')).toContainText('helpful-ocean');
|
|
||||||
await expect(page.locator('#historyList')).toContainText('hollow-art');
|
await expect(page.locator('#historyList')).toContainText('hollow-art');
|
||||||
await page.getByText('Restore').first().click();
|
await expect(page.locator('#historyList')).toContainText('helpful-ocean');
|
||||||
await expect(page.locator('#view').getByText('Halloween')).toBeVisible();
|
|
||||||
await page.getByText('Timeline').click();
|
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Restore this version' }).first().click();
|
||||||
|
await expect(page.locator('#view')).toContainText('Halloween');
|
||||||
|
|
||||||
|
// Switching to the Timeline tab shows the auto entries only.
|
||||||
|
await page.getByRole('tab', { name: 'Timeline' }).click();
|
||||||
await expect(page.locator('#historyList li')).toHaveCount(2);
|
await expect(page.locator('#historyList li')).toHaveCount(2);
|
||||||
await expect(page.locator('#historyList').getByText('No items in History')).not.toBeVisible();
|
|
||||||
await expect(page.locator('#historyList')).toContainText('needy-mosquito');
|
|
||||||
await expect(page.locator('#historyList')).toContainText('barking-dog');
|
await expect(page.locator('#historyList')).toContainText('barking-dog');
|
||||||
await page.getByText('Restore').first().click();
|
await expect(page.locator('#historyList')).toContainText('needy-mosquito');
|
||||||
await expect(page.locator('#view').getByText('New Year')).toBeVisible();
|
await expect(page.locator('#historyList')).not.toContainText('hollow-art');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Restore this version' }).first().click();
|
||||||
|
await expect(page.locator('#view')).toContainText('NewYear');
|
||||||
});
|
});
|
||||||
|
|
||||||
test.skip('should save when clicked', async ({ page }) => {
|
test('each entry has a copyable link that opens it in a new tab', async ({ page }) => {
|
||||||
await expect(page.locator('#historyList li')).toHaveCount(0);
|
await page.evaluate(
|
||||||
await expect(page.locator('#historyList')).toContainText('No items in History');
|
(manual) => localStorage.setItem('manualHistoryStore', manual),
|
||||||
await page.locator('#saveHistory').click();
|
JSON.stringify(manualHistory)
|
||||||
await expect(page.locator('#historyList').getByText('No items in History')).not.toBeVisible();
|
);
|
||||||
await expect(page.locator('#historyList li')).toHaveCount(1);
|
await page.reload();
|
||||||
const dialogPromise = page.waitForEvent('dialog');
|
await openHistory(page);
|
||||||
await page.locator('#saveHistory').click();
|
|
||||||
const dialog = await dialogPromise;
|
|
||||||
expect(dialog.message()).toBe('State already saved.');
|
|
||||||
await dialog.accept();
|
|
||||||
|
|
||||||
await typeInEditor(page, ' C --> HistoryTest');
|
// It is a real link (so it can be copied / opened in a new tab), not a button.
|
||||||
|
const link = page.getByRole('link', { name: 'Open in new tab' }).first();
|
||||||
|
await expect(link).toHaveAttribute('target', '_blank');
|
||||||
|
const href = await link.getAttribute('href');
|
||||||
|
expect(href).toContain('/edit#pako:');
|
||||||
|
|
||||||
|
// Following it loads that entry's diagram.
|
||||||
|
await page.goto(href ?? '');
|
||||||
|
await expect(page.locator('#view')).toContainText('Halloween');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the active tab highlighted when switching modes', async ({ page }) => {
|
||||||
|
await openHistory(page);
|
||||||
|
const saved = page.getByRole('tab', { name: 'Saved' });
|
||||||
|
const timeline = page.getByRole('tab', { name: 'Timeline' });
|
||||||
|
|
||||||
|
await expect(saved).toHaveClass(/border-b-2/);
|
||||||
|
await expect(timeline).not.toHaveClass(/border-b-2/);
|
||||||
|
|
||||||
|
await timeline.click();
|
||||||
|
await expect(timeline).toHaveClass(/border-b-2/);
|
||||||
|
await expect(saved).not.toHaveClass(/border-b-2/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('saves the current state and reports duplicates', async ({ page }) => {
|
||||||
|
await openHistory(page);
|
||||||
|
await expect(page.locator('#historyList li')).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.locator('#saveHistory').click();
|
||||||
|
await expect(page.locator('#historyList li')).toHaveCount(1);
|
||||||
|
|
||||||
|
// Saving again without changes does not add a duplicate and notifies the user.
|
||||||
|
await page.locator('#saveHistory').click();
|
||||||
|
await expect(page.getByText('State already saved.')).toBeVisible();
|
||||||
|
await expect(page.locator('#historyList li')).toHaveCount(1);
|
||||||
|
|
||||||
|
// Loading a different sample changes the state, so it saves as a new entry.
|
||||||
|
await page.getByRole('button', { name: 'Sequence', exact: true }).click();
|
||||||
|
await expect(page.locator('#view')).not.toContainText('Christmas');
|
||||||
await page.locator('#saveHistory').click();
|
await page.locator('#saveHistory').click();
|
||||||
await expect(page.locator('#historyList li')).toHaveCount(2);
|
await expect(page.locator('#historyList li')).toHaveCount(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
test.skip('should be able to restore and delete', async ({ page }) => {
|
test('auto-saves to the Timeline only, never the Saved list', async ({ page }) => {
|
||||||
|
await openHistory(page);
|
||||||
await page.locator('#saveHistory').click();
|
await page.locator('#saveHistory').click();
|
||||||
await typeInEditor(page, ' C --> HistoryTest');
|
|
||||||
await expect(page.locator('#historyList').getByText('No items in History')).not.toBeVisible();
|
|
||||||
await expect(page.locator('#historyList li')).toHaveCount(1);
|
await expect(page.locator('#historyList li')).toHaveCount(1);
|
||||||
await expect(page.locator('#view').getByText('HistoryTest')).toBeVisible();
|
|
||||||
await page.getByText('Restore').click();
|
await page.getByRole('tab', { name: 'Timeline' }).click();
|
||||||
await expect(page.locator('#view').getByText('HistoryTest')).not.toBeVisible();
|
// A manual save must not appear under Timeline.
|
||||||
await page.getByText('Delete').click();
|
await expect(page.locator('#historyList')).toContainText('No timeline snapshots yet.');
|
||||||
await expect(page.locator('#historyList li')).toHaveCount(0);
|
});
|
||||||
await expect(page.locator('#historyList')).toContainText('No items in History');
|
|
||||||
|
test('deletes a single entry and clears all after confirmation', async ({ page }) => {
|
||||||
|
await openHistory(page);
|
||||||
await page.locator('#saveHistory').click();
|
await page.locator('#saveHistory').click();
|
||||||
await typeInEditor(page, ' C --> HistoryTest');
|
await page.getByRole('button', { name: 'Sequence', exact: true }).click();
|
||||||
|
await expect(page.locator('#view')).not.toContainText('Christmas');
|
||||||
await page.locator('#saveHistory').click();
|
await page.locator('#saveHistory').click();
|
||||||
await page.locator('#editor').type('ing');
|
await expect(page.locator('#historyList li')).toHaveCount(2);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Delete this version' }).first().click();
|
||||||
|
await expect(page.locator('#historyList li')).toHaveCount(1);
|
||||||
|
|
||||||
|
page.on('dialog', (dialog) => dialog.accept());
|
||||||
await page.locator('#clearHistory').click();
|
await page.locator('#clearHistory').click();
|
||||||
|
await expect(page.locator('#historyList li')).toHaveCount(0);
|
||||||
const dialog = await page.waitForEvent('dialog');
|
await expect(page.locator('#historyList')).toContainText('No saved states yet.');
|
||||||
expect(dialog.message()).toBe('Clear all saved items?');
|
|
||||||
await dialog.accept();
|
|
||||||
|
|
||||||
await expect(page.locator('#historyList')).toContainText('No items in History');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { State } from '$/types';
|
||||||
|
import assert from 'node:assert';
|
||||||
import { expect, test } from './test';
|
import { expect, test } from './test';
|
||||||
|
|
||||||
test.describe('Site Loads', () => {
|
test.describe('Site Loads', () => {
|
||||||
@@ -67,6 +69,44 @@ test.describe('Site Loads', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should prompt user to scrub unsafe config', async ({ editPage, page }) => {
|
||||||
|
let dialogAccepted = false;
|
||||||
|
page.on('dialog', async (dialog) => {
|
||||||
|
expect(dialog.type()).toBe('confirm');
|
||||||
|
expect(dialog.message()).toContain('from the config for safety');
|
||||||
|
await dialog.accept();
|
||||||
|
dialogAccepted = true;
|
||||||
|
});
|
||||||
|
await editPage.start(
|
||||||
|
`/edit?${new URLSearchParams({
|
||||||
|
code: `data:application/vnd.mermaid,${encodeURIComponent('flowchart TD\nHello-->World')}`,
|
||||||
|
config: `data:application/json,${encodeURIComponent(
|
||||||
|
JSON.stringify({
|
||||||
|
someOtherSetting: 'Test value',
|
||||||
|
securityLevel: 'loose',
|
||||||
|
secure: [],
|
||||||
|
themeVariables: {
|
||||||
|
nodeBorder: '</style></svg><script>alert("XSS")</script>'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)}`
|
||||||
|
}).toString()}`
|
||||||
|
);
|
||||||
|
await editPage.checkTextInView('Hello');
|
||||||
|
await expect.poll(() => dialogAccepted).toBeTruthy();
|
||||||
|
const codeStore = await page.evaluate(() => localStorage.getItem('codeStore'));
|
||||||
|
assert(codeStore);
|
||||||
|
const parsedStore = JSON.parse(codeStore) as State;
|
||||||
|
const parsedConfig = JSON.parse(parsedStore.mermaid) as Record<string, unknown>;
|
||||||
|
expect(parsedConfig).toEqual({
|
||||||
|
someOtherSetting: 'Test value',
|
||||||
|
themeVariables: {}
|
||||||
|
});
|
||||||
|
// should scrub unsafe securityLevel but keep other settings
|
||||||
|
expect(parsedConfig.securityLevel).toBeUndefined();
|
||||||
|
expect(parsedConfig.secure).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
test('should show troubleshooting steps if loading fails', async ({ editPage, page }) => {
|
test('should show troubleshooting steps if loading fails', async ({ editPage, page }) => {
|
||||||
await editPage.start('/#/edit/eyJjb2RlIjoiZ3JhcGggVERcbiAg');
|
await editPage.start('/#/edit/eyJjb2RlIjoiZ3JhcGggVERcbiAg');
|
||||||
await page.reload({ waitUntil: 'networkidle' });
|
await page.reload({ waitUntil: 'networkidle' });
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"strictNullChecks": true,
|
"strictNullChecks": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
"types": ["vitest/importMeta", "@playwright/test"]
|
"types": ["vitest/importMeta", "@playwright/test"]
|
||||||
},
|
},
|
||||||
"extends": "./.svelte-kit/tsconfig.json"
|
"extends": "./.svelte-kit/tsconfig.json"
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export default defineConfig({
|
|||||||
envPrefix: 'MERMAID_',
|
envPrefix: 'MERMAID_',
|
||||||
server: { port: 3000, host: true },
|
server: { port: 3000, host: true },
|
||||||
preview: { port: 3000, host: true },
|
preview: { port: 3000, host: true },
|
||||||
|
// Vitest otherwise resolves Svelte's server build, where $effect is a no-op.
|
||||||
|
resolve: process.env.VITEST ? { conditions: ['browser'] } : undefined,
|
||||||
test: {
|
test: {
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
// in-source testing
|
// in-source testing
|
||||||
|
|||||||
Reference in New Issue
Block a user