Compare commits

..
Author SHA1 Message Date
Sidharth Vinod eca8781445 temp 2025-05-26 22:49:02 +05:30
Sidharth Vinod 9626f6b5df fix: PNG export icons 2025-05-09 21:58:33 +05:30
123 changed files with 6133 additions and 7213 deletions
+1 -3
View File
@@ -1,9 +1,7 @@
MERMAID_DOMAIN=''
MERMAID_BASE_PATH=''
MERMAID_DOCS_URL='https://mermaid.js.org'
MERMAID_ANALYTICS_URL=''
MERMAID_RENDERER_URL='https://mermaid.ink'
MERMAID_KROKI_RENDERER_URL='https://kroki.io'
MERMAID_IS_ENABLED_MERMAID_CHART_LINKS='true'
MERMAID_IS_ENABLED_MERMAID_CHART_LINKS=''
# cp .env .env.local to make local changes
+8
View File
@@ -0,0 +1,8 @@
docs/**
.svelte-kit/**
static/**
build/**
node_modules/**
coverage/**
__snapshots__/**
snapshots.js
+121
View File
@@ -0,0 +1,121 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
// 'plugin:@typescript-eslint/recommended-requiring-type-checking',
'plugin:@typescript-eslint/strict',
'plugin:unicorn/recommended',
'plugin:svelte/recommended',
'plugin:svelte/prettier',
'prettier'
],
plugins: [
'tailwindcss',
'@typescript-eslint',
'es',
'vitest',
'no-only-tests',
'sort-keys',
'unicorn'
],
ignorePatterns: [
'docs/*',
'*.cjs',
'*.js',
'*.md',
'snapshots.js',
'svelte.config.js',
'renovate.json',
'package.json',
'tsconfig.json'
],
overrides: [
{
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser'
}
},
{
files: ['*.ts'],
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:@typescript-eslint/recommended-requiring-type-checking',
'plugin:@typescript-eslint/strict',
'prettier'
]
},
{
files: ['**/components/ui/**'],
rules: {
'unicorn/prefer-export-from': 'off',
'unicorn/prevent-abbreviations': 'off',
'unicorn/explicit-length-check': 'off',
'sort-keys/sort-keys-fix': 'off'
}
}
],
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
tsconfigRootDir: __dirname,
project: './tsconfig.json',
extraFileExtensions: ['.svelte']
},
env: {
browser: true,
es2020: true
},
rules: {
'sort-keys/sort-keys-fix': ['error', 'asc', { minKeys: 5 }],
'@typescript-eslint/ban-ts-comment': [
'error',
{
'ts-ignore': 'allow-with-description'
}
],
'@typescript-eslint/no-unsafe-member-access': 'off',
'@typescript-eslint/no-unsafe-assignment': 'off',
'es/no-regexp-lookbehind-assertions': 'error',
curly: ['error', 'all'],
'no-only-tests/no-only-tests': 'error',
'unicorn/no-null': 'off',
'unicorn/filename-case': [
'error',
{
case: 'camelCase'
}
],
'unicorn/filename-case': 'off',
'unicorn/prevent-abbreviations': [
'error',
{
allowList: {
args: true,
ctx: true,
db: true,
doc: true,
env: true,
fn: true,
i: true,
j: true,
k: true,
param: true,
Params: true,
params: true,
Props: true,
props: true,
req: true,
res: true,
str: true,
temp: true,
ImportMetaEnv: true
}
}
]
}
};
-49
View File
@@ -1,49 +0,0 @@
name: Docker Build
on:
push:
branches:
- master
- develop
pull_request:
branches:
- master
- develop
jobs:
docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Login to Gitea Container Registry
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: gitea.forkless.com
username: ${{ secrets.GITEAUSER }}
password: ${{ secrets.GITEATOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: gitea.forkless.com/forkless/mermaid-live-editor
tags: |
type=raw,value=latest,enable=${{ github.ref_name == 'master' }}
type=raw,value=nightly,enable=${{ github.ref_name == 'develop' }}
type=sha,prefix={{branch}}-
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
target: mermaid
push: ${{ github.event_name == 'push' }}
pull: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
MERMAID_RENDERER_URL=${{ vars.MERMAID_RENDERER_URL || 'https://mermaid.ink' }}
MERMAID_KROKI_RENDERER_URL=${{ vars.MERMAID_KROKI_RENDERER_URL || 'https://kroki.io' }}
MERMAID_IS_ENABLED_MERMAID_CHART_LINKS=false
@@ -1,60 +0,0 @@
name: Close broken link issues
on:
issues:
types: [opened, edited]
permissions:
issues: write
jobs:
close-broken-link-issues:
if: github.event.issue.state == 'open'
runs-on: ubuntu-latest
steps:
- name: Close issues with only "Broken link" in the title
uses: actions/github-script@v7
with:
script: |
const title = context.payload.issue.title.trim();
const isBrokenLinkOnlyTitle = /^broken link\.?$/i.test(title);
if (!isBrokenLinkOnlyTitle) {
return;
}
const { owner, repo } = context.repo;
const issueNumber = context.payload.issue.number;
const newIssueUrl = `https://github.com/${owner}/${repo}/issues/new?assignees=&labels=bug&template=bug_report.md&title=Broken%20link`;
const mermaidIssuesUrl = 'https://github.com/mermaid-js/mermaid/issues/new';
const commentBody = [
`@${context.payload.issue.user.login} This issue was automatically closed because the title only contains "Broken link" without additional details.`,
'',
'**Please only open issues here if something is broken in the live editor itself** (e.g. a link fails to load, the editor UI misbehaves, or a feature of mermaid.live does not work).',
'',
'**Do not open issues here for syntax errors or invalid Mermaid diagram code.** Those belong in the main Mermaid repository: [mermaid-js/mermaid](https://github.com/mermaid-js/mermaid/issues/new).',
'',
'If you are reporting a live editor bug, please include:',
'- The full URL that failed to load',
'- What you expected to happen',
'- Any error messages you saw',
'',
`You can [open a new issue in this repo](${newIssueUrl}) with this information, or report diagram syntax issues in the [Mermaid repo](${mermaidIssuesUrl}).`
].join('\n');
await github.rest.issues.createComment({
owner,
repo,
issue_number: issueNumber,
body: commentBody
});
await github.rest.issues.update({
owner,
repo,
issue_number: issueNumber,
state: 'closed',
state_reason: 'not_planned'
});
+6 -17
View File
@@ -17,43 +17,32 @@ on:
jobs:
docker:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
attestations: write
id-token: write
steps:
- uses: actions/checkout@v4
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
if: github.event_name == 'push'
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get release version
run: |
RELEASE_VERSION=$([ "${{ github.ref_name }}" = "master" ] && echo "latest" || echo "nightly")
echo "RELEASE_VERSION=${RELEASE_VERSION}" >> "${GITHUB_ENV}"
- uses: docker/metadata-action@v5
id: meta
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=latest,enable=${{ github.ref_name == 'master' }}
type=raw,value=nightly,enable=${{ github.ref_name == 'develop' }}
type=raw,value=${{ env.RELEASE_VERSION }}
- uses: docker/build-push-action@v5
id: build
with:
context: .
target: mermaid
push: ${{ github.event_name == 'push' }}
platforms: linux/amd64,linux/arm64
pull: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: Generate Build Attestation
uses: actions/attest-build-provenance@v2
with:
subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
if: github.event_name == 'push'
+4 -5
View File
@@ -5,14 +5,13 @@ on:
branches:
- master
- develop
merge_group:
jobs:
playwright:
name: 'Playwright Tests'
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.60.0-jammy
image: mcr.microsoft.com/playwright:v1.52.0-jammy
steps:
- name: Checkout
@@ -50,6 +49,6 @@ jobs:
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: test-results
path: test-results/
retention-days: 7
name: playwright-report
path: playwright-report/
retention-days: 30
-4
View File
@@ -5,7 +5,6 @@ on:
branches:
- master
- develop
merge_group:
jobs:
unit-tests:
@@ -39,8 +38,5 @@ jobs:
- name: Lint
run: pnpm lint
- name: Type check
run: pnpm check
- name: Run unit tests
run: pnpm test:unit
-1
View File
@@ -15,4 +15,3 @@
/playwright-report/
/blob-report/
/playwright/.cache/
.playwright-mcp/
+1 -1
View File
@@ -1 +1 @@
24.16.0
22.15.0
+2 -2
View File
@@ -4,6 +4,6 @@
"bracketSameLine": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"tailwindStylesheet": "./src/app.css"
"tailwindConfig": "./tailwind.config.js",
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"]
}
-10
View File
@@ -1,10 +0,0 @@
{
"servers": {
"svelte": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@sveltejs/mcp"]
}
},
"inputs": []
}
+3 -12
View File
@@ -9,17 +9,14 @@
"corg",
"cssnano",
"esserializer",
"fontawesome",
"fsegurai",
"gantt",
"gitgraph",
"hugeicons",
"KROKI",
"localstorage",
"mermaidchart",
"mindmap",
"NEWYEAR",
"noopener",
"noreferrer",
"Pageview",
"pako",
"panmove",
@@ -29,14 +26,12 @@
"pinchstart",
"pzoom",
"roughjs",
"sankey",
"Serde",
"serdes",
"Stackable",
"tailwindcss",
"treemap",
"uparrow",
"xychart",
"webfonts",
"zenuml"
],
"vitest.commandLine": "pnpm test:unit",
@@ -51,9 +46,5 @@
"tailwindCSS.experimental.classRegex": [
["([\"'`][^\"'`]*.*?[\"'`])", "[\"'`]([^\"'`]*).*?[\"'`]"]
],
"tailwindCSS.emmetCompletions": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "explicit"
}
"tailwindCSS.emmetCompletions": true
}
+1 -4
View File
@@ -1,4 +1,4 @@
FROM docker.io/library/node:24.16.0-alpine3.22 AS mermaid-live-editor-dependencies
FROM docker.io/library/node:22.15.0-alpine3.21 AS mermaid-live-editor-dependencies
RUN apk --no-cache add build-base git python3 && \
rm -rf /var/cache/apk/*
@@ -19,9 +19,6 @@ ARG MERMAID_KROKI_RENDERER_URL
ARG MERMAID_ANALYTICS_URL
ARG MERMAID_DOMAIN
ARG MERMAID_IS_ENABLED_MERMAID_CHART_LINKS
ARG MERMAID_PRIVACY_POLICY_URL
ARG MERMAID_HIDE_PRIVACY_POLICY
ARG MERMAID_BASE_PATH
COPY . ./
-2
View File
@@ -28,8 +28,6 @@ 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
```
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
When building set the MERMAID_RENDERER_URL build argument to the rendering
+5 -4
View File
@@ -1,16 +1,17 @@
{
"$schema": "https://next.shadcn-svelte.com/schema.json",
"style": "new-york",
"tailwind": {
"css": "src/app.css",
"config": "tailwind.config.js",
"css": "src/app.postcss",
"baseColor": "slate"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
"hooks": "$lib/hooks"
},
"typescript": true,
"registry": "https://tw3.shadcn-svelte.com/registry/new-york"
"registry": "https://next.shadcn-svelte.com/registry"
}
-74
View File
@@ -1,74 +0,0 @@
import { includeIgnoreFile } from '@eslint/compat';
import js from '@eslint/js';
import prettier from 'eslint-config-prettier';
import sortKeysPlugin from 'eslint-plugin-sort-keys';
import svelte from 'eslint-plugin-svelte';
import eslintPluginUnicorn from 'eslint-plugin-unicorn';
import { defineConfig } from 'eslint/config';
import globals from 'globals';
import { fileURLToPath } from 'node:url';
import ts from 'typescript-eslint';
import svelteConfig from './svelte.config.js';
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
export default defineConfig(
includeIgnoreFile(gitignorePath),
js.configs.recommended,
...ts.configs.strict,
...ts.configs.stylistic,
...svelte.configs.recommended,
prettier,
...svelte.configs.prettier,
{
languageOptions: {
globals: { ...globals.browser, ...globals.node }
},
rules: {
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
'no-undef': 'off'
}
},
{
plugins: {
unicorn: eslintPluginUnicorn
},
rules: {
'unicorn/no-null': 'off',
'unicorn/filename-case': 'off'
}
},
{
files: ['src/**'],
plugins: {
'sort-keys': sortKeysPlugin
},
rules: {
'sort-keys/sort-keys-fix': ['error', 'asc', { minKeys: 5 }]
}
},
{
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
projectService: true,
extraFileExtensions: ['.svelte'],
parser: ts.parser,
svelteConfig
}
},
rules: {
'svelte/no-unused-props': 'off'
}
},
{
files: ['**/components/ui/**'],
rules: {
'unicorn/prefer-export-from': 'off',
'unicorn/prevent-abbreviations': 'off',
'unicorn/explicit-length-check': 'off',
'sort-keys/sort-keys-fix': 'off'
}
}
);
-1
View File
@@ -1,6 +1,5 @@
[build.environment]
MERMAID_ANALYTICS_URL = 'https://p.mermaid.live'
MERMAID_DOCS_URL = 'https://mermaid.js.org'
MERMAID_DOMAIN = 'mermaid.live'
MERMAID_RENDERER_URL = 'https://mermaid.ink'
MERMAID_KROKI_RENDERER_URL = 'https://kroki.io'
+74 -82
View File
@@ -9,10 +9,8 @@
"dev:test": "pnpm dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check --cache . && eslint .",
"lint:fix": "prettier --write --cache . && eslint --fix .",
"lint": "prettier --check --cache . && eslint --ignore-path .gitignore .",
"lint:fix": "prettier --write --cache . && eslint --fix --ignore-path .gitignore .",
"format": "prettier --write --cache .",
"pre-commit": "lint-staged",
"postinstall": "husky install && svelte-kit sync && (git config blame.ignoreRevsFile .git-blame-ignore-revs || true)",
@@ -25,107 +23,101 @@
"test:e2e:debug": "playwright test --debug"
},
"devDependencies": {
"@eslint/compat": "^2.1.0",
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^10.0.1",
"@fortawesome/fontawesome-free": "^7.2.0",
"@iconify-json/hugeicons": "^1.2.29",
"@iconify-json/logos": "^1.2.11",
"@iconify-json/material-symbols": "^1.2.79",
"@fortawesome/fontawesome-free": "^6.7.2",
"@iconify-json/material-symbols": "^1.2.20",
"@iconify-json/mdi": "^1.2.3",
"@playwright/test": "^1.60.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.1",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/typography": "^0.5.20",
"@tailwindcss/vite": "^4.3.1",
"@playwright/test": "^1.52.0",
"@sveltejs/adapter-static": "3.0.8",
"@sveltejs/kit": "2.20.8",
"@sveltejs/vite-plugin-svelte": "^4.0.4",
"@types/hammerjs": "^2.0.46",
"@types/lodash-es": "^4.17.12",
"@types/node": "^24.13.2",
"@types/pako": "2.0.4",
"@vitest/coverage-v8": "^4.1.9",
"@vitest/ui": "^4.1.9",
"autoprefixer": "^10.5.0",
"bits-ui": "^2.18.1",
"c8": "11.0.0",
"chai": "^6.2.2",
"@types/node": "^22.15.10",
"@types/pako": "2.0.3",
"@types/uuid": "9.0.8",
"@typescript-eslint/eslint-plugin": "6.21.0",
"@typescript-eslint/parser": "6.21.0",
"@vitest/coverage-v8": "2.1.9",
"@vitest/ui": "^2.1.9",
"autoprefixer": "^10.4.21",
"bits-ui": "^1.4.6",
"c8": "7.14.0",
"chai": "^4.5.0",
"clsx": "^2.1.1",
"cssnano": "^8.0.2",
"dotenv": "^17.4.2",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"cssnano": "^6.1.2",
"eslint": "8.57.1",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-es": "^4.1.0",
"eslint-plugin-no-only-tests": "^3.4.0",
"eslint-plugin-no-only-tests": "^3.3.0",
"eslint-plugin-postcss-modules": "^2.0.0",
"eslint-plugin-sort-keys": "^2.3.5",
"eslint-plugin-svelte": "^3.19.0",
"eslint-plugin-tailwindcss": "^3.18.3",
"eslint-plugin-unicorn": "^65.0.1",
"globals": "^17.6.0",
"husky": "^9.1.7",
"jsdom": "^29.1.1",
"lint-staged": "^17.0.7",
"lucide-svelte": "^1.0.1",
"node-html-parser": "^7.1.0",
"paneforge": "1.0.2",
"prettier": "^3.8.4",
"prettier-plugin-svelte": "^4.1.1",
"prettier-plugin-tailwindcss": "^0.8.0",
"svelte": "^5.56.3",
"svelte-check": "^4.6.0",
"svelte-preprocess": "^6.0.5",
"svelte-sonner": "^1.1.1",
"tailwind-merge": "^3.6.0",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.3.1",
"eslint-plugin-svelte": "^2.46.1",
"eslint-plugin-tailwindcss": "^3.18.0",
"eslint-plugin-unicorn": "^50.0.1",
"eslint-plugin-vitest": "^0.5.4",
"esserializer": "^1.3.11",
"husky": "^8.0.3",
"jsdom": "^25.0.1",
"lint-staged": "^15.5.1",
"lucide-svelte": "^0.507.0",
"node-html-parser": "^6.1.13",
"paneforge": "^1.0.0-next.5",
"postcss": "^8.5.3",
"postcss-load-config": "5.1.0",
"prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"svelte": "^5.28.2",
"svelte-preprocess": "^6.0.3",
"svelte-sonner": "^0.3.28",
"tailwind-merge": "^3.2.0",
"tailwind-variants": "^0.3.1",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"tslib": "^2.8.1",
"tw-animate-css": "^1.4.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.60.1",
"unplugin-icons": "^23.0.1",
"vite": "^8.0.16",
"vite-plugin-devtools-json": "^1.0.0",
"vitest": "^4.1.9",
"typescript": "^5.8.3",
"unplugin-icons": "^22.1.0",
"vite": "^5.4.19",
"vitest": "^2.1.9",
"vitest-dom": "^0.1.1"
},
"dependencies": {
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.0",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/language": "^6.12.3",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.1",
"@fontsource-variable/recursive": "^5.2.8",
"@fsegurai/codemirror-theme-vscode-dark": "^6.2.6",
"@fsegurai/codemirror-theme-vscode-light": "^6.2.6",
"@mermaid-js/examples": "^1.2.0",
"@mermaid-js/layout-elk": "^0.2.1",
"@mermaid-js/layout-tidy-tree": "^0.2.2",
"@mermaid-js/mermaid-zenuml": "^0.2.3",
"codemirror": "^6.0.2",
"dayjs": "^1.11.21",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-markdown": "^6.3.2",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.11.0",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.7",
"@fontsource-variable/recursive": "^5.2.5",
"@fsegurai/codemirror-theme-vscode-dark": "^6.1.4",
"@fsegurai/codemirror-theme-vscode-light": "^6.1.4",
"@mermaid-js/layout-elk": "^0.1.7",
"@mermaid-js/mermaid-zenuml": "^0.2.0",
"codemirror": "^6.0.1",
"dayjs": "^1.11.13",
"hammerjs": "^2.0.8",
"js-base64": "3.7.8",
"lodash-es": "^4.18.1",
"mermaid": "^11.15.0",
"mode-watcher": "^1.1.0",
"monaco-editor": "0.55.1",
"js-base64": "3.7.7",
"lodash-es": "^4.17.21",
"mermaid": "^11.6.0",
"mode-watcher": "^0.5.1",
"monaco-editor": "0.52.2",
"pako": "2.1.0",
"plausible-tracker": "^0.3.9",
"random-word-slugs": "0.1.7",
"svg-pan-zoom": "3.6.2",
"svg2roughjs": "^3.2.3",
"uuid": "14.0.0"
"svg2roughjs": "^3.2.1",
"uuid": "9.0.1"
},
"lint-staged": {
"*.{ts,svelte,js,css,md,json}": [
"prettier --plugin-search-dir=. --write",
"eslint "
"eslint --ignore-path .gitignore "
]
},
"engines": {
"node": ">=24.16.0"
"node": ">=20.19.0"
},
"packageManager": "pnpm@10.34.4+sha512.8768be55200ae3f2226b6527fcca2687e14bc4e5f12d7721a0f25da3df47915177058648db4177baf348120fa0ba2752d8d8d93f6beaf1fe64ae18da8de961af",
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39",
"pnpm": {
"onlyBuiltDependencies": [
"deasync",
+3891 -3517
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
const tailwindcss = require('tailwindcss');
const autoprefixer = require('autoprefixer');
const cssnano = require('cssnano');
const mode = process.env.NODE_ENV;
const dev = mode === 'development';
module.exports = {
plugins: [
// Some plugins, like postcss-nested, need to run before Tailwind
tailwindcss,
// But others, like autoprefixer, need to run after
autoprefixer,
!dev &&
cssnano({
preset: 'default'
})
]
};
-119
View File
@@ -1,119 +0,0 @@
@import '@fontsource-variable/recursive/crsv.css';
@import 'tailwindcss';
@import 'tw-animate-css';
@custom-variant dark (&:is(.dark *));
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
}
@layer base {
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(222.2 84% 4.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(222.2 84% 4.9%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(222.2 84% 4.9%);
--primary: hsl(240 10% 91%);
--primary-foreground: hsl(255 20% 15%);
--secondary: hsl(210 40% 96.1%);
--secondary-foreground: hsl(222.2 47.4% 11.2%);
--muted: hsl(228 24% 96%);
--muted-foreground: hsl(215.4 16.3% 46.9%);
--accent: hsl(340 100% 44%);
--accent-foreground: hsl(210 40% 98%);
--destructive: hsl(0 72.22% 50.59%);
--destructive-foreground: hsl(210 40% 98%);
--border: hsl(214.3 31.8% 91.4%);
--border-dark: hsl(214.3 31.8% 81.4%);
--input: hsl(214.3 31.8% 91.4%);
--ring: hsl(222.2 84% 4.9%);
--radius: 0.75rem;
}
.dark {
--background: hsl(222.2 84% 4.9%);
--foreground: hsl(210 40% 98%);
--card: hsl(222.2 84% 4.9%);
--card-foreground: hsl(210 40% 98%);
--popover: hsl(222.2 84% 4.9%);
--popover-foreground: hsl(210 40% 98%);
--primary: hsl(210 40% 30%);
--primary-foreground: hsl(222.2 47.4% 90%);
--secondary: hsl(217.2 32.6% 17.5%);
--secondary-foreground: hsl(210 40% 98%);
--muted: hsl(217.2 32.6% 17.5%);
--muted-foreground: hsl(215 20.2% 65.1%);
--accent: hsl(340 100% 44%);
--accent-foreground: hsl(210 40% 98%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(210 40% 98%);
--border: hsl(217.2 32.6% 17.5%);
--border-dark: hsl(217.2 32.6% 27.5%);
--input: hsl(217.2 32.6% 17.5%);
--ring: hsl(212.7 26.8% 83.9%);
}
}
@theme inline {
/* Radius (for rounded-*) */
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
/* Colors */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-border: var(--border);
--color-border-dark: var(--border-dark);
--color-input: var(--input);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-ring: var(--ring);
--color-radius: var(--radius);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
@layer base {
* {
@apply border-border;
}
body {
@apply h-screen w-screen overflow-hidden bg-background text-foreground;
}
}
body {
font-family: 'Recursive Variable', sans-serif;
}
.d {
@apply border border-red-500;
}
+2 -2
View File
@@ -5,11 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Online FlowChart &amp; Diagrams Editor - Mermaid Live Editor</title>
<meta name="og:image" content="%sveltekit.assets%/favicon.svg" />
<link rel="canonical" href="https://mermaid.live" />
<meta
name="description"
content="Simplify documentation and avoid heavy tools. Open source Visio Alternative. Commonly used for explaining your code! Mermaid is a simple markdown-like script language for generating charts from text via javascript." />
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/favicon.png" />
<link rel="icon" type="image/png" href="%sveltekit.assets%/favicon.svg" />
<link rel="mask-icon" href="%sveltekit.assets%/favicon.svg" color="#000000" />
<meta name="theme-color" content="#ff3670" />
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
+68
View File
@@ -0,0 +1,68 @@
@import '@fontsource-variable/recursive/crsv.css';
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 240 10% 91%;
--primary-foreground: 255 20% 15%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 228 24% 96%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 340 100% 44%;
--accent-foreground: 210 40% 98%;
--destructive: 0 72.22% 50.59%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.75rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 30%;
--primary-foreground: 222.2 47.4% 90%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 340 100% 44%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply h-screen w-screen overflow-hidden bg-background text-foreground;
}
}
body {
font-family: 'Recursive Variable', sans-serif;
}
.d {
@apply border border-red-500;
}
-3
View File
@@ -4,11 +4,8 @@ interface ImportMetaEnv {
readonly MERMAID_RENDERER_URL?: string;
readonly MERMAID_KROKI_RENDERER_URL?: string;
readonly MERMAID_ANALYTICS_URL?: string;
readonly MERMAID_DOCS_URL?: string;
readonly MERMAID_DOMAIN?: string;
readonly MERMAID_IS_ENABLED_MERMAID_CHART_LINKS?: string;
readonly MERMAID_PRIVACY_POLICY_URL?: string;
readonly MERMAID_HIDE_PRIVACY_POLICY?: string;
// more env variables...
}
+6
View File
@@ -0,0 +1,6 @@
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
const response = await resolve(event, {});
return response;
};
-174
View File
@@ -1,174 +0,0 @@
<script lang="ts">
import { Button } from '$/components/ui/button';
import { cn } from '$lib/utils.js';
import CloseIcon from '~icons/material-symbols/close-rounded';
interface Props {
show: boolean;
input: string;
onClose: () => void;
onHeightChange?: (height: number) => void;
onTryFree: () => void;
}
let { show, input = $bindable(), onClose, onHeightChange, onTryFree }: Props = $props();
let textarea = $state<HTMLTextAreaElement>();
let container = $state<HTMLDivElement>();
$effect(() => {
if (!container || !onHeightChange) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
if (entry.target instanceof HTMLElement) {
onHeightChange(entry.target.offsetHeight);
}
}
});
observer.observe(container);
return () => observer.disconnect();
});
function resizeTextarea() {
if (!textarea) return;
const computed = globalThis.getComputedStyle(textarea);
const lineHeight =
Number.parseFloat(computed.lineHeight) || Number.parseFloat(computed.fontSize) * 1.5 || 0;
const paddingTop = Number.parseFloat(computed.paddingTop) || 0;
const paddingBottom = Number.parseFloat(computed.paddingBottom) || 0;
const minHeight = lineHeight + paddingTop + paddingBottom;
const maxLines = 8;
const maxHeight = lineHeight * maxLines + paddingTop + paddingBottom;
textarea.style.height = 'auto';
const nextHeight = Math.max(minHeight, Math.min(textarea.scrollHeight, maxHeight));
textarea.style.height = `${nextHeight}px`;
textarea.style.overflowY = textarea.scrollHeight > maxHeight ? 'auto' : 'hidden';
}
$effect(() => {
if (input !== undefined) {
resizeTextarea();
}
});
function handleKeydown(e: KeyboardEvent) {
if (show && e.key === 'Escape') {
onClose();
}
}
function handleOutsideClick(e: MouseEvent) {
if (show && container && e.target instanceof Node && !container.contains(e.target)) {
onClose();
}
}
</script>
<svelte:window onkeydown={handleKeydown} onmousedown={handleOutsideClick} />
{#if show}
<div
bind:this={container}
class={cn(
'button-container-for-animation relative z-50 mr-6 flex w-auto flex-col gap-2 rounded-xl border-2 border-border bg-background p-2 shadow-xl dark:border-border-dark dark:bg-secondary',
!input.trim() && 'rainbow-border'
)}
role="dialog"
aria-modal="true"
tabindex="-1">
<div class="relative flex min-h-2 items-start gap-1 px-1">
<textarea
bind:this={textarea}
bind:value={input}
onkeydown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (input.trim()) {
onTryFree();
}
}
}}
placeholder="Describe what to add or change"
rows="1"
class="focus font-recursive min-h-0 flex-1 resize-none border-none bg-transparent px-1 text-sm font-normal text-foreground placeholder:text-muted-foreground focus:ring-0 focus:outline-none disabled:opacity-50 dark:text-foreground dark:placeholder:text-muted-foreground"
style="height: 20px; overflow-y: hidden;"></textarea>
<button onclick={onClose} class="text-muted-foreground hover:text-foreground">
<CloseIcon class="size-4" />
</button>
</div>
<div class="flex items-center justify-between">
<span class="font-recursive text-xs font-normal text-foreground dark:text-foreground"
>Sign Up at Mermaid.ai to try AI</span>
<Button
class="font-recursive h-6 w-16 gap-1.5 rounded-sm bg-accent p-1 text-xs font-medium text-white no-underline hover:bg-accent/90 hover:text-white hover:no-underline active:bg-accent/80 dark:bg-accent dark:text-white! dark:hover:bg-accent/90 dark:active:bg-accent/80"
onclick={onTryFree}>
Try free
</Button>
</div>
</div>
{/if}
<style>
@property --gradient-angle {
syntax: '<angle>';
initial-value: 0deg;
inherits: false;
}
@keyframes gradient-angle-shift {
0% {
--gradient-angle: 0deg;
}
100% {
--gradient-angle: 360deg;
}
}
.button-container-for-animation {
border-radius: 12px;
}
.button-container-for-animation::before {
content: '';
position: absolute;
inset: -2px;
border-radius: 14px;
padding: 2px;
background: conic-gradient(
from var(--gradient-angle, 0deg),
color-mix(in srgb, var(--color-accent) 0%, transparent) 0%,
color-mix(in srgb, var(--color-accent) 0%, transparent) 12%,
color-mix(in srgb, var(--color-accent) 12%, transparent) 18%,
color-mix(in srgb, var(--color-accent) 42%, transparent) 24%,
color-mix(in srgb, var(--color-accent) 77%, transparent) 32%,
rgba(93, 85, 212, 0.923) 41%,
rgba(93, 85, 212, 0.5) 52%,
color-mix(in srgb, var(--color-accent) 58%, transparent) 72%,
color-mix(in srgb, var(--color-accent) 56%, transparent) 82%,
color-mix(in srgb, var(--color-accent) 36%, transparent) 87%,
color-mix(in srgb, var(--color-accent) 19%, transparent) 92%,
color-mix(in srgb, var(--color-accent) 10%, transparent) 96%,
color-mix(in srgb, var(--color-accent) 3%, transparent) 98%,
color-mix(in srgb, var(--color-accent) 0%, transparent) 100%
);
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask-composite: exclude;
pointer-events: none;
z-index: 0;
opacity: 0;
transition: opacity 0.3s ease-out;
}
.button-container-for-animation.rainbow-border::before {
opacity: 1;
animation: gradient-angle-shift 2s linear infinite;
}
</style>
+133 -115
View File
@@ -2,16 +2,15 @@
import Card from '$/components/Card/Card.svelte';
import CopyButton from '$/components/CopyButton.svelte';
import CopyInput from '$/components/CopyInput.svelte';
import ExternalLinkWrapper from '$/components/ExternalLinkWrapper.svelte';
import { Button } from '$/components/ui/button';
import { Input } from '$/components/ui/input';
import { Separator } from '$/components/ui/separator';
import * as ToggleGroup from '$/components/ui/toggle-group';
import { TID } from '$/constants';
import { getDomain } from '$/util/util';
import { env } from '$/util/env';
import { browser } from '$app/environment';
import { waitForRender } from '$lib/util/autoSync';
import { inputState, updateCodeStore, urls, validatedState } from '$lib/util/state.svelte';
import { inputStateStore, stateStore, urlsStore } from '$lib/util/state';
import { logEvent } from '$lib/util/stats';
import { version as FAVersion } from '@fortawesome/fontawesome-free/package.json';
import dayjs from 'dayjs';
@@ -20,81 +19,123 @@
import ExternalLinkIcon from '~icons/material-symbols/open-in-new-rounded';
import WidthIcon from '~icons/material-symbols/width-rounded';
const FONT_AWESOME_URL = `https://cdnjs.cloudflare.com/ajax/libs/font-awesome/${FAVersion}/css/all.min.css`;
const fontAwesomeURLs = (() => {
const baseUrl = `https://cdnjs.cloudflare.com/ajax/libs/font-awesome/${FAVersion}`;
return {
css: `${baseUrl}/css/all.min.css`,
woff2: `${baseUrl}/webfonts/fa-solid-900.woff2`
};
})();
type Exporter = (context: CanvasRenderingContext2D, image: HTMLImageElement) => () => void;
const getFileName = (extension: string) =>
`mermaid-diagram-${dayjs().format('YYYY-MM-DD-HHmmss')}.${extension}`;
/**
* Fix text clipping in exported SVG for hand-drawn (rough) mode.
* svg2roughjs copies foreignObject elements but their height is often insufficient,
* causing text bottom edges to be cut off regardless of language.
*/
const fixForeignObjectClipping = (svg: HTMLElement) => {
const foreignObjects = svg.querySelectorAll('foreignObject');
foreignObjects.forEach((foreignObj) => {
const currentHeight = parseFloat(foreignObj.getAttribute('height') || '0');
if (currentHeight <= 0) return;
const currentY = parseFloat(foreignObj.getAttribute('y') || '0');
const newHeight = currentHeight * 1.5;
const heightDiff = newHeight - currentHeight;
foreignObj.setAttribute('height', newHeight.toString());
foreignObj.setAttribute('y', (currentY - heightDiff / 2).toString());
// Ensure inner HTML elements are vertically centered within the expanded area
const htmlElements = foreignObj.querySelectorAll('div, span, p');
htmlElements.forEach((htmlEl) => {
const el = htmlEl as HTMLElement;
el.style.display = 'flex';
el.style.alignItems = 'center';
el.style.justifyContent = 'center';
el.style.height = '100%';
});
});
};
const getSvgElement = () => {
const svgElement = document.querySelector('#container svg')?.cloneNode(true) as HTMLElement;
const getSvgElement = async () => {
try {
$inputStateStore.panZoom = false;
await new Promise((resolve) => setTimeout(resolve, 1000));
await waitForRender();
const svgElement = document.querySelector('#container svg');
if (!svgElement) {
throw new Error('svg not found');
}
svgElement.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
return svgElement;
return {
svg: svgElement.cloneNode(true) as HTMLElement,
box: svgElement.querySelector('g')!.getBoundingClientRect()
};
} finally {
setTimeout(() => {
$inputStateStore.panZoom = true;
}, 10000);
}
};
const getBase64SVG = (svg?: HTMLElement, width?: number, height?: number): string => {
if (svg) {
const injectFontAwesome = async ({
svgString,
fontAwesomeEmbedMode
}: {
svgString: string;
fontAwesomeEmbedMode: 'raw' | 'url';
}) => {
if (fontAwesomeEmbedMode === 'url') {
return `<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet href="${fontAwesomeURLs.css}" type="text/css"?>
${svgString}`;
}
const [fontAwesomeCSS, fontBlob] = await Promise.all([
fetch(fontAwesomeURLs.css).then((response) => response.text()),
fetch(fontAwesomeURLs.woff2).then((res) => res.blob())
]);
const reader = new FileReader();
const fontBase64 = await new Promise<string>((resolve) => {
reader.onloadend = () => {
const base64 = reader.result as string;
// Remove the data URL prefix (data:application/octet-stream;base64,)
resolve(base64.split(',')[1]);
};
reader.readAsDataURL(fontBlob);
});
const styleString = `
@font-face {
font-family: 'Font Awesome 6 Free';
font-style: normal;
font-weight: 900;
src: url(data:font/woff2;base64,${fontBase64}) format('woff2');
}
.fa {
font-family: 'Font Awesome 6 Free';
font-weight: 900;
// TODO: Make this dynamic from config
font-size: 16px;
width: 16px;
fill: black;
}
${fontAwesomeCSS}
`;
return svgString.replace('<style>', `<style>${styleString}`);
};
const getBase64SVG = async ({
svg,
box,
width,
height,
embedFontAwesome
}: {
svg?: HTMLElement;
box?: DOMRect;
width?: number;
height?: number;
embedFontAwesome?: boolean;
} = {}): Promise<string> => {
if (!svg || !box) {
({ svg, box } = await getSvgElement());
}
console.log(box);
// Prevents the SVG size of the interface from being changed
svg = svg.cloneNode(true) as HTMLElement;
}
if (height) {
svg?.setAttribute('height', `${height}px`);
}
if (width) {
svg?.setAttribute('width', `${width}px`);
}
// Workaround https://stackoverflow.com/questions/28690643/firefox-error-rendering-an-svg-image-to-html5-canvas-with-drawimage
if (!svg) {
svg = getSvgElement();
}
if (validatedState.current.rough) {
fixForeignObjectClipping(svg);
}
svg.style.backgroundColor = window
.getComputedStyle(document.body)
.getPropertyValue('--background');
// svg = svg.cloneNode(true) as HTMLElement;
height && svg.setAttribute('height', `${height}px`);
width && svg.setAttribute('width', `${width}px`); // Workaround https://stackoverflow.com/questions/28690643/firefox-error-rendering-an-svg-image-to-html5-canvas-with-drawimage
svg.setAttribute('viewBox', `0 0 ${box.width} ${box.height}`);
const svgString = svg.outerHTML
.replaceAll('<br>', '<br/>')
.replaceAll(/<img([^>]*)>/g, (m, g: string) => `<img ${g} />`);
return toBase64(`<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet href="${FONT_AWESOME_URL}" type="text/css"?>
${svgString}`);
const svgStringWithFontAwesome = await injectFontAwesome({
svgString,
fontAwesomeEmbedMode: embedFontAwesome ? 'raw' : 'url'
});
console.log(svgStringWithFontAwesome);
return toBase64(svgStringWithFontAwesome);
};
const simulateDownload = (download: string, href: string): void => {
@@ -106,36 +147,23 @@ ${svgString}`);
};
const exportImage = async (event: Event, exporter: Exporter) => {
updateCodeStore({ panZoom: false });
await new Promise((resolve) => setTimeout(resolve, 1000));
await waitForRender();
event.stopPropagation();
event.preventDefault();
const canvas = document.createElement('canvas');
const svg = document.querySelector<HTMLElement>('#container svg');
if (!svg) {
throw new Error('svg not found');
}
const box = svg.getBoundingClientRect();
// In rough mode, SVG has width/height="100%" so getBoundingClientRect returns
// the container size, not the actual diagram size. Use viewBox dimensions instead.
const svgEl = svg as unknown as SVGSVGElement;
const viewBox = svgEl.viewBox?.baseVal;
const contentWidth = viewBox && viewBox.width > 0 ? viewBox.width : box.width;
const contentHeight = viewBox && viewBox.height > 0 ? viewBox.height : box.height;
const { svg, box } = await getSvgElement();
if (imageSizeMode === 'width') {
const ratio = contentHeight / contentWidth;
const ratio = box.height / box.width;
canvas.width = imageSize;
canvas.height = imageSize * ratio;
} else if (imageSizeMode === 'height') {
const ratio = contentWidth / contentHeight;
const ratio = box.width / box.height;
canvas.width = imageSize * ratio;
canvas.height = imageSize;
} else {
const multiplier = 2;
canvas.width = contentWidth * multiplier;
canvas.height = contentHeight * multiplier;
canvas.width = box.width * multiplier;
canvas.height = box.height * multiplier;
}
const context = canvas.getContext('2d');
@@ -143,24 +171,20 @@ ${svgString}`);
throw new Error('context not found');
}
context.fillStyle = window.getComputedStyle(document.body).getPropertyValue('--background');
context.fillStyle = `hsl(${window.getComputedStyle(document.body).getPropertyValue('--background')})`;
context.fillRect(0, 0, canvas.width, canvas.height);
const image = new Image();
image.addEventListener('load', () => {
exporter(context, image)();
updateCodeStore({ panZoom: true });
});
image.src = `data:image/svg+xml;base64,${getBase64SVG(svg, canvas.width, canvas.height)}`;
image.src = `data:image/svg+xml;base64,${await getBase64SVG({ svg, box, height: canvas.height, width: canvas.width, embedFontAwesome: true })}`;
// Fallback to set panZoom to true after 2 seconds
// This is a workaround for the case when the image is not loaded
setTimeout(() => {
if (!inputState.panZoom) {
updateCodeStore({ panZoom: true });
}
}, 2000);
event.stopPropagation();
event.preventDefault();
// setTimeout(() => {
// if (!$inputStateStore.panZoom) {
// }
// }, 2000);
};
const downloadImage: Exporter = (context, image) => {
@@ -199,10 +223,7 @@ ${svgString}`);
};
};
const onCopyClipboard = async (event?: Event) => {
if (!event) {
return;
}
const onCopyClipboard = async (event: Event) => {
await exportImage(event, clipboardCopy);
logEvent('copyClipboard');
};
@@ -214,16 +235,15 @@ ${svgString}`);
});
};
const onDownloadSVG = () => {
simulateDownload(getFileName('svg'), `data:image/svg+xml;base64,${getBase64SVG()}`);
const onDownloadSVG = async () => {
simulateDownload(getFileName('svg'), `data:image/svg+xml;base64,${await getBase64SVG()}`);
logEvent('download', {
type: 'svg'
});
};
let gistURL = $state('');
$effect(() => {
const { loader } = validatedState.current;
stateStore.subscribe(({ loader }) => {
if (loader?.type === 'gist') {
gistURL = loader.config.url;
}
@@ -259,17 +279,17 @@ ${svgString}`);
<DownloadIcon />
{text}
</Button>
<ExternalLinkWrapper domain={getDomain(url)} isVisible={!!url}>
{#if url}
<Button class="rounded-l-none" href={url} target="_blank" rel="noreferrer noopener">
<ExternalLinkIcon />
</Button>
</ExternalLinkWrapper>
{/if}
</div>
{/snippet}
<Card title="Actions" isStackable icon={{ component: DownloadIcon, class: 'rotate-180' }}>
<div class="flex min-w-fit flex-col gap-2 p-2">
<div class="flex w-full items-center gap-2 py-2 whitespace-nowrap">
<div class="flex w-full items-center gap-2 whitespace-nowrap py-2">
PNG size
<ToggleGroup.Root type="single" variant="outline" bind:value={imageSizeMode}>
<ToggleGroup.Item value="auto">Auto</ToggleGroup.Item>
@@ -288,26 +308,24 @@ ${svgString}`);
bind:value={imageSize} />
</div>
<div class="flex gap-2">
{@render dualActionButton('PNG', onDownloadPNG, urls.current.png)}
{@render dualActionButton('SVG', onDownloadSVG, urls.current.svg)}
<ExternalLinkWrapper domain={getDomain(urls.current.kroki)} isVisible={!!urls.current.kroki}>
<a target="_blank" rel="noreferrer" class="flex-grow" href={urls.current.kroki}>
{@render dualActionButton('PNG', onDownloadPNG, $urlsStore.png)}
{@render dualActionButton('SVG', onDownloadSVG, $urlsStore.svg)}
{#if env.krokiRendererUrl}
<a target="_blank" rel="noreferrer" class="flex-grow" href={$urlsStore.kroki}>
<Button class="action-btn flex w-full items-center gap-2">
<ExternalLinkIcon /> Kroki
</Button>
</a>
</ExternalLinkWrapper>
{/if}
</div>
<Separator />
{#if isClipboardAvailable()}
<CopyButton onclick={onCopyClipboard} label="Copy Image" />
{/if}
<ExternalLinkWrapper
labelPrefix="Thumbnail generated by"
domain={getDomain(urls.current.png)}
isVisible={!!urls.current.mdCode}>
<CopyInput value={urls.current.mdCode} label="Copy Markdown" testID={TID.copyMarkdown} />
</ExternalLinkWrapper>
{#if $urlsStore.mdCode}
<CopyInput value={$urlsStore.mdCode} label="Copy Markdown" testID={TID.copyMarkdown} />
{/if}
<div class="flex w-full items-center gap-2">
<Input type="url" bind:value={gistURL} placeholder="Enter Gist URL" />
<Button onclick={loadGist}>Load Gist</Button>
+1 -1
View File
@@ -54,7 +54,7 @@
role="toolbar"
tabindex="0"
class={[
'flex h-11 flex-none cursor-pointer items-center justify-between bg-muted p-2 whitespace-nowrap',
'flex h-11 flex-none cursor-pointer items-center justify-between whitespace-nowrap bg-muted p-2',
isTabsShown && 'pb-1'
]}
onclick={toggleCardOpen}
+5 -4
View File
@@ -14,8 +14,9 @@
onselect?: (tab: Tab) => void;
} = $props();
// Derive (don't mutate the prop) so the highlight tracks a bound activeTabID.
const effectiveTabID = $derived(activeTabID || tabs[0]?.id);
if (!activeTabID && tabs.length > 0) {
activeTabID = tabs[0].id;
}
const toggleTabs = (tab: Tab) => {
return (event: Event) => {
@@ -27,13 +28,13 @@
<div class="flex w-fit cursor-default items-center gap-2">
<ul class="flex gap-2 align-middle" transition:fade>
{#each tabs as tab, index (tab.id)}
{#each tabs as tab, index}
<Button
role="tab"
variant="ghost"
class={[
'px-2',
effectiveTabID === tab.id && 'rounded-b-none border-b-2 border-b-primary-foreground/50'
activeTabID === tab.id && 'rounded-b-none border-b-2 border-b-primary-foreground/50'
]}
onclick={toggleTabs(tab)}
onkeypress={toggleTabs(tab)}>
+2 -1
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { Button } from '$/components/ui/button';
import type { InputType } from '$/types';
import { notify } from '$/util/notify';
import { scale } from 'svelte/transition';
import CheckIcon from '~icons/material-symbols/check-rounded';
@@ -8,7 +9,7 @@
let {
onclick,
label = 'Copy'
}: { onclick: (event?: Event) => Promise<unknown>; label?: string } = $props();
}: { onclick: (event?: Event) => Promise<unknown>; label?: string; type?: InputType } = $props();
let showCheckIcon = $state(false);
</script>
+30 -185
View File
@@ -1,9 +1,6 @@
<script lang="ts">
import type { EditorProps } from '$/types';
import { env } from '$/util/env';
import { urls, validatedState } from '$/util/state.svelte';
import { logMermaidChartClick } from '$/util/stats';
import { AIPromptViewZoneManager } from '$lib/util/AIPromptViewZoneManager';
import { stateStore } from '$/util/state';
import { initEditor } from '$lib/util/monacoExtra';
import { errorDebug } from '$lib/util/util';
import { mode } from 'mode-watcher';
@@ -11,39 +8,18 @@
import monacoEditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import monacoJsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
import { onMount } from 'svelte';
import AIPromptPopup from './AIPromptPopup.svelte';
const { onUpdate }: EditorProps = $props();
let divElement: HTMLDivElement | undefined = $state();
let aiPromptPopupElement: HTMLDivElement | undefined = $state();
let editor: monaco.editor.IStandaloneCodeEditor | undefined;
let editorOptions = {
minimap: {
enabled: false
},
overviewRulerLanes: 0,
glyphMargin: true,
lineNumbersMinChars: 4
overviewRulerLanes: 0
} satisfies monaco.editor.IStandaloneEditorConstructionOptions;
let currentText = '';
let isUpdatingFromState = false;
let showPopup = $state(false);
let popupPosition = $state({ top: 0, lineNumber: 0 });
let decorationsCollection: monaco.editor.IEditorDecorationsCollection | undefined;
let input = $state('');
let lastMouseLine = 0;
const aiPromptManager = new AIPromptViewZoneManager();
const applyEditorTheme = (currentMode: typeof mode.current) => {
if (!editor) return;
monaco.editor.setTheme(`mermaid${currentMode === 'dark' ? '-dark' : ''}`);
divElement?.classList.toggle('mermaid-dark', currentMode === 'dark');
};
$effect(() => {
applyEditorTheme(mode.current);
});
const jsonModel = monaco.editor.createModel(
'',
@@ -56,51 +32,6 @@
monaco.Uri.parse('internal://mermaid.mmd')
);
const renderAIPromptGutterGlyphIcon = () => {
decorationsCollection?.clear();
if (!editor || showPopup) {
return;
}
const model = editor.getModel();
if (!model) {
return;
}
if (lastMouseLine > 0 && model.id === mermaidModel.id) {
decorationsCollection?.set([
{
range: new monaco.Range(lastMouseLine, 1, lastMouseLine, 1),
options: {
glyphMarginClassName: 'suggestion-icon'
}
}
]);
}
};
const closePopup = () => {
showPopup = false;
input = '';
aiPromptManager.hide();
renderAIPromptGutterGlyphIcon();
};
const toggleAIPopup = (lineNumber: number) => {
if (!divElement || !aiPromptPopupElement) return;
popupPosition = {
top: 0,
lineNumber
};
showPopup = !showPopup;
if (showPopup) {
aiPromptManager.show(popupPosition.lineNumber, aiPromptPopupElement, 100);
editor?.setSelection(new monaco.Range(0, 0, 0, 0));
} else {
aiPromptManager.hide();
}
renderAIPromptGutterGlyphIcon();
};
onMount(() => {
self.MonacoEnvironment = {
getWorker(_, label) {
@@ -115,13 +46,13 @@
throw new Error('divEl is undefined');
}
monaco.json.jsonDefaults.setDiagnosticsOptions({
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
enableSchemaRequest: true,
schemas: [
{
fileMatch: ['config.json'],
uri: `${env.docsUrl}/schemas/config.schema.json`
uri: 'https://mermaid.js.org/schemas/config.schema.json'
}
]
});
@@ -129,43 +60,41 @@
initEditor(monaco);
errorDebug();
editor = monaco.editor.create(divElement, editorOptions);
aiPromptManager.setEditor(editor);
decorationsCollection = editor.createDecorationsCollection([]);
editor.onMouseDown((e) => {
const isGutter = e.target.type === monaco.editor.MouseTargetType.GUTTER_GLYPH_MARGIN;
if (isGutter && e.target.position?.lineNumber === lastMouseLine && lastMouseLine > 0) {
e.event.preventDefault();
e.event.stopPropagation();
toggleAIPopup(e.target.position.lineNumber);
}
});
editor.onDidChangeModelContent(({ isFlush }) => {
const newText = editor?.getValue();
if (!newText || currentText === newText || isFlush || isUpdatingFromState) {
if (!newText || currentText === newText || isFlush) {
return;
}
currentText = newText;
onUpdate(currentText);
});
editor.onMouseMove((e) => {
if (!editor) return;
if (showPopup) return;
if (editor.getModel()?.id !== mermaidModel.id) return;
const unsubscribeState = stateStore.subscribe(({ errorMarkers, editorMode, code, mermaid }) => {
if (!editor) {
return;
}
lastMouseLine = e.target.position?.lineNumber ?? 0;
renderAIPromptGutterGlyphIcon();
const model = editorMode === 'code' ? mermaidModel : jsonModel;
if (editor.getModel()?.id !== model.id) {
editor.setModel(model);
}
// Update editor text if it's different
const newText = editorMode === 'code' ? code : mermaid;
if (newText !== currentText) {
editor.setScrollTop(0);
editor.setValue(newText);
currentText = newText;
}
// Display/clear errors
monaco.editor.setModelMarkers(model, 'mermaid', errorMarkers);
});
editor.onMouseLeave(() => {
lastMouseLine = 0;
renderAIPromptGutterGlyphIcon();
const unsubscribeMode = mode.subscribe((mode) => {
editor && monaco.editor.setTheme(`mermaid${mode === 'dark' ? '-dark' : ''}`);
});
applyEditorTheme(mode.current);
const resizeObserver = new ResizeObserver((entries) => {
editor?.layout({
height: entries[0].contentRect.height,
@@ -177,97 +106,13 @@
resizeObserver.observe(divElement);
}
renderAIPromptGutterGlyphIcon();
return () => {
unsubscribeState();
unsubscribeMode();
resizeObserver.disconnect();
jsonModel.dispose();
mermaidModel.dispose();
aiPromptManager.destroy();
editor?.dispose();
};
});
$effect(() => {
const { errorMarkers, editorMode, code, mermaid } = validatedState.current;
if (!editor) {
return;
}
const model = editorMode === 'code' ? mermaidModel : jsonModel;
if (editor.getModel()?.id !== model.id) {
editor.setModel(model);
renderAIPromptGutterGlyphIcon();
}
// Clear decorations if not in 'code' mode, or if the model changes
if (editorMode !== 'code' || editor.getModel()?.id !== mermaidModel.id) {
decorationsCollection?.clear();
}
// Update editor text if it's different
const newText = editorMode === 'code' ? code : mermaid;
if (newText !== currentText) {
isUpdatingFromState = true;
try {
editor.setScrollTop(0);
editor.pushUndoStop();
editor.executeEdits('updateCode', [
{
range: model.getFullModelRange(),
text: newText
}
]);
editor.pushUndoStop();
currentText = newText;
} finally {
isUpdatingFromState = false;
}
renderAIPromptGutterGlyphIcon();
}
// Display/clear errors
monaco.editor.setModelMarkers(model, 'mermaid', errorMarkers);
});
</script>
<div class="relative h-full grow overflow-hidden">
<div bind:this={divElement} id="editor" class="h-full w-full"></div>
<div bind:this={aiPromptPopupElement}>
<AIPromptPopup
show={showPopup}
bind:input
onHeightChange={(height) => aiPromptManager.updateHeight(height)}
onClose={closePopup}
onTryFree={() => {
logMermaidChartClick('vibeDiagramming');
window.open(
urls.current.mermaidChart({ medium: 'vibe_diagramming' }).save,
'_blank',
'noopener'
);
closePopup();
}} />
</div>
</div>
<style>
:global(.suggestion-icon) {
background-color: #e8eaf9;
width: 20px !important;
height: 20px !important;
margin-left: 4px;
background-image: url('/icons/use-chat.svg');
background-size: 16px 16px;
background-repeat: no-repeat;
background-position: center;
border-radius: 4px;
cursor: pointer;
}
:global(#editor.mermaid-dark .suggestion-icon) {
background-color: #2e4d6b;
background-image: url('/icons/use-chat-dark.svg');
}
</style>
<div bind:this={divElement} id="editor" class="h-full flex-grow overflow-hidden"></div>
@@ -2,12 +2,11 @@
import { Button } from '$/components/ui/button';
import { TID } from '$/constants';
import type { DocumentationConfig } from '$/types';
import { env } from '$/util/env';
import { standardizeDiagramType } from '$/util/mermaid';
import { validatedState } from '$/util/state.svelte';
import { stateStore } from '$/util/state';
import BookIcon from '~icons/material-symbols/book-2-outline-rounded';
const docURLBase = env.docsUrl;
const docURLBase = 'https://mermaid.js.org';
const docMap = {
architecture: {
code: '/syntax/architecture.html'
@@ -78,10 +77,6 @@
code: '/syntax/timeline.html',
config: '/syntax/timeline.html#themes'
},
treemap: {
code: '/syntax/treemap.html',
config: '/syntax/treemap.html#configuration-options'
},
xychart: {
code: '/syntax/xyChart.html',
config: '/syntax/xyChart.html#chart-configurations'
@@ -92,14 +87,12 @@
} as const satisfies DocumentationConfig;
const doc = $derived.by(() => {
const { editorMode, diagramType } = validatedState.current;
const { editorMode, diagramType } = $stateStore;
if (!diagramType) {
return { key: '', url: docURLBase };
}
const key = standardizeDiagramType(diagramType);
const docConfig: { code: string; config?: string } = docMap[key as keyof typeof docMap] ?? {
code: ''
};
const docConfig = docMap[key] ?? { code: '' };
const url = docURLBase + (docConfig[editorMode] ?? docConfig.code ?? '');
return { key, url };
});
+1 -1
View File
@@ -21,7 +21,7 @@
<Popover.Content>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<ul tabindex="0" class="flex flex-col">
{#each props.links as { href, title } (title)}
{#each props.links as { href, title }}
<li class="rounded-md p-2 hover:bg-muted">
<a
role="menuitem"
+8 -36
View File
@@ -6,38 +6,17 @@
import { Button } from '$/components/ui/button';
import { TID } from '$/constants';
import { env } from '$/util/env';
import { updateCode, updateConfig, urls, validatedState } from '$lib/util/state.svelte';
import { logMermaidChartClick } from '$lib/util/stats';
import { debounce } from 'lodash-es';
import { stateStore, updateCode, updateConfig, urlsStore } from '$lib/util/state';
import ExclamationCircleIcon from '~icons/material-symbols/error-outline-rounded';
const { isMobile } = $props<{ isMobile: boolean }>();
let { isMobile }: { isMobile: boolean } = $props();
const onUpdate = (text: string) => {
if (validatedState.current.editorMode === 'code') {
if ($stateStore.editorMode === 'code') {
updateCode(text);
} else {
updateConfig(text);
}
};
let showError = $state(false);
const showErrorDebounced = debounce(() => {
showError = true;
}, 3000);
$effect(() => {
if (validatedState.current.error) {
showErrorDebounced();
} else {
showErrorDebounced.cancel();
showError = false;
}
return () => {
showErrorDebounced.cancel();
};
});
</script>
<div class="flex h-full flex-col">
@@ -46,37 +25,30 @@
{:else}
<DesktopEditor {onUpdate} />
{/if}
{#if showError && validatedState.current.error instanceof Error}
{#if $stateStore.error instanceof Error}
<div class="flex flex-col text-sm" data-testid={TID.errorContainer}>
<div class="flex items-center justify-between gap-2 bg-slate-900 p-2 text-white">
<div class="flex w-fit items-center gap-2">
<ExclamationCircleIcon class="size-6 text-destructive" aria-hidden="true" />
<div class="flex flex-col">
<p>Syntax error</p>
{#if env.isEnabledMermaidChartLinks && validatedState.current.editorMode === 'code'}
<p class="text-xs text-white/60" data-testid={TID.aiHelpText}>
Create a free account to repair with AI
</p>
{#if env.isEnabledMermaidChartLinks}
<p class="text-xs text-white/60">Create a free account to repair with AI</p>
{/if}
</div>
</div>
{#if validatedState.current.editorMode === 'code'}
<McWrapper>
<Button
variant="accent"
size="sm"
data-testid={TID.aiRepairButton}
href={urls.current.mermaidChart({ medium: 'ai_repair' }).save}
target="_blank"
onclick={() => logMermaidChartClick('aiRepair')}>
href={$urlsStore.mermaidChart({ medium: 'ai_repair' }).save}>
<MermaidChartIcon />
AI Repair
</Button>
</McWrapper>
{/if}
</div>
<output class="max-h-32 overflow-auto bg-muted p-2" name="mermaid-error" for="editor">
<pre>{validatedState.current.error?.toString()}</pre>
<pre>{$stateStore.error?.toString()}</pre>
</output>
</div>
{/if}
@@ -1,119 +0,0 @@
<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,63 +0,0 @@
<script lang="ts">
import { validatedState } from '$/util/state.svelte';
import * as Tooltip from '$lib/components/ui/tooltip';
import type { ComponentProps, Snippet } from 'svelte';
import ExternalLinkIcon from '~icons/material-symbols/open-in-new-rounded';
let {
children,
domain,
shouldCheckDiagramType = true,
side = 'bottom',
labelPrefix = 'Opens your diagram in',
isVisible = true,
sharesData = true,
showPopup = true
}: {
children: Snippet;
domain: string;
shouldCheckDiagramType?: boolean;
side?: ComponentProps<typeof Tooltip.Content>['side'];
labelPrefix?: string;
isVisible?: boolean;
sharesData?: boolean;
showPopup?: boolean;
} = $props();
let shouldDisableComponent = $derived(
shouldCheckDiagramType && validatedState.current.diagramType === 'zenuml'
);
</script>
{#if isVisible}
<Tooltip.Provider>
<Tooltip.Root delayDuration={100}>
<Tooltip.Trigger>
<div class={[shouldDisableComponent && 'pointer-events-none cursor-not-allowed grayscale']}>
{@render children()}
</div>
</Tooltip.Trigger>
{#if showPopup}
<Tooltip.Content {side} class="bg-secondary shadow-xl">
<div
class="flex cursor-help items-center gap-2"
title={sharesData
? 'Your diagram will be sent to the external service'
: 'Your diagram is not shared'}>
{#if shouldDisableComponent}
<div class="text-muted-foreground">
This diagram type is not supported in {domain}
</div>
{:else}
<ExternalLinkIcon />
<span class="flex items-center gap-1">
{labelPrefix}
<div class="text-accent">{domain}</div>
</span>
{/if}
</div>
</Tooltip.Content>
{/if}
</Tooltip.Root>
</Tooltip.Provider>
{/if}
+1
View File
@@ -29,6 +29,7 @@
// Vite will automatically take care of adding this to our `<head>`
let lazyLoadFontAwesomeCSS = import('./FontAwesomeCSS.svelte');
// eslint-disable-next-line unicorn/prefer-top-level-await
let fontsLoaded = (async () => {
await lazyLoadFontAwesomeCSS;
+81 -78
View File
@@ -2,11 +2,12 @@
import Card from '$lib/components/Card/Card.svelte';
import type { HistoryEntry, HistoryType, State, Tab } from '$lib/types';
import { notify, prompt } from '$lib/util/notify';
import { serializeState } from '$lib/util/serde';
import { inputState, replaceInputState } from '$lib/util/state.svelte';
import { getStateString, inputStateStore } from '$lib/util/state';
import { logEvent } from '$lib/util/stats';
import dayjs from 'dayjs';
import dayjsRelativeTime from 'dayjs/plugin/relativeTime';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import BookmarkIcon from '~icons/material-symbols/bookmark-outline-rounded';
import TrashAltIcon from '~icons/material-symbols/delete-outline-rounded';
import DownloadIcon from '~icons/material-symbols/download-rounded';
@@ -15,51 +16,41 @@
import UploadIcon from '~icons/material-symbols/upload-rounded';
import HistoryIcon from '~icons/mdi/clock-outline';
import GitAltIcon from '~icons/mdi/git';
import OpenInNewIcon from '~icons/material-symbols/open-in-new-rounded';
import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import {
addManualEntry,
clearActive,
historyState,
removeEntry,
restoreEntries,
setMode
} from './historyState.svelte';
addHistoryEntry,
clearHistoryData,
getPreviousState,
historyModeStore,
historyStore,
loaderHistoryStore,
restoreHistory
} from './history';
dayjs.extend(dayjsRelativeTime);
const baseTabs: Tab[] = [
{ id: 'manual', title: 'Saved', icon: BookmarkIcon },
{ id: 'auto', title: 'Timeline', icon: HistoryIcon }
];
const loaderTab: Tab = { id: 'loader', title: 'Revisions', icon: GitAltIcon };
const tabs = $derived(
historyState.loaderEntries.length > 0 ? [loaderTab, ...baseTabs] : baseTabs
);
// Surface revisions once when they first appear; the user can switch away after.
let revisionsShown = false;
$effect(() => {
if (historyState.loaderEntries.length > 0 && !revisionsShown) {
revisionsShown = true;
setMode('loader');
}
});
const emptyMessage = $derived(
historyState.mode === 'auto'
? 'No timeline snapshots yet.\nThe Timeline is saved automatically every minute.'
: 'No saved states yet.\nClick the Save button to bookmark the current diagram and restore it later.'
);
const HISTORY_SAVE_INTERVAL = 60_000;
const tabSelectHandler = (tab: Tab) => {
setMode(tab.id as HistoryType);
historyModeStore.set(tab.id as HistoryType);
};
let tabs: Tab[] = $state([
{
id: 'manual',
title: 'Saved',
icon: BookmarkIcon
},
{
id: 'auto',
title: 'Timeline',
icon: HistoryIcon
}
]);
const downloadHistory = () => {
const data = historyState.entries;
const data = get(historyStore);
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -67,7 +58,9 @@
a.download = `mermaid-history-${dayjs().format('YYYY-MM-DD-HHmmss')}.json`;
a.click();
URL.revokeObjectURL(url);
logEvent('history', { action: 'download' });
logEvent('history', {
action: 'download'
});
};
const uploadHistory = () => {
@@ -80,39 +73,59 @@
return;
}
const data: HistoryEntry[] = JSON.parse(await file.text());
const { restored, invalid, duplicates } = restoreEntries(data);
notify(`${restored} restored, ${duplicates} duplicate, ${invalid} invalid.`);
restoreHistory(data);
});
input.click();
};
const saveHistory = () => {
if (!addManualEntry($state.snapshot(inputState))) {
const saveHistory = (auto = false) => {
const currentState: string = getStateString();
const previousState: string = getPreviousState(auto);
if (previousState !== currentState) {
addHistoryEntry({
state: $inputStateStore,
time: Date.now(),
type: auto ? 'auto' : 'manual'
});
} else if (!auto) {
notify('State already saved.');
}
};
const clearAll = () => {
if (prompt('Clear all saved items?')) {
clearActive();
const clearHistory = (id?: string): void => {
if (!id && !prompt('Clear all saved items?')) {
return;
}
clearHistoryData(id);
};
const restoreHistoryItem = (state: State): void => {
replaceInputState({ ...state, updateDiagram: true });
inputStateStore.set({ ...state, updateDiagram: true });
};
// Absolute editor URL for an entry, so the link can be opened in a new tab or copied.
const entryUrl = (state: State): string =>
`${window.location.origin}${window.location.pathname}#${serializeState(state)}`;
onMount(() => {
historyModeStore.set('manual');
setInterval(() => {
saveHistory(true);
}, HISTORY_SAVE_INTERVAL);
});
// Serialize each entry's URL once per change rather than per row on every render.
const entriesWithUrl = $derived(
historyState.entries.map((entry) => ({ ...entry, openUrl: entryUrl(entry.state) }))
);
loaderHistoryStore.subscribe((entries) => {
if (entries.length > 0 && tabs.length === 2) {
tabs = [
{
id: 'loader',
title: 'Revisions',
icon: GitAltIcon
},
...tabs
];
historyModeStore.set('loader');
}
});
</script>
<Card onselect={tabSelectHandler} isOpen isClosable={false} {tabs} activeTabID={historyState.mode}>
<Card onselect={tabSelectHandler} isOpen isClosable={false} {tabs}>
{#snippet actions()}
<div class="flex items-center gap-2">
<Button
@@ -121,7 +134,7 @@
id="uploadHistory"
onclick={uploadHistory}
title="Upload history"><UploadIcon /></Button>
{#if historyState.entries.length > 0}
{#if $historyStore.length > 0}
<Button
id="downloadHistory"
size="icon"
@@ -134,22 +147,22 @@
id="saveHistory"
size="icon"
variant="ghost"
onclick={saveHistory}
onclick={() => saveHistory()}
title="Save current state"><SaveIcon /></Button>
{#if historyState.mode !== 'loader'}
{#if $historyModeStore !== 'loader'}
<Button
id="clearHistory"
size="icon"
variant="ghost"
class="hover:text-destructive"
onclick={clearAll}
onclick={() => clearHistory()}
title="Delete all saved states"><TrashAltIcon /></Button>
{/if}
</div>
{/snippet}
<ul class="flex h-full min-w-fit flex-col gap-2 overflow-auto p-2" id="historyList">
{#if entriesWithUrl.length > 0}
{#each entriesWithUrl as { id, state, time, name, url, type, openUrl } (id)}
{#if $historyStore.length > 0}
{#each $historyStore as { id, state, time, name, url, type } (id)}
<li class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<div class="flex flex-col">
@@ -162,29 +175,16 @@
{:else}
<span class="whitespace-nowrap">{name}</span>
{/if}
<span class="text-xs whitespace-nowrap text-primary-foreground/30">
<span class="whitespace-nowrap text-xs text-primary-foreground/30">
{new Date(time).toLocaleString()}
</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm whitespace-nowrap text-primary-foreground/50">
<span class="whitespace-nowrap text-sm text-primary-foreground/50">
{dayjs(time).fromNow()}
</span>
<Button
href={openUrl}
target="_blank"
rel="noopener"
size="icon"
variant="ghost"
title="Open in new tab">
<OpenInNewIcon />
</Button>
<Button
size="icon"
variant="ghost"
title="Restore this version"
onclick={() => restoreHistoryItem(state)}>
<Button size="icon" variant="ghost" onclick={() => restoreHistoryItem(state)}>
<UndoIcon />
</Button>
{#if type !== 'loader'}
@@ -192,8 +192,7 @@
size="icon"
variant="ghost"
class="hover:text-destructive"
title="Delete this version"
onclick={() => removeEntry(id)}>
onclick={() => clearHistory(id)}>
<TrashAltIcon />
</Button>
{/if}
@@ -203,7 +202,11 @@
</li>
{/each}
{:else}
<div class="m-2 text-center whitespace-pre-line">{emptyMessage}</div>
<div class="m-2 text-center">
No items in History<br />
Click the Save button to save current state and restore it later.<br />
Timeline will automatically be saved every minute.
</div>
{/if}
</ul>
</Card>
+127
View File
@@ -0,0 +1,127 @@
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
import type { HistoryEntry } from '$lib/types';
import { describe, it, expect } from 'vitest';
import {
addHistoryEntry,
injectHistoryIDs,
clearHistoryData,
historyModeStore,
historyStore
} from './history';
import { defaultState } from '$lib/util/state';
import { get } from 'svelte/store';
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[];
let 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);
});
});
+152
View File
@@ -0,0 +1,152 @@
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
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return entry.type && entry.state && entry.time && true;
};
@@ -1,177 +0,0 @@
import type { HistoryEntry, HistoryType, Optional, State } from '$lib/types';
import { persisted, readJSON, type Persisted } from '$lib/util/persist.svelte';
import { inputState } from '$lib/util/state.svelte';
import { logEvent } from '$lib/util/stats';
import { generateSlug } from 'random-word-slugs';
import { v4 as uuidV4 } from 'uuid';
const MAX_AUTO_HISTORY_LENGTH = 30;
const AUTO_SAVE_INTERVAL = 60_000;
const auto = persisted<HistoryEntry[]>('autoHistoryStore', []);
const manual = persisted<HistoryEntry[]>('manualHistoryStore', []);
const mode = persisted<HistoryType>('autoHistoryMode', 'manual');
let loader = $state<HistoryEntry[]>([]);
// Loader entries are in-memory, so a persisted 'loader' mode is empty after reload.
if (mode.value === 'loader') {
mode.value = 'manual';
}
// The persisted slot backing a mode; loader is in-memory and has no slot.
const slotFor = (m: HistoryType): Persisted<HistoryEntry[]> | null => {
switch (m) {
case 'auto': {
return auto;
}
case 'manual': {
return manual;
}
default: {
return null;
}
}
};
export const historyState = {
get entries(): HistoryEntry[] {
return slotFor(mode.value)?.value ?? loader;
},
get loaderEntries(): HistoryEntry[] {
return loader;
},
get mode(): HistoryType {
return mode.value;
}
};
export const setMode = (next: HistoryType): void => {
mode.value = next;
};
// Dedup key: only the fields that define the diagram, so volatile/view-only
// fields (renderCount, pan/zoom, …) don't count as a change.
export const stateKey = (state: State): string =>
JSON.stringify({ code: state.code, mermaid: state.mermaid });
const createEntry = (state: State, type: 'auto' | 'manual'): HistoryEntry => ({
id: uuidV4(),
name: generateSlug(2),
state,
time: Date.now(),
type
});
// Returns true if added, false if it duplicated the most recent entry.
const addEntry = (
slot: Persisted<HistoryEntry[]>,
state: State,
type: 'auto' | 'manual',
maxLength?: number
): boolean => {
const entries = slot.value;
if (entries.length > 0 && stateKey(entries[0].state) === stateKey(state)) {
return false;
}
const trimmed =
maxLength && entries.length >= maxLength ? entries.slice(0, maxLength - 1) : entries;
slot.value = [createEntry(state, type), ...trimmed];
logEvent('history', { action: 'save', type });
return true;
};
export const addManualEntry = (state: State): boolean => addEntry(manual, state, 'manual');
export const addAutoEntry = (state: State): boolean =>
addEntry(auto, state, 'auto', MAX_AUTO_HISTORY_LENGTH);
// Replaces the in-memory revisions (e.g. when a gist is loaded), assigning ids.
export const setLoaderEntries = (entries: Optional<HistoryEntry, 'id'>[]): void => {
loader = entries.map((entry) =>
entry.id ? (entry as HistoryEntry) : { ...entry, id: uuidV4() }
);
};
export const removeEntry = (id: string): void => {
const slot = slotFor(mode.value);
if (!slot) {
return;
}
slot.value = slot.value.filter((entry) => entry.id !== id);
logEvent('history', { action: 'clear', type: 'single' });
};
export const clearActive = (): void => {
const slot = slotFor(mode.value);
if (!slot) {
return;
}
slot.value = [];
logEvent('history', { action: 'clear', type: 'all' });
};
const validateEntry = (entry: HistoryEntry): boolean =>
Boolean(entry && entry.type && entry.state) && typeof entry.time === 'number';
export interface RestoreResult {
restored: number;
invalid: number;
duplicates: number;
}
// Routes each uploaded entry to the store matching its own type, skipping ids
// that already exist.
export const restoreEntries = (data: HistoryEntry[]): RestoreResult => {
const valid = data.filter((entry) => validateEntry(entry));
const invalid = data.length - valid.length;
let restored = 0;
const slots: [HistoryType, Persisted<HistoryEntry[]>][] = [
['auto', auto],
['manual', manual]
];
for (const [type, slot] of slots) {
const incoming = valid.filter((entry) => entry.type === type);
if (incoming.length === 0) {
continue;
}
const existingIDs = slot.value.map(({ id }) => id);
const fresh = incoming.filter(({ id }) => !existingIDs.includes(id));
restored += fresh.length;
slot.value = [...slot.value, ...fresh].sort((a, b) => b.time - a.time);
}
const duplicates = valid.length - restored;
logEvent('history', { action: 'restore', duplicates, invalid, success: restored });
return { restored, invalid, duplicates };
};
const setIDs = (entries: HistoryEntry[]): HistoryEntry[] =>
entries.map((entry) => (entry.id ? entry : { ...entry, id: uuidV4() }));
// One-time migration: re-reads localStorage so entries written by an older
// version get ids, then persists and updates the reactive state.
export const injectHistoryIDs = (): void => {
auto.value = setIDs(readJSON<HistoryEntry[]>('autoHistoryStore', []));
manual.value = setIDs(readJSON<HistoryEntry[]>('manualHistoryStore', []));
};
let autoSaveTimer: ReturnType<typeof setInterval> | undefined;
// Idempotent; returns the stop function for use as a lifecycle cleanup.
export const startAutoSave = (): (() => void) => {
if (autoSaveTimer === undefined) {
autoSaveTimer = setInterval(
() => addAutoEntry($state.snapshot(inputState)),
AUTO_SAVE_INTERVAL
);
}
return stopAutoSave;
};
export const stopAutoSave = (): void => {
if (autoSaveTimer !== undefined) {
clearInterval(autoSaveTimer);
autoSaveTimer = undefined;
}
};
@@ -1,314 +0,0 @@
import type { HistoryEntry } from '$lib/types';
import { defaultState, replaceInputState } from '$lib/util/state.svelte';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
addAutoEntry,
addManualEntry,
clearActive,
historyState,
injectHistoryIDs,
removeEntry,
restoreEntries,
setLoaderEntries,
setMode,
startAutoSave,
stateKey,
stopAutoSave
} from './historyState.svelte';
const codeState = (code: string) => ({ ...defaultState, code });
/** Read the entries currently shown for a given mode. */
const entriesFor = (mode: 'auto' | 'manual' | 'loader'): HistoryEntry[] => {
setMode(mode);
return historyState.entries;
};
beforeEach(() => {
// Reset every store through the public API so tests don't leak into each other.
setMode('manual');
clearActive();
setMode('auto');
clearActive();
setLoaderEntries([]);
setMode('manual');
});
describe('stateKey', () => {
it('ignores volatile and view-only fields, keying only on code + config', () => {
const a = {
...defaultState,
code: 'graph TD\n A-->B',
panZoom: true,
renderCount: 1,
updateDiagram: true
};
const b = {
...defaultState,
code: 'graph TD\n A-->B',
pan: { x: 5, y: 5 },
panZoom: false,
renderCount: 99,
updateDiagram: false
};
expect(stateKey(a)).toBe(stateKey(b));
});
it('differs when code differs', () => {
expect(stateKey(codeState('graph TD\n A-->B'))).not.toBe(
stateKey(codeState('graph TD\n A-->C'))
);
});
it('differs when config differs', () => {
const a = { ...defaultState, mermaid: '{"theme":"dark"}' };
const b = { ...defaultState, mermaid: '{"theme":"forest"}' };
expect(stateKey(a)).not.toBe(stateKey(b));
});
});
describe('addManualEntry', () => {
it('adds to the manual store only, never the auto store', () => {
expect(addManualEntry(codeState('graph TD\n A-->B'))).toBe(true);
expect(entriesFor('manual')).toHaveLength(1);
expect(entriesFor('auto')).toHaveLength(0);
});
it('returns false and does not add a duplicate of the latest entry', () => {
const state = codeState('graph TD\n A-->B');
expect(addManualEntry(state)).toBe(true);
expect(addManualEntry(state)).toBe(false);
expect(entriesFor('manual')).toHaveLength(1);
});
it('treats states differing only in volatile/view fields as duplicates', () => {
expect(addManualEntry({ ...defaultState, code: 'graph TD\n A-->B', renderCount: 1 })).toBe(
true
);
expect(
addManualEntry({
...defaultState,
code: 'graph TD\n A-->B',
panZoom: false,
renderCount: 2,
updateDiagram: true
})
).toBe(false);
expect(entriesFor('manual')).toHaveLength(1);
});
it('adds a new entry when the code changes', () => {
expect(addManualEntry(codeState('graph TD\n A-->B'))).toBe(true);
expect(addManualEntry(codeState('graph TD\n A-->C'))).toBe(true);
expect(entriesFor('manual')).toHaveLength(2);
});
it('generates an id and a name for each entry', () => {
addManualEntry(codeState('graph TD\n A-->B'));
const [entry] = entriesFor('manual');
expect(entry.id).toBeTruthy();
expect(entry.name).toBeTruthy();
expect(entry.type).toBe('manual');
});
});
describe('addAutoEntry', () => {
it('adds to the auto store only, never the manual store', () => {
expect(addAutoEntry(codeState('graph TD\n A-->B'))).toBe(true);
expect(entriesFor('auto')).toHaveLength(1);
expect(entriesFor('manual')).toHaveLength(0);
});
it('returns false and does not add a duplicate of the latest entry', () => {
const state = codeState('graph TD\n A-->B');
expect(addAutoEntry(state)).toBe(true);
expect(addAutoEntry(state)).toBe(false);
expect(entriesFor('auto')).toHaveLength(1);
});
it('caps the auto store at 30 entries, dropping the oldest', () => {
for (let i = 0; i < 35; i++) {
addAutoEntry(codeState(`graph TD\n A-->B${i}`));
}
const entries = entriesFor('auto');
expect(entries).toHaveLength(30);
expect(entries[0].state.code).toBe('graph TD\n A-->B34');
});
});
describe('historyState.entries', () => {
it('reflects the active mode', () => {
addManualEntry(codeState('manual-code'));
addAutoEntry(codeState('auto-code'));
setMode('manual');
expect(historyState.entries).toHaveLength(1);
expect(historyState.entries[0].state.code).toBe('manual-code');
setMode('auto');
expect(historyState.entries).toHaveLength(1);
expect(historyState.entries[0].state.code).toBe('auto-code');
setMode('loader');
expect(historyState.entries).toHaveLength(0);
});
});
describe('removeEntry / clearActive', () => {
it('removes a single entry from the active store by id', () => {
addManualEntry(codeState('graph TD\n A-->B'));
addManualEntry(codeState('graph TD\n A-->C'));
setMode('manual');
const target = historyState.entries[1].id;
removeEntry(target);
expect(historyState.entries).toHaveLength(1);
expect(historyState.entries.some((e) => e.id === target)).toBe(false);
});
it('clears all entries in the active store only', () => {
addManualEntry(codeState('graph TD\n A-->B'));
addAutoEntry(codeState('graph TD\n A-->C'));
setMode('manual');
clearActive();
expect(entriesFor('manual')).toHaveLength(0);
expect(entriesFor('auto')).toHaveLength(1);
});
it('does nothing in loader mode', () => {
setLoaderEntries([
{ name: 'rev', state: defaultState, time: 1, type: 'loader', url: 'http://x' }
]);
setMode('loader');
clearActive();
expect(historyState.entries).toHaveLength(1);
});
});
describe('setLoaderEntries', () => {
it('replaces the in-memory revisions and assigns ids', () => {
setLoaderEntries([
{ name: 'v1', state: defaultState, time: 1, type: 'loader', url: 'http://x/1' },
{ name: 'v2', state: defaultState, time: 2, type: 'loader', url: 'http://x/2' }
]);
setMode('loader');
expect(historyState.entries).toHaveLength(2);
expect(historyState.entries.every((e) => e.id)).toBe(true);
setLoaderEntries([
{ name: 'only', state: defaultState, time: 3, type: 'loader', url: 'http://x/3' }
]);
expect(historyState.entries).toHaveLength(1);
expect(historyState.entries[0].name).toBe('only');
});
});
describe('restoreEntries', () => {
it('routes each entry to the store matching its own type', () => {
const result = restoreEntries([
{ id: 'a1', name: 'a', state: defaultState, time: 10, type: 'auto' },
{ id: 'm1', name: 'm', state: defaultState, time: 20, type: 'manual' }
]);
expect(result.restored).toBe(2);
expect(entriesFor('auto').map((e) => e.id)).toEqual(['a1']);
expect(entriesFor('manual').map((e) => e.id)).toEqual(['m1']);
});
it('skips duplicates by id and reports them', () => {
restoreEntries([{ id: 'm1', name: 'm', state: defaultState, time: 20, type: 'manual' }]);
const result = restoreEntries([
{ id: 'm1', name: 'm', state: defaultState, time: 20, type: 'manual' },
{ id: 'm2', name: 'm2', state: defaultState, time: 30, type: 'manual' }
]);
expect(result.restored).toBe(1);
expect(result.duplicates).toBe(1);
expect(entriesFor('manual')).toHaveLength(2);
});
it('reports invalid entries and does not restore them', () => {
const result = restoreEntries([
{ id: 'm1', name: 'm', state: defaultState, time: 20, type: 'manual' },
{ foo: 'bar' } as unknown as HistoryEntry
]);
expect(result.restored).toBe(1);
expect(result.invalid).toBe(1);
});
it('sorts restored entries newest first', () => {
restoreEntries([
{ id: 'm1', name: 'old', state: defaultState, time: 10, type: 'manual' },
{ id: 'm2', name: 'new', state: defaultState, time: 30, type: 'manual' },
{ id: 'm3', name: 'mid', state: defaultState, time: 20, type: 'manual' }
]);
expect(entriesFor('manual').map((e) => e.time)).toEqual([30, 20, 10]);
});
it('restores entries whose time is 0 (epoch) instead of treating them as invalid', () => {
const result = restoreEntries([
{ id: 'm0', name: 'epoch', state: defaultState, time: 0, type: 'manual' }
]);
expect(result.restored).toBe(1);
expect(result.invalid).toBe(0);
expect(entriesFor('manual')).toHaveLength(1);
});
});
describe('injectHistoryIDs migration', () => {
it('adds ids to persisted entries that lack them', () => {
window.localStorage.setItem(
'manualHistoryStore',
'[{"state":{"code":"a"},"time":1,"type":"manual","name":"x"}]'
);
window.localStorage.setItem(
'autoHistoryStore',
'[{"state":{"code":"b"},"time":2,"type":"auto","name":"y"}]'
);
injectHistoryIDs();
const manual = JSON.parse(
window.localStorage.getItem('manualHistoryStore') ?? '[]'
) as HistoryEntry[];
const auto = JSON.parse(
window.localStorage.getItem('autoHistoryStore') ?? '[]'
) as HistoryEntry[];
expect(manual).toHaveLength(1);
expect(auto).toHaveLength(1);
expect(manual.every(({ id }) => id !== undefined)).toBe(true);
expect(auto.every(({ id }) => id !== undefined)).toBe(true);
});
});
describe('auto-save lifecycle', () => {
afterEach(() => {
stopAutoSave();
vi.useRealTimers();
});
it('records an auto entry on each interval from the current editor state', () => {
vi.useFakeTimers();
replaceInputState(codeState('graph TD\n auto-saved'));
startAutoSave();
vi.advanceTimersByTime(60_000);
const entries = entriesFor('auto');
expect(entries).toHaveLength(1);
expect(entries[0].state.code).toBe('graph TD\n auto-saved');
});
it('is idempotent: calling startAutoSave twice does not double-record', () => {
vi.useFakeTimers();
replaceInputState(codeState('graph TD\n once'));
startAutoSave();
startAutoSave();
vi.advanceTimersByTime(60_000);
expect(entriesFor('auto')).toHaveLength(1);
});
it('stops recording after stopAutoSave', () => {
vi.useFakeTimers();
replaceInputState(codeState('graph TD\n stoppable'));
startAutoSave();
vi.advanceTimersByTime(60_000);
stopAutoSave();
replaceInputState(codeState('graph TD\n after-stop'));
vi.advanceTimersByTime(60_000);
expect(entriesFor('auto')).toHaveLength(1);
});
});
+39 -95
View File
@@ -1,102 +1,54 @@
<script lang="ts">
import McWrapper from '$/components/McWrapper.svelte';
import ThemeIcon from '$/components/ThemeIcon.svelte';
import * as Popover from '$/components/ui/popover';
import { Switch } from '$/components/ui/switch';
import { env } from '$/util/env';
import { urls } from '$/util/state.svelte';
import { logMermaidChartClick } from '$/util/stats';
import { urlsStore } from '$/util/state';
import { cn } from '$/utils';
import { mode, setMode } from 'mode-watcher';
import type { Component, Snippet } from 'svelte';
import MermaidTailIcon from '~icons/custom/mermaid-tail';
import type { Component } from 'svelte';
import AddIcon from '~icons/material-symbols/add-2-rounded';
import BookIcon from '~icons/material-symbols/book-2-outline-rounded';
import DuplicateIcon from '~icons/material-symbols/content-copy-outline-rounded';
import ContrastIcon from '~icons/material-symbols/contrast';
import PluginIcon from '~icons/material-symbols/electrical-services-rounded';
import MenuIcon from '~icons/material-symbols/menu-rounded';
import PluginIcon from '~icons/material-symbols/extension-outline';
import HomeIcon from '~icons/material-symbols/house-outline-rounded';
import CommunityIcon from '~icons/material-symbols/person-play-outline-rounded';
import PlaygroundIcon from '~icons/material-symbols/shape-line-outline';
import MermaidChartIcon from './MermaidChartIcon.svelte';
interface MenuItem {
label: string;
icon: Component;
href: string;
class?: string;
onclick?: () => void;
sharesData?: boolean;
checkDiagramType?: boolean;
isSectionEnd?: boolean;
renderer: Snippet<[Omit<MenuItem, 'renderer'>]>;
}
const menuItems = $derived([
{ label: 'New Diagram', icon: AddIcon, href: $urlsStore.new },
{ label: 'Home', icon: HomeIcon, href: 'https://mermaid.js.org/' },
{ label: 'Documentation', icon: BookIcon, href: 'https://mermaid.js.org/intro/' },
{ label: 'Community', icon: CommunityIcon, href: 'https://discord.gg/sKeNQX4Wtj' }
]);
const menuItems: MenuItem[] = $derived([
{ label: 'New', icon: AddIcon, href: urls.current.new, renderer: menuItem },
{ label: 'Duplicate', icon: DuplicateIcon, href: window.location.href, renderer: menuItem },
const mermaidChartMenuItems = $derived([
{
href: urls.current.mermaidChart({ medium: 'main_menu' }).playground,
icon: PlaygroundIcon,
isSectionEnd: true,
label: 'Edit in Playground',
onclick: () => logMermaidChartClick('editInPlayground'),
renderer: mcMenuItem
icon: PlaygroundIcon,
href: $urlsStore.mermaidChart({ medium: 'main_menu' }).playground
},
{
label: 'Mermaid.js',
icon: MermaidTailIcon,
href: env.docsUrl,
renderer: menuItem
},
{
label: 'Documentation',
icon: BookIcon,
href: `${env.docsUrl}/intro/`,
renderer: menuItem
},
{
label: 'Community',
icon: CommunityIcon,
href: 'https://discord.gg/sKeNQX4Wtj',
renderer: menuItem
},
{
checkDiagramType: false,
href: urls.current.mermaidChart({ medium: 'main_menu' }).plugins,
icon: PluginIcon,
label: 'Plugins',
onclick: () => logMermaidChartClick('plugins'),
renderer: mcMenuItem,
sharesData: false
icon: PluginIcon,
href: $urlsStore.mermaidChart({ medium: 'main_menu' }).plugins,
checkDiagramType: false
},
{
href: '#',
icon: ContrastIcon,
isSectionEnd: true,
label: 'Dark Mode',
renderer: darkModeMenuItem
},
{
checkDiagramType: false,
class: 'text-accent border-b-0',
href: urls.current.mermaidChart({ medium: 'main_menu' }).home,
label: 'MermaidChart',
icon: MermaidChartIcon,
label: 'Mermaid',
onclick: () => logMermaidChartClick('mermaidHome'),
renderer: mcMenuItem,
sharesData: false
href: $urlsStore.mermaidChart({ medium: 'main_menu' }).home,
checkDiagramType: false
}
]);
</script>
{#snippet menuItem(options: Omit<MenuItem, 'renderer'>)}
{#snippet menuItem(options: { label: string; icon: Component; href: string; class?: string })}
<a
href={options.href}
target="_blank"
onclick={options.onclick}
class={cn(
'flex items-center justify-start gap-2 border-b-2 p-2 px-3 hover:bg-muted',
options.isSectionEnd && 'border-border-dark',
'flex items-center justify-start gap-2 border-b bg-muted p-2 px-3 hover:bg-background',
options.class
)}>
<options.icon class="size-5" />
@@ -104,40 +56,32 @@
</a>
{/snippet}
{#snippet mcMenuItem(item: Omit<MenuItem, 'renderer'>)}
<McWrapper
side="right"
labelPrefix={item.sharesData === false ? 'Opens a new tab in' : undefined}
sharesData={item.sharesData}
shouldCheckDiagramType={item.checkDiagramType}>
<Popover.Root>
<Popover.Trigger class="shrink-0">
<img class="size-6" src="/favicon.svg" alt="Mermaid Live Editor" />
</Popover.Trigger>
<Popover.Content align="start" class="flex flex-col overflow-hidden p-0" sideOffset={16}>
{#each menuItems as item}
{@render menuItem(item)}
</McWrapper>
{/snippet}
{/each}
{#snippet darkModeMenuItem(options: Omit<MenuItem, 'renderer'>)}
<div
class={cn(
'flex cursor-pointer items-center justify-between border-b-2 px-3 py-2 hover:bg-muted',
options.isSectionEnd && 'border-border-dark',
options.class
)}>
<div class="flex items-center justify-between border-b bg-muted px-3 py-2 hover:bg-background">
<span class="flex items-center gap-2">
<ContrastIcon />
<ThemeIcon />
Dark Mode
</span>
<Switch
checked={mode.current === 'dark'}
checked={$mode === 'dark'}
onCheckedChange={(dark) => setMode(dark ? 'dark' : 'light')} />
</div>
{/snippet}
<Popover.Root>
<Popover.Trigger class="shrink-0">
<MenuIcon class="size-6" />
</Popover.Trigger>
<Popover.Content align="start" class="flex flex-col overflow-hidden border-2 p-0" sideOffset={16}>
{#each menuItems as { renderer, ...item } (item.label)}
{@render renderer(item)}
{#each mermaidChartMenuItems as item}
<McWrapper side="right" shouldCheckDiagramType={item.checkDiagramType}>
{@render menuItem({
...item,
class: 'text-accent bg-background hover:bg-muted'
})}
</McWrapper>
{/each}
</Popover.Content>
</Popover.Root>
+41 -15
View File
@@ -1,24 +1,50 @@
<script lang="ts">
import { env } from '$/util/env';
import { isOnMermaidAI } from '$/util/migration/domainMigration';
import type { ComponentProps } from 'svelte';
import ExternalLinkWrapper from './ExternalLinkWrapper.svelte';
import { stateStore } from '$/util/state';
import * as Tooltip from '$lib/components/ui/tooltip';
import type { ComponentProps, Snippet } from 'svelte';
import ExternalLinkIcon from '~icons/material-symbols/open-in-new-rounded';
let {
children,
...props
}: Omit<
ComponentProps<typeof ExternalLinkWrapper>,
'isVisible' | 'domain' | 'showPopup'
> = $props();
shouldCheckDiagramType = true,
side = 'bottom',
labelPrefix = 'Opens a new tab in'
}: {
children: Snippet;
shouldCheckDiagramType?: boolean;
side?: ComponentProps<typeof Tooltip.Content>['side'];
labelPrefix?: string;
} = $props();
const mermaidChartDomain = 'mermaid.ai';
let shouldDisableComponent = $derived(
shouldCheckDiagramType && $stateStore.diagramType === 'zenuml'
);
</script>
<ExternalLinkWrapper
{...props}
domain={mermaidChartDomain}
isVisible={env.isEnabledMermaidChartLinks}
showPopup={!isOnMermaidAI()}>
{#if env.isEnabledMermaidChartLinks}
<Tooltip.Provider>
<Tooltip.Root delayDuration={100}>
<Tooltip.Trigger>
<div class={[shouldDisableComponent && 'pointer-events-none cursor-not-allowed grayscale']}>
{@render children()}
</ExternalLinkWrapper>
</div>
</Tooltip.Trigger>
<Tooltip.Content {side}>
<div class="flex items-center gap-2">
{#if shouldDisableComponent}
<div class="text-muted-foreground">
This diagram type is not supported in MermaidChart.com
</div>
{:else}
<ExternalLinkIcon />
<span class="flex items-center gap-1">
{labelPrefix}
<div class="text-accent">MermaidChart.com</div>
</span>
{/if}
</div>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
{/if}
+1 -2
View File
@@ -1,8 +1,7 @@
<script lang="ts">
import { asset } from '$app/paths';
import type { ClassValue } from 'svelte/elements';
let { class: className }: { class?: ClassValue } = $props();
</script>
<img class={['size-4', className]} src={asset('/mermaidchart-logo.svg')} alt="Mermaid Chart" />
<img class={['size-4', className]} src="/mermaidchart-logo.svg" alt="Mermaid Chart" />
+17 -18
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import type { EditorProps } from '$/types';
import { validatedState } from '$/util/state.svelte';
import { stateStore } from '$/util/state';
import { json, jsonLanguage } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
import { yamlFrontmatter } from '@codemirror/lang-yaml';
@@ -15,22 +15,14 @@
let editorView: EditorView | undefined;
let editorContainer: HTMLDivElement;
// Deliberately not $state: the sync effect below both reads and writes it,
// so a reactive currentText would make every keystroke re-run the effect
// against the not-yet-revalidated state and revert the user's input.
let currentText = '';
const themeCompartment = new Compartment();
const languageCompartment = new Compartment();
let currentText = $state('');
const { onUpdate }: EditorProps = $props();
$effect(() => {
editorView?.dispatch({
effects: themeCompartment.reconfigure(mode.current === 'dark' ? vsCodeDark : vsCodeLight)
});
});
onMount(() => {
const themeCompartment = new Compartment();
const languageCompartment = new Compartment();
editorView = new EditorView({
state: EditorState.create({
doc: currentText,
@@ -64,13 +56,13 @@
parent: editorContainer
});
return () => {
editorView?.destroy();
};
const unsubscribeMode = mode.subscribe((mode) => {
editorView?.dispatch({
effects: themeCompartment.reconfigure(mode === 'dark' ? vsCodeDark : vsCodeLight)
});
});
$effect(() => {
const { editorMode, code, mermaid } = validatedState.current;
const unsubscribeState = stateStore.subscribe(({ editorMode, code, mermaid }) => {
const text = editorMode === 'code' ? code : mermaid;
if (currentText === text || !editorView) {
return;
@@ -95,6 +87,13 @@
)
});
});
return () => {
unsubscribeMode();
unsubscribeState();
editorView?.destroy();
};
});
</script>
<div bind:this={editorContainer} class="size-full"></div>
+50 -15
View File
@@ -1,5 +1,5 @@
<script lang="ts" module>
import { logEvent, logMermaidChartClick } from '$lib/util/stats';
import { logEvent, plausible } from '$lib/util/stats';
import { version } from 'mermaid/package.json';
void logEvent('version', {
@@ -8,13 +8,15 @@
</script>
<script lang="ts">
import { resolve } from '$app/paths';
import MainMenu from '$/components/MainMenu.svelte';
import McWrapper from '$/components/McWrapper.svelte';
import { Button } from '$/components/ui/button';
import { Separator } from '$/components/ui/separator';
import { dismissPromotion, getActivePromotion } from '$lib/util/promos/promo.svelte';
import { untrack, type ComponentProps, type Snippet } from 'svelte';
import MermaidIcon from '~icons/custom/mermaid';
import { Switch } from '$/components/ui/switch';
import { dismissPromotion, getActivePromotion } from '$lib/util/promos/promo';
import { urlsStore } from '$lib/util/state';
import { MCBaseURL } from '$lib/util/util';
import type { ComponentProps, Snippet } from 'svelte';
import CloseIcon from '~icons/material-symbols/close-rounded';
import GithubIcon from '~icons/mdi/github';
import DropdownNavMenu from './DropdownNavMenu.svelte';
@@ -22,10 +24,11 @@
interface Props {
mobileToggle?: Snippet;
children: Snippet;
hidePromotion?: boolean;
}
let { children, mobileToggle, hidePromotion = false }: Props = $props();
let { children, mobileToggle }: Props = $props();
const isReferral = document.referrer.includes(MCBaseURL);
type Links = ComponentProps<typeof DropdownNavMenu>['links'];
@@ -41,23 +44,22 @@
}
];
let activePromotion = $state(untrack(() => (hidePromotion ? undefined : getActivePromotion())));
let activePromotion = $state(getActivePromotion());
const trackBannerClick = () => {
if (!activePromotion) {
if (!plausible || !activePromotion) {
return;
}
logEvent('bannerClick', {
promotion: activePromotion.id
});
logMermaidChartClick('banner');
};
</script>
{#if activePromotion}
<div class="top-bar z-10 flex h-fit w-full bg-primary">
<div
class="flex grow"
class="flex flex-grow"
role="button"
tabindex="0"
onclick={trackBannerClick}
@@ -81,15 +83,48 @@
{/if}
<nav class="z-50 flex p-4 sm:p-6">
<div class="flex flex-1 items-center gap-2">
<div class="flex flex-1 items-center gap-4">
<MainMenu />
<MermaidIcon class="size-6" />
<a href={resolve('/', {})} class="whitespace-nowrap text-accent">
{#if !mobileToggle}
<div
id="switcher"
class="flex items-center justify-center gap-4 font-medium"
class:flex-row-reverse={isReferral}>
<a href="/" class="whitespace-nowrap text-accent">
{#if !isReferral && !mobileToggle}
Mermaid
{/if}
Live Editor
</a>
<McWrapper labelPrefix="Opens the current diagram in">
<div class="hidden items-center justify-center gap-4 md:flex">
<Separator orientation="vertical" />
<Switch
id="editorMode"
class="data-[state=checked]:bg-secondary"
checked={isReferral}
onclick={() => {
logEvent('playgroundToggle', { isReferred: isReferral });
// Wait for the event to be logged
setTimeout(() => {
window.open(
$urlsStore.mermaidChart({ medium: 'toggle' }).playground,
'_self',
// Do not send referrer header, if the user already came from playground
isReferral ? 'noreferrer' : ''
);
}, 100);
}} />
<a
href={$urlsStore.mermaidChart({ medium: 'toggle' }).playground}
class="whitespace-nowrap">
Playground <span class="hidden text-sm opacity-50 lg:inline"
>- more features, no account required</span>
</a>
</div>
</McWrapper>
</div>
</div>
<div
id="menu"
+2 -2
View File
@@ -3,7 +3,7 @@
import { Button } from '$/components/ui/button';
import { Separator } from '$/components/ui/separator';
import type { PanZoomState } from '$/util/panZoom';
import { urls } from '$/util/state.svelte';
import { urlsStore } from '$/util/state';
import ExpandIcon from '~icons/material-symbols/open-in-full-rounded';
import ArrowsToCircleIcon from '~icons/material-symbols/screenshot-frame-2';
import MagnifyingGlassPlusIcon from '~icons/material-symbols/zoom-in';
@@ -28,7 +28,7 @@
<MagnifyingGlassPlusIcon />
</Button>
<Separator orientation="vertical" class="hidden sm:block" />
<Button variant="ghost" size="icon" title="Full Screen" href={urls.current.view} target="_blank">
<Button variant="ghost" size="icon" title="Full Screen" href={$urlsStore.view} target="_blank">
<ExpandIcon />
</Button>
</FloatingToolbar>
+185 -61
View File
@@ -1,20 +1,172 @@
<script lang="ts">
import Card from '$/components/Card/Card.svelte';
import { Button, buttonVariants } from '$/components/ui/button';
import * as Popover from '$/components/ui/popover';
import { getSampleDiagrams, type SampleExample } from '$/util/mermaid';
import { updateCode } from '$lib/util/state.svelte';
import { Button } from '$/components/ui/button';
import { updateCode } from '$lib/util/state';
import { logEvent } from '$lib/util/stats';
import { cn } from '$lib/utils';
import ShapesIcon from '~icons/material-symbols/account-tree-outline-rounded';
import ChevronDownIcon from '~icons/material-symbols/keyboard-arrow-down-rounded';
const extras: Record<string, SampleExample[]> = {
ZenUML: [
{
title: 'Order Service',
isDefault: true,
code: `zenuml
const samples = {
Block: `block-beta
columns 3
doc>"Document"]:3
space down1<[" "]>(down) space
block:e:3
l["left"]
m("A wide one in the middle")
r["right"]
end
space down2<[" "]>(down) space
db[("DB")]:3
space:3
D space C
db --> D
C --> db
D --> C
style m fill:#d6d,stroke:#333,stroke-width:4px
`,
Class: `classDiagram
Animal <|-- Duck
Animal <|-- Fish
Animal <|-- Zebra
Animal : +int age
Animal : +String gender
Animal: +isMammal()
Animal: +mate()
class Duck{
+String beakColor
+swim()
+quack()
}
class Fish{
-int sizeInFeet
-canEat()
}
class Zebra{
+bool is_wild
+run()
}`,
ER: `erDiagram
CUSTOMER }|..|{ DELIVERY-ADDRESS : has
CUSTOMER ||--o{ ORDER : places
CUSTOMER ||--o{ INVOICE : "liable for"
DELIVERY-ADDRESS ||--o{ ORDER : receives
INVOICE ||--|{ ORDER : covers
ORDER ||--|{ ORDER-ITEM : includes
PRODUCT-CATEGORY ||--|{ PRODUCT : contains
PRODUCT ||--o{ ORDER-ITEM : "ordered in"`,
Flow: `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]`,
Gantt: `gantt
title A Gantt Diagram
dateFormat YYYY-MM-DD
section Section
A task :a1, 2014-01-01, 30d
Another task :after a1 , 20d
section Another
Task in sec :2014-01-12 , 12d
another task : 24d`,
Git: `gitGraph
commit
commit
branch develop
checkout develop
commit
commit
checkout main
merge develop
commit
commit`,
Mindmap: `mindmap
root((mindmap))
Origins
Long history
::icon(fa fa-book)
Popularisation
British popular psychology author Tony Buzan
Research
On effectiveness<br/>and features
On Automatic creation
Uses
Creative techniques
Strategic planning
Argument mapping
Tools
Pen and paper
Mermaid`,
Packet: `---
title: "TCP Packet"
---
packet-beta
0-15: "Source Port"
16-31: "Destination Port"
32-63: "Sequence Number"
64-95: "Acknowledgment Number"
96-99: "Data Offset"
100-105: "Reserved"
106: "URG"
107: "ACK"
108: "PSH"
109: "RST"
110: "SYN"
111: "FIN"
112-127: "Window"
128-143: "Checksum"
144-159: "Urgent Pointer"
160-191: "(Options and Padding)"
192-255: "Data (variable length)"
`,
Pie: `pie title Pets adopted by volunteers
"Dogs" : 386
"Cats" : 85
"Rats" : 15`,
QuadrantChart: `quadrantChart
title Reach and engagement of campaigns
x-axis Low Reach --> High Reach
y-axis Low Engagement --> High Engagement
quadrant-1 We should expand
quadrant-2 Need to promote
quadrant-3 Re-evaluate
quadrant-4 May be improved
Campaign A: [0.3, 0.6]
Campaign B: [0.45, 0.23]
Campaign C: [0.57, 0.69]
Campaign D: [0.78, 0.34]
Campaign E: [0.40, 0.34]
Campaign F: [0.35, 0.78]`,
Sequence: `sequenceDiagram
Alice->>+John: Hello John, how are you?
Alice->>+John: John, can you hear me?
John-->>-Alice: Hi Alice, I can hear you!
John-->>-Alice: I feel great!`,
State: `stateDiagram-v2
[*] --> Still
Still --> [*]
Still --> Moving
Moving --> Still
Moving --> Crash
Crash --> [*]`,
'User Journey': `journey
title My working day
section Go to work
Make tea: 5: Me
Go upstairs: 3: Me
Do work: 1: Me, Cat
section Go home
Go downstairs: 5: Me
Sit down: 3: Me`,
XYChart: `
xychart-beta
title "Sales Revenue"
x-axis [jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec]
y-axis "Revenue (in $)" 4000 --> 11000
bar [5000, 6000, 7500, 8200, 9500, 10500, 11000, 10200, 9200, 8500, 7000, 6000]
line [5000, 6000, 7500, 8200, 9500, 10500, 11000, 10200, 9200, 8500, 7000, 6000]`,
ZenUML: `zenuml
title Order Service
@Actor Client #FFEBE6
@Boundary OrderController #0747A6
@@ -38,73 +190,45 @@
}
}
`
}
]
};
const samples = { ...getSampleDiagrams(), ...extras };
const loadSampleDiagram = (diagramType: string, example: SampleExample): void => {
updateCode(example.code, {
type SampleTypes = keyof typeof samples;
const loadSampleDiagram = (diagramType: SampleTypes): void => {
updateCode(samples[diagramType], {
resetPanZoom: true,
updateDiagram: true
});
logEvent('loadSampleDiagram', { diagramType, exampleTitle: example.title });
logEvent('loadSampleDiagram', { diagramType });
};
const mainDiagrams = [
'Flowchart',
'Class',
const diagramOrder: SampleTypes[] = [
'Flow',
'Sequence',
'Entity Relationship',
'Class',
'State',
'Mindmap'
];
const diagramOrder = [
...mainDiagrams,
...Object.keys(samples)
.filter((key) => !mainDiagrams.includes(key))
.sort()
'ER',
'Gantt',
'User Journey',
'Git',
'Pie',
'Mindmap',
'ZenUML',
'QuadrantChart',
'XYChart',
'Block',
'Packet'
];
</script>
<Card title="Sample Diagrams" isOpen isStackable icon={{ component: ShapesIcon }}>
<div class="flex h-fit max-h-52 flex-wrap gap-2 overflow-y-auto p-2">
{#each diagramOrder as sample (sample)}
{@const examples = samples[sample]}
<div class="flex min-w-20 flex-grow">
{#each diagramOrder as sample}
<Button
size="sm"
class={cn('flex-grow normal-case', examples.length > 1 && 'rounded-r-none')}
onclick={() => loadSampleDiagram(sample, examples[0])}>
class="w-fit min-w-20 flex-grow normal-case"
onclick={() => loadSampleDiagram(sample)}>
{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}
</div>
</Card>
+28 -41
View File
@@ -1,21 +1,13 @@
<script lang="ts">
import ExternalLinkWrapper from '$/components/ExternalLinkWrapper.svelte';
<script>
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';
</script>
{#if env.privacyPolicyUrl}
<a href={env.privacyPolicyUrl} target="_blank">
<ShieldIcon />
</a>
{:else}
<Dialog.Root>
<Dialog.Trigger>
<ShieldIcon />
</Dialog.Trigger>
<Dialog.Content class="max-h-full overflow-hidden overflow-y-auto p-12">
<Dialog.Content class="max-h-full overflow-y-auto">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2 text-xl">
<ShieldIcon class="size-8 text-green-700" />
@@ -23,44 +15,39 @@
</Dialog.Title>
</Dialog.Header>
{#if isOnMermaidLive()}
<p class="text-xl font-semibold">Your diagrams never leave your browser.</p>
<p>They're only stored in the URL and your browser's local storage.</p>
<p class="text-xl font-semibold">
The content of the diagrams you create never leaves your browser.
</p>
<p>It's only stored in the URL, and your browser's local storage.</p>
<p>
This is a fully open source, client-side app deployed on <a
Mermaid Live Editor is a fully open source, client side application, deployed transparently on <a
href="https://github.com/mermaid-js/mermaid-live-editor/deployments"
class="underline"
target="_blank">GitHub Pages</a>
that works offline as a
<a href="https://web.dev/explore/progressive-web-apps" target="_blank"
>Progressive Web App</a
>.
target="_blank">
GitHub Pages
</a>.
</p>
<p>
We use self hosted, privacy-friendly Plausible Analytics to collect anonymous usage
metadata (diagram types, feature usage, etc.). All data is <a
href="https://p.mermaid.live/mermaid.live"
class="underline"
target="_blank">publicly available</a
>.
It will also work as a fully offline
<a href="https://web.dev/explore/progressive-web-apps" target="_blank">
Progressive Web App.
</a>
</p>
<ExternalLinkWrapper domain="example.com" isVisible>
<p class="text-left">
External services (PNG/SVG/Kroki exports, "Save to Mermaid Chart", "Repair with AI",
etc) will share your diagram with those 3rd parties, and are highlighted in the UI on
hover.
</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.
The only server we have is a self hosted version of the open source and privacy friendly
Plausible Analytics. We only collect anonymous data related to actions performed, like the
type of diagram rendered, number of times a feature was used, etc.
</p>
<p>
All the data we collect is anonymized and
<a href="https://p.mermaid.live/mermaid.live" class="underline" target="_blank">
available publicly.
</a>
</p>
<p>
Additional services like the external PNG/SVG/Kroki links and "Save to Mermaid Chart" feature
will share your diagram with the respective 3rd party service.
</p>
{/if}
</Dialog.Content>
</Dialog.Root>
{/if}
+4 -5
View File
@@ -1,10 +1,9 @@
<script lang="ts">
<script>
import { buttonVariants } from '$/components/ui/button';
import * as Dialog from '$/components/ui/dialog';
import { Separator } from '$/components/ui/separator';
import { env } from '$/util/env';
import { urls } from '$/util/state.svelte';
import { asset } from '$app/paths';
import { urlsStore } from '$/util/state';
import ShareIcon from '~icons/material-symbols/share';
import CopyInput from './CopyInput.svelte';
import MermaidChartIcon from './MermaidChartIcon.svelte';
@@ -23,7 +22,7 @@
<div class="flex flex-col gap-4">
<div class="flex flex-col gap-2">
<h2 class="flex items-center gap-2">
<img class="size-5" src={asset('/favicon.svg')} alt="Mermaid Live Editor" />
<img class="size-5" src="/favicon.svg" alt="Mermaid Live Editor" />
Mermaid Live Editor
</h2>
<CopyInput value={window.location.href} />
@@ -38,7 +37,7 @@
<MermaidChartIcon class="size-5" />
Mermaid Chart Playground
</h2>
<CopyInput value={urls.current.mermaidChart({ medium: 'share' }).playground} />
<CopyInput value={$urlsStore.mermaidChart({ medium: 'share' }).playground} />
<Dialog.Description>
Opens the Mermaid Chart Playground with Mermaid AI, Visual Editor, and more.
</Dialog.Description>
+5 -11
View File
@@ -1,27 +1,21 @@
<script lang="ts">
import FloatingToolbar from '$/components/FloatingToolbar.svelte';
import { Toggle } from '$/components/ui/toggle';
import { defaultState, inputState, updateCodeStore } from '$/util/state.svelte';
import { defaultState, inputStateStore } from '$/util/state';
import RoughIcon from '~icons/material-symbols/draw-outline-rounded';
import BackgroundIcon from '~icons/material-symbols/grid-4x4-rounded';
if (inputState.grid === undefined) {
if ($inputStateStore.grid === undefined) {
// Handle cases where old states were saved without grid option
updateCodeStore({ grid: defaultState.grid });
$inputStateStore.grid = defaultState.grid;
}
</script>
<FloatingToolbar>
<Toggle
bind:pressed={() => inputState.rough, (rough) => updateCodeStore({ rough })}
size="sm"
title="Hand-Drawn">
<Toggle bind:pressed={$inputStateStore.rough} size="sm" title="Hand-Drawn">
<RoughIcon />
</Toggle>
<Toggle
bind:pressed={() => inputState.grid ?? defaultState.grid, (grid) => updateCodeStore({ grid })}
size="sm"
title="Background Grid">
<Toggle bind:pressed={$inputStateStore.grid} size="sm" title="Background Grid">
<BackgroundIcon />
</Toggle>
</FloatingToolbar>
+2 -2
View File
@@ -24,12 +24,12 @@
</script>
<div class="inline-grid">
{#key mode.current}
{#key $mode}
<div
in:spin={{ clockWise: true }}
out:spin={{ clockWise: false }}
class="col-start-1 row-start-1">
{#if mode.current === 'dark'}
{#if $mode === 'dark'}
<MoonIcon />
{:else}
<SunIcon />
@@ -4,7 +4,6 @@
import { Button } from '$/components/ui/button';
import { Separator } from '$/components/ui/separator';
import { TID } from '$/constants';
import { env } from '$/util/env';
import { version } from 'mermaid/package.json';
import { mode, setMode } from 'mode-watcher';
import ThemeIcon from './ThemeIcon.svelte';
@@ -12,20 +11,18 @@
<FloatingToolbar>
<span class="text-sm font-semibold opacity-60">v{version}</span>
{#if !env.hidePrivacyPolicy}
<Button variant="ghost" size="icon" title="Privacy & Security">
<Privacy />
</Button>
<Separator orientation="vertical" />
{/if}
<Button
variant="ghost"
size="icon"
data-testid={TID.themeToggleButton}
title="Switch to {mode.current === 'dark' ? 'light' : 'dark'} theme"
title="Switch to {$mode === 'dark' ? 'light' : 'dark'} theme"
class="[&_svg]:size-5"
onclick={() => setMode(mode.current === 'dark' ? 'light' : 'dark')}>
onclick={() => setMode($mode === 'dark' ? 'light' : 'dark')}>
<ThemeIcon />
</Button>
</FloatingToolbar>
+7 -14
View File
@@ -3,8 +3,8 @@
import { recordRenderTime, shouldRefreshView } from '$/util/autoSync';
import { render as renderDiagram } from '$/util/mermaid';
import { PanZoomState } from '$/util/panZoom';
import { updateCodeStore, validatedState } from '$/util/state.svelte';
import { saveStatistics } from '$/util/stats';
import { inputStateStore, stateStore, updateCodeStore } from '$/util/state';
import { logEvent, saveStatistics } from '$/util/stats';
import FontAwesome, { mayContainFontAwesome } from '$lib/components/FontAwesome.svelte';
import uniqueID from 'lodash-es/uniqueId';
import type { MermaidConfig } from 'mermaid';
@@ -30,15 +30,12 @@
const setupPanZoomObserver = () => {
panZoomState.onPanZoomChange = (pan, zoom) => {
updateCodeStore({ pan, zoom });
logEvent('panZoom');
};
};
const handlePanZoom = (state: State, graphDiv: SVGSVGElement) => {
try {
panZoomState.updateElement(graphDiv, state);
} catch (error) {
console.error('PanZoom error:', error);
}
};
const handleStateChange = async (state: ValidatedState) => {
@@ -85,7 +82,6 @@
} = await renderDiagram(JSON.parse(state.mermaid) as MermaidConfig, code, viewID);
diagramType = detectedDiagramType;
if (svg.length > 0) {
// eslint-disable-next-line svelte/no-dom-manipulating
container.innerHTML = svg;
let graphDiv = document.querySelector<SVGSVGElement>(`#${viewID}`);
if (!graphDiv) {
@@ -133,21 +129,18 @@
const renderTime = Date.now() - startTime;
saveStatistics({ code, diagramType, isRough: state.rough, renderTime });
recordRenderTime(renderTime, () => {
updateCodeStore({ updateDiagram: true });
$inputStateStore.updateDiagram = true;
});
};
onMount(() => {
setupPanZoomObserver();
});
// Queue state changes to avoid race condition
let pendingStateChange = Promise.resolve();
$effect(() => {
const state = validatedState.current;
// eslint-disable-next-line @typescript-eslint/no-empty-function
stateStore.subscribe((state) => {
pendingStateChange = pendingStateChange.then(() => handleStateChange(state).catch(() => {}));
});
});
</script>
<FontAwesome bind:waitForFontAwesomeToLoad />
@@ -155,7 +148,7 @@
<div
id="view"
bind:this={view}
class={['h-full w-full', shouldShowGrid && `grid-bg-${mode.current}`, error && 'opacity-50']}>
class={['h-full w-full', shouldShowGrid && `grid-bg-${$mode}`, error && 'opacity-50']}>
<div id="container" bind:this={container} class="h-full overflow-auto"></div>
</div>
@@ -1,101 +0,0 @@
<script lang="ts">
import McWrapper from '$/components/McWrapper.svelte';
import MermaidChartIcon from '$/components/MermaidChartIcon.svelte';
import PrivacyPolicyLink from '$/components/migration/PrivacyPolicyLink.svelte';
import { Button } from '$/components/ui/button';
import * as Dialog from '$/components/ui/dialog';
import { dismissEditorChooser } from '$/util/migration/domainMigration';
import type { Component } from 'svelte';
import AtlassianIcon from '~icons/logos/atlassian';
import AmazonIcon from '~icons/logos/aws';
import GoogleIcon from '~icons/logos/google';
import MicrosoftIcon from '~icons/logos/microsoft';
import { createEditorChooserActions } from './editorChooserActions';
interface Props {
open: boolean;
}
let { open = $bindable() }: Props = $props();
// Tracks whether the current close was triggered by an explicit action
// (button click logs its own event) vs. implicit dismissal (ESC/click-outside).
let handled = false;
const actions = createEditorChooserActions(() => {
handled = true;
open = false;
});
const features = [
{ title: 'AI diagram generation', description: 'Describe what you need, AI builds it' },
{
title: 'Visual drag-and-drop editor',
description: 'Edit diagrams without writing code'
},
{
title: 'Unlimited diagram storage',
description: 'Save and organize all your diagrams'
},
{
title: 'Team collaboration',
description: 'Share, comment, and edit together in real-time'
}
];
const trustedLogos: { name: string; icon: Component }[] = [
{ name: 'Google', icon: GoogleIcon },
{ name: 'Microsoft', icon: MicrosoftIcon },
{ name: 'Atlassian', icon: AtlassianIcon },
{ name: 'Amazon', icon: AmazonIcon }
];
</script>
<Dialog.Root
bind:open
onOpenChange={(v) => {
if (v) return;
if (!handled) actions.log('dismissed');
handled = false;
dismissEditorChooser();
}}>
<Dialog.Content class="flex max-w-lg flex-col gap-3 bg-background p-8">
<Dialog.Header class="flex-col items-start gap-2 space-y-0 text-left sm:text-left">
<MermaidChartIcon class="size-10" />
<Dialog.Title class="pt-2 text-2xl font-bold">Try the full Mermaid experience</Dialog.Title>
<Dialog.Description class="text-xs font-light text-muted-foreground">
Free forever, with Plus features free for 7 days.
</Dialog.Description>
</Dialog.Header>
<ul class="mt-2 flex flex-col gap-3">
{#each features as feature (feature.title)}
<li class="flex items-center gap-3 rounded-lg border border-border p-3">
<span class="size-2 shrink-0 rounded-full bg-accent"></span>
<div class="flex flex-col gap-0.5">
<p class="text-xs">{feature.title}</p>
<p class="text-xs font-light text-muted-foreground">{feature.description}</p>
</div>
</li>
{/each}
</ul>
<div class="mt-2 flex items-center gap-3">
<McWrapper labelPrefix="Opens ">
<Button variant="accent" onclick={() => actions.startTrial()}>Start free trial</Button>
</McWrapper>
<Button variant="outline" onclick={() => actions.dismiss('stayOnLive')}>
Stay on mermaid.live
</Button>
</div>
<div class="mt-3 flex flex-col items-start gap-4">
<p class="text-xs">Trusted by 5M people and over 200k companies</p>
<div class="flex w-full items-center justify-between gap-2">
{#each trustedLogos as logo (logo.name)}
<logo.icon class="h-6 w-auto grayscale" aria-label={logo.name} />
{/each}
</div>
</div>
<PrivacyPolicyLink />
</Dialog.Content>
</Dialog.Root>
@@ -1,10 +0,0 @@
<script lang="ts"></script>
<div class="text-center">
<a
href="https://mermaid.ai/privacy-policy"
target="_blank"
class="text-sm text-foreground underline hover:text-accent">
mermaid.ai Privacy Policy
</a>
</div>
@@ -1,38 +0,0 @@
import { logEvent, logMermaidChartClick } from '$/util/stats';
import { getCheckoutUrl, getMermaidAiLiveUrl } from '$/util/util';
const utmMedium = 'editorSelection';
const utmCampaign = 'live_2026';
export interface EditorChooserActions {
log: (buttonClick: string) => void;
startTrial: (buttonClick?: string) => void;
dismiss: (buttonClick: string) => void;
openMermaidAiLive: (buttonClick: string) => void;
}
export const createEditorChooserActions = (close: () => void): EditorChooserActions => {
const log = (buttonClick: string) => {
logEvent('chooseEditor', { buttonClick });
};
const startTrial = (buttonClick = 'startTrial') => {
log(buttonClick);
logMermaidChartClick('editorPicker');
close();
window.open(getCheckoutUrl({ utmCampaign, utmMedium }), '_blank', 'noopener');
};
const dismiss = (buttonClick: string) => {
log(buttonClick);
close();
};
const openMermaidAiLive = (buttonClick: string) => {
log(buttonClick);
close();
window.open(getMermaidAiLiveUrl({ utmCampaign, utmMedium }), '_blank', 'noopener');
};
return { log, startTrial, dismiss, openMermaidAiLive };
};
+4 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts" module>
import { cn, type WithElementRef } from '$lib/utils.js';
import type { WithElementRef } from 'bits-ui';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { tv, type VariantProps } from 'tailwind-variants';
import { type VariantProps, tv } from 'tailwind-variants';
export const buttonVariants = tv({
base: 'focus-visible:ring-ring inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-6 [&_svg]:shrink-0',
@@ -40,6 +40,8 @@
</script>
<script lang="ts">
import { cn } from '$lib/utils.js';
let {
class: className,
variant = 'default',
@@ -1,6 +1,6 @@
<script lang="ts">
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { Dialog as DialogPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
import X from 'lucide-svelte/icons/x';
import type { Snippet } from 'svelte';
import * as Dialog from './index.js';
@@ -22,13 +22,13 @@
<DialogPrimitive.Content
bind:ref
class={cn(
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...restProps}>
{@render children?.()}
<DialogPrimitive.Close
class="absolute top-4 right-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-none disabled:pointer-events-none data-[state=open]:text-muted-foreground">
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:text-muted-foreground">
<X class="size-4" />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
@@ -1,6 +1,7 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { WithElementRef } from 'bits-ui';
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -1,6 +1,7 @@
<script lang="ts">
import { cn, type WithElementRef } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
import type { WithElementRef } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -12,7 +12,7 @@
<DialogPrimitive.Overlay
bind:ref
class={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...restProps} />
@@ -11,5 +11,5 @@
<DialogPrimitive.Title
bind:ref
class={cn('text-lg leading-none font-semibold tracking-tight', className)}
class={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...restProps} />
+4 -3
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import type { InputType } from '$/types';
import { cn, type WithElementRef } from '$lib/utils.js';
import { cn } from '$lib/utils.js';
import type { WithElementRef } from 'bits-ui';
import type { HTMLInputAttributes } from 'svelte/elements';
type Props = WithElementRef<
@@ -22,7 +23,7 @@
<input
bind:this={ref}
class={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
type="file"
@@ -33,7 +34,7 @@
<input
bind:this={ref}
class={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
{type}
@@ -20,7 +20,7 @@
{align}
{sideOffset}
class={cn(
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...restProps} />
@@ -1,7 +1,8 @@
<script lang="ts">
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import GripVertical from 'lucide-svelte/icons/grip-vertical';
import * as ResizablePrimitive from 'paneforge';
import type { WithoutChildrenOrChild } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
@@ -16,7 +17,7 @@
<ResizablePrimitive.PaneResizer
bind:ref
class={cn(
'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:outline-none data-[direction=vertical]:h-px data-[direction=vertical]:w-full data-[direction=vertical]:after:left-0 data-[direction=vertical]:after:h-1 data-[direction=vertical]:after:w-full data-[direction=vertical]:after:translate-x-0 data-[direction=vertical]:after:-translate-y-1/2 [&[data-direction=vertical]>div]:rotate-90',
'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[direction=vertical]:h-px data-[direction=vertical]:w-full data-[direction=vertical]:after:left-0 data-[direction=vertical]:after:h-1 data-[direction=vertical]:after:w-full data-[direction=vertical]:after:-translate-y-1/2 data-[direction=vertical]:after:translate-x-0 [&[data-direction=vertical]>div]:rotate-90',
className
)}
{...restProps}>
+1 -1
View File
@@ -6,7 +6,7 @@
</script>
<Sonner
theme={mode.current}
theme={$mode}
class="toaster group"
toastOptions={{
classes: {
+3 -3
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
import { Switch as SwitchPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import { Switch as SwitchPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
let {
ref = $bindable(null),
@@ -14,7 +14,7 @@
bind:ref
bind:checked
class={cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-accent data-[state=unchecked]:bg-slate-700',
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-accent data-[state=unchecked]:bg-slate-700',
className
)}
{...restProps}>
@@ -24,12 +24,8 @@
}: ToggleGroupPrimitive.RootProps & ToggleVariants = $props();
setToggleGroupCtx({
get variant() {
return variant;
},
get size() {
return size;
}
variant,
size
});
</script>
@@ -14,7 +14,7 @@
bind:ref
{sideOffset}
class={cn(
'z-50 animate-in overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
'z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...restProps} />
-4
View File
@@ -1,6 +1,4 @@
export const TID = {
aiHelpText: 'ai-help-text',
aiRepairButton: 'ai-repair-button',
copyMarkdown: 'copy-markdown',
diagramDocumentationButton: 'diagram-documentation-button',
downloadPNG: 'download-PNG',
@@ -10,7 +8,5 @@ export const TID = {
} as const;
export const C = {
aiLiveEditor: 'ai_live_editor',
editorChooserDismissedKey: 'mermaid-editor-chooser-dismissed',
utmSource: 'mermaid_live_editor'
} as const;
-71
View File
@@ -1,71 +0,0 @@
import type * as monaco from 'monaco-editor';
export class AIPromptViewZoneManager {
private editor: monaco.editor.IStandaloneCodeEditor | null = null;
private viewZoneId: string | null = null;
private viewZoneNode: HTMLElement | null = null;
private viewZone: monaco.editor.IViewZone | null = null;
private topPopupSpace = 8;
public setEditor(editor: monaco.editor.IStandaloneCodeEditor): void {
this.editor = editor;
}
public show(lineNumber: number, node: HTMLElement, height: number): void {
if (!this.editor) return;
this.viewZoneNode = node;
this.editor.changeViewZones((changeAccessor) => {
if (this.viewZoneId) {
changeAccessor.removeZone(this.viewZoneId);
}
const domNode = document.createElement('div');
domNode.style.top = '0px';
domNode.style.paddingTop = '8px';
domNode.style.paddingBottom = '0px';
domNode.appendChild(this.viewZoneNode as Node);
this.viewZone = {
afterLineNumber: lineNumber,
heightInPx: height + this.topPopupSpace,
domNode: domNode
};
this.viewZoneId = changeAccessor.addZone(this.viewZone);
});
}
public updateHeight(height: number): void {
if (!this.editor || !this.viewZoneId || !this.viewZone) return;
this.viewZone.heightInPx = height + this.topPopupSpace;
this.editor.changeViewZones((changeAccessor) => {
if (this.viewZoneId) {
changeAccessor.layoutZone(this.viewZoneId);
}
});
}
public hide(): void {
if (!this.editor || !this.viewZoneId) return;
this.editor.changeViewZones((changeAccessor) => {
if (this.viewZoneId) {
changeAccessor.removeZone(this.viewZoneId);
this.viewZoneId = null;
this.viewZone = null;
}
});
if (this.viewZoneNode) {
this.viewZoneNode = null;
}
}
public destroy(): void {
this.hide();
this.editor = null;
}
}
-3
View File
@@ -1,10 +1,7 @@
export const env = {
analyticsUrl: import.meta.env.MERMAID_ANALYTICS_URL ?? '',
docsUrl: import.meta.env.MERMAID_DOCS_URL ?? 'https://mermaid.js.org',
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',
krokiRendererUrl: import.meta.env.MERMAID_KROKI_RENDERER_URL ?? '',
privacyPolicyUrl: import.meta.env.MERMAID_PRIVACY_POLICY_URL ?? '',
rendererUrl: import.meta.env.MERMAID_RENDERER_URL ?? ''
} as const;
+14 -15
View File
@@ -1,7 +1,10 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { setLoaderEntries } from '$lib/components/History/historyState.svelte';
import { addHistoryEntry } from '$lib/components/History/history';
import type { State } from '$lib/types';
import { defaultState } from '$lib/util/state.svelte';
import { defaultState } from '$lib/util/state';
import { fetchJSON, fetchText } from '$lib/util/util';
const codeFileName = 'code.mmd';
@@ -46,7 +49,7 @@ const getGistData = async (gistURL: string): Promise<GistData> => {
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, __, gistID, revisionID] = path.split('/');
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { html_url, files, history }: GistResponse = await fetchJSON(
`https://api.github.com/gists/${gistID}${revisionID ? '/' + revisionID : ''}`
);
@@ -62,7 +65,7 @@ const getGistData = async (gistURL: string): Promise<GistData> => {
code,
config,
time: new Date(currentItem.committed_at).getTime(),
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
url: `${html_url}/${currentItem.version}`,
version: currentItem.version.slice(-7)
};
@@ -82,9 +85,7 @@ const getStateFromGist = (gist: GistData, gistURL: string = gist.url): State =>
type: 'gist'
}
};
if (gist.config) {
state.mermaid = gist.config;
}
gist.config && (state.mermaid = gist.config);
return state;
};
@@ -95,7 +96,7 @@ export const loadGistData = async (gistURL: string): Promise<State> => {
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [_, __, gistID, revisionID] = path.split('/');
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const { history }: GistResponse = await fetchJSON(
`https://api.github.com/gists/${gistID}${revisionID ? '/' + revisionID : ''}`
);
@@ -117,16 +118,14 @@ export const loadGistData = async (gistURL: string): Promise<State> => {
throw new Error('Invalid gist provided');
}
const state = getStateFromGist(entry, gistURL);
setLoaderEntries(
gistHistory
.map((gist) => ({
for (const gist of gistHistory) {
addHistoryEntry({
name: `${gist.author} v${gist.version}`,
state: getStateFromGist(gist),
time: gist.time,
type: 'loader' as const,
type: 'loader',
url: gist.url
}))
.reverse()
);
});
}
return state;
};
+2 -4
View File
@@ -1,5 +1,5 @@
import type { Loader, State } from '$lib/types';
import { defaultState, sanitizeConfig, updateCodeStore } from '$lib/util/state.svelte';
import { defaultState, updateCodeStore } from '$lib/util/state';
import { fetchText } from '$lib/util/util';
import { loadGistData } from './gist';
@@ -49,11 +49,9 @@ export const loadDataFromUrl = async (): Promise<void> => {
}
}
}
if (loaded) {
state.mermaid = sanitizeConfig(state.mermaid || defaultState.mermaid);
loaded &&
updateCodeStore({
...state,
updateDiagram: true
});
}
};
-14
View File
@@ -1,14 +0,0 @@
import type { LoadingState } from '$lib/types';
export const loadingState = $state<LoadingState>({ loading: false });
export const initLoading = async <T>(message: string, task: Promise<T>): Promise<T> => {
loadingState.loading = true;
loadingState.message = message;
try {
return await task;
} finally {
loadingState.loading = false;
loadingState.message = undefined;
}
};
+20
View File
@@ -0,0 +1,20 @@
import { writable } from 'svelte/store';
import type { Writable } from 'svelte/store';
import type { LoadingState } from '$lib/types';
const defaultLoading: LoadingState = {
loading: false
};
export const loadingStateStore: Writable<LoadingState> = writable(defaultLoading);
export const initLoading = async <T>(message: string, task: Promise<T>): Promise<T> => {
loadingStateStore.set({
loading: true,
message
});
const result: T = await task;
loadingStateStore.set({
loading: false
});
return result;
};
-23
View File
@@ -1,23 +0,0 @@
import { describe, expect, it } from 'vitest';
import { getSampleDiagrams } from './mermaid';
describe('getSampleDiagrams', () => {
const samples = getSampleDiagrams();
it('should return at least one example per diagram', () => {
expect(Object.keys(samples).length).toBeGreaterThan(0);
for (const [name, examples] of Object.entries(samples)) {
expect(examples.length, `${name} should have at least one example`).toBeGreaterThan(0);
for (const example of examples) {
expect(example.title, `${name} has an example without a title`).toBeTruthy();
expect(example.code, `${name} example "${example.title}" has no code`).toBeTruthy();
}
}
});
it('should list the default example first', () => {
for (const [name, examples] of Object.entries(samples)) {
expect(examples[0].isDefault, `${name} should have its default example first`).toBe(true);
}
});
});
+1 -28
View File
@@ -1,11 +1,9 @@
import { diagramData } from '@mermaid-js/examples';
import elkLayouts from '@mermaid-js/layout-elk';
import tidyTreeLayouts from '@mermaid-js/layout-tidy-tree';
import zenuml from '@mermaid-js/mermaid-zenuml';
import type { MermaidConfig, RenderResult } from 'mermaid';
import mermaid from 'mermaid';
mermaid.registerLayoutLoaders([...elkLayouts, ...tidyTreeLayouts]);
mermaid.registerLayoutLoaders(elkLayouts);
const init = mermaid.registerExternalDiagrams([zenuml]);
export const render = async (
@@ -24,11 +22,6 @@ export const parse = async (code: string) => {
return await mermaid.parse(code);
};
/**
* @see https://mermaid.js.org/config/schema-docs/config.html
*/
export const defaultMermaidConfig = mermaid.mermaidAPI.defaultConfig ?? {};
export const standardizeDiagramType = (diagramType: string) => {
switch (diagramType) {
case 'class':
@@ -46,23 +39,3 @@ export const standardizeDiagramType = (diagramType: string) => {
}
}
};
type DiagramDefinition = (typeof diagramData)[number];
export type SampleExample = DiagramDefinition['examples'][number];
const isValidDiagram = (diagram: DiagramDefinition): diagram is Required<DiagramDefinition> => {
return Boolean(diagram.name && diagram.examples && diagram.examples.length > 0);
};
export const getSampleDiagrams = (): Record<string, SampleExample[]> => {
const samples: Record<string, SampleExample[]> = {};
for (const diagram of diagramData.filter((d) => isValidDiagram(d))) {
// The default example comes first, so it is loaded when clicking the
// diagram name and shown at the top of the example dropdown.
samples[diagram.name.replace(/ (Diagram|Chart|Graph)/, '')] = [...diagram.examples].sort(
(a, b) => Number(b.isDefault ?? false) - Number(a.isDefault ?? false)
);
}
return samples;
};
-86
View File
@@ -1,86 +0,0 @@
import { C } from '$/constants';
import { env } from '$/util/env';
const mermaidAiDomain = 'mermaid.ai';
const mermaidLiveDomain = 'mermaid.live';
const netlifyPreviewDomain = 'netlify.app';
/**
* Check if we're on mermaid.ai
*/
export const isOnMermaidAI = (): boolean => {
const domain = window.location.hostname;
return domain === mermaidAiDomain || domain.endsWith(`.${mermaidAiDomain}`);
};
/**
* Check if we're on mermaid.live
*/
export const isOnMermaidLive = (): boolean => {
const domain = window.location.hostname;
return domain === mermaidLiveDomain || domain.endsWith(`.${mermaidLiveDomain}`);
};
/**
* Check if we're on a Netlify preview/staging deploy (*.netlify.app).
*/
const isOnNetlifyPreview = (): boolean => {
return window.location.hostname.endsWith(`.${netlifyPreviewDomain}`);
};
/**
* Check if the current URL has pako data (diagram content from a shared link).
*/
const hasPakoData = (): boolean => {
const hash = window.location.hash;
return (
hash.includes('pako:') || hash.includes('base64:') || (hash.length > 10 && hash.startsWith('#'))
);
};
/**
* Check if the user arrived from a known Mermaid domain (mermaid.ai or mermaid.js.org).
*/
const isReferredFromMermaid = (): boolean => {
try {
const referrer = document.referrer;
if (!referrer) return false;
const hostname = new URL(referrer).hostname;
return (
hostname === 'mermaid.ai' ||
hostname.endsWith('.mermaid.ai') ||
hostname === 'mermaid.js.org' ||
hostname.endsWith('.mermaid.js.org') ||
hostname === 'mermaid.live' ||
hostname.endsWith('.mermaid.live')
);
} catch {
return false;
}
};
/**
* Check if the editor chooser modal should be shown.
* Shows for new users who haven't dismissed it and aren't viewing a shared link.
* Not shown on mobile (viewport width < 640px).
* Can be forced open for QA via the `?editorChooser=1` query flag, which bypasses
* the hostname and dismissed checks.
*/
export const shouldShowEditorChooser = (): boolean => {
if (!env.isEnabledMermaidChartLinks) return false;
if (window.innerWidth < 640) return false;
const forced = new URLSearchParams(window.location.search).get('editorChooser') === '1';
if (forced) return true;
if (!isOnMermaidAI() && !isOnMermaidLive() && !isOnNetlifyPreview()) return false;
if (window.localStorage.getItem(C.editorChooserDismissedKey) === 'true') return false;
if (hasPakoData()) return false;
if (isReferredFromMermaid()) return false;
return true;
};
/**
* Dismiss the editor chooser modal permanently
*/
export const dismissEditorChooser = (): void => {
window.localStorage.setItem(C.editorChooserDismissedKey, 'true');
};
+1 -1
View File
@@ -14,7 +14,7 @@ describe('migrations', () => {
});
it('should migrate from v0 to v1', async () => {
const { applyMigrations } = await import('./migrations.svelte');
const { applyMigrations } = await import('./migrations');
let manualHistoryStore: HistoryEntry[] = JSON.parse(
window.localStorage.getItem('manualHistoryStore') ?? '[]'
) as HistoryEntry[];
@@ -1,5 +1,6 @@
import { injectHistoryIDs } from '$lib/components/History/historyState.svelte';
import { persisted } from '$lib/util/persist.svelte';
import { writable, get, type Writable } from 'svelte/store';
import { persist, localStorage } from '$lib/util/persist';
import { injectHistoryIDs } from '$lib/components/History/history';
import { logEvent } from './stats';
interface MigrationState {
@@ -10,10 +11,14 @@ const migrations: Record<string, () => void> = {
injectHistoryIDs
};
const migrationState = persisted<MigrationState>('migrations', { version: -1 });
const migrationStore: Writable<MigrationState> = persist(
writable({ version: -1 }),
localStorage(),
'migrations'
);
export const applyMigrations = (): void => {
const { version } = migrationState.value;
const { version }: MigrationState = get(migrationStore);
const allMigrations = Object.entries(migrations);
if (version === allMigrations.length - 1) {
return;
@@ -24,7 +29,7 @@ export const applyMigrations = (): void => {
console.log(`Applying migration ${i}: ${key}.`);
fn();
logEvent('migration', { key });
migrationState.value = { version: i };
migrationStore.set({ version: i });
}
logEvent('migration', { status: 'complete', from: version, to: allMigrations.length - 1 });
};
+2
View File
@@ -2,6 +2,7 @@
import type * as Monaco from 'monaco-editor';
// eslint-disable-next-line es/no-regexp-lookbehind-assertions
const commentRegex = /(?<!["'])%%(?![^"']*["']\)).*$/;
export const initEditor = (monacoEditor: typeof Monaco): void => {
@@ -234,6 +235,7 @@ export const initEditor = (monacoEditor: typeof Monaco): void => {
])
)
)
// eslint-disable-next-line unicorn/no-array-reduce
.reduce(
(overallKeywords, nextKeyword) => ({
...overallKeywords,
-66
View File
@@ -1,66 +0,0 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { persisted, readJSON, writeJSON } from './persist.svelte';
beforeEach(() => {
window.localStorage.clear();
});
describe('readJSON', () => {
it('returns the fallback when the key is missing', () => {
expect(readJSON('missing', 'fallback')).toBe('fallback');
});
it('returns the parsed value when present', () => {
window.localStorage.setItem('key', '{"a":1}');
expect(readJSON<{ a: number }>('key', { a: 0 })).toEqual({ a: 1 });
});
it('returns the fallback when the stored value is corrupt', () => {
window.localStorage.setItem('corrupt', '{oops');
expect(readJSON('corrupt', 'fallback')).toBe('fallback');
// The previous persistence layer could write the literal string "undefined".
window.localStorage.setItem('legacy', 'undefined');
expect(readJSON('legacy', 'fallback')).toBe('fallback');
});
it('returns the fallback when the stored value parses to null', () => {
// The pre-runes persistence layer treated a stored null as absent.
window.localStorage.setItem('legacy-null', 'null');
expect(readJSON('legacy-null', 'fallback')).toBe('fallback');
});
});
describe('writeJSON', () => {
it('round-trips values through localStorage as JSON', () => {
writeJSON('key', { nested: { value: 2 } });
expect(window.localStorage.getItem('key')).toBe('{"nested":{"value":2}}');
expect(readJSON('key', {})).toEqual({ nested: { value: 2 } });
});
});
describe('persisted', () => {
it('initialises from storage when a value exists', () => {
window.localStorage.setItem('counter', '5');
const counter = persisted('counter', 0);
expect(counter.value).toBe(5);
});
it('uses the initial value when storage is empty, without writing it', () => {
const counter = persisted('counter', 7);
expect(counter.value).toBe(7);
expect(window.localStorage.getItem('counter')).toBeNull();
});
it('persists on assignment and exposes the new value', () => {
const counter = persisted('counter', 0);
counter.value = 42;
expect(counter.value).toBe(42);
expect(window.localStorage.getItem('counter')).toBe('42');
});
it('uses the initial value when storage holds a literal null', () => {
window.localStorage.setItem('settings', 'null');
const settings = persisted('settings', { theme: 'default' });
expect(settings.value).toEqual({ theme: 'default' });
});
});
-53
View File
@@ -1,53 +0,0 @@
/**
* Runes-based localStorage persistence, shared by the persisted state in
* `state.svelte.ts`, `migrations.svelte.ts`, `promo.svelte.ts` and History.
*
* Values are stored as plain JSON. Reads of missing or corrupt values fall
* back to the provided default, so values written by older versions of the
* editor (which serialized plain objects to the same JSON shape) stay loadable.
*/
const hasStorage = (): boolean => typeof window !== 'undefined' && !!window.localStorage;
export const readJSON = <T>(key: string, fallback: T): T => {
if (!hasStorage()) {
return fallback;
}
try {
const raw = window.localStorage.getItem(key);
if (raw === null) {
return fallback;
}
// A stored literal "null" means the value is absent: the pre-runes
// persistence layer never wrote null and treated it as missing.
return (JSON.parse(raw) as T) ?? fallback;
} catch {
return fallback;
}
};
export const writeJSON = (key: string, value: unknown): void => {
if (hasStorage()) {
window.localStorage.setItem(key, JSON.stringify(value));
}
};
export interface Persisted<T> {
value: T;
}
// A localStorage-backed reactive value. Reads on init, writes on every set.
// Raw state: replace `value` wholesale to change it. With a deep proxy,
// in-place mutation would update the UI without ever being persisted.
export const persisted = <T>(key: string, initial: T): Persisted<T> => {
let value = $state.raw<T>(readJSON(key, initial));
return {
get value() {
return value;
},
set value(next: T) {
value = next;
writeJSON(key, next);
}
};
};
+257
View File
@@ -0,0 +1,257 @@
/* 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 = () => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (listenExternalChanges && typeof window !== 'undefined' && window.addEventListener) {
window.addEventListener('storage', listenerFunction);
}
};
const disconnect = () => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
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> {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
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
}
};
}
+55 -21
View File
@@ -21,6 +21,37 @@
const commonParams = { utm_source: C.utmSource, utm_medium: 'banner_ad' } as const;
const randomTagLines: Taglines[] = [
{
label: 'Customize your layout and design in Mermaid Charts whiteboard!',
url: {
path: '/whiteboard',
params: { utm_campaign: 'whiteboard' }
}
},
{
label: 'Customize your layout and design in Mermaid Charts visual editor!',
url: {
path: '/whiteboard',
params: { utm_campaign: 'visual_editor' }
}
},
{
label: 'Customize your layout and design with Mermaid Charts GUI!',
url: {
path: '/whiteboard',
params: { utm_campaign: 'gui' }
}
},
{
label: 'Customize your layout and design in Mermaid Chart!',
url: {
path: '/whiteboard',
params: { utm_campaign: 'visual_editor_whiteboard_gui' }
}
}
];
let taglines: Taglines[] = [
{
label: 'Replace ChatGPT Pro, Mermaid.live, and Lucid Chart with Mermaid Chart',
@@ -35,38 +66,39 @@
path: '/landing',
params: { utm_campaign: 'team_collaboration' }
}
},
{
label: 'Customize your layout and design in Mermaid Charts visual editor!',
url: {
path: '/whiteboard',
params: { utm_campaign: 'visual_editor' }
}
}
];
const getRandomIndex = (array: unknown[]) => Math.floor(Math.random() * array.length);
const getSessionHeadlineIndex = (): number => {
const storageKey = 'mermaidBannerIndex';
const storedIndex = sessionStorage.getItem(storageKey);
let index = $state(getRandomIndex(taglines));
let currentTagline = $derived(taglines[index]);
if (storedIndex) {
return Number.parseInt(storedIndex, 10);
}
const randomIndex = getRandomIndex(randomTagLines);
sessionStorage.setItem(storageKey, randomIndex.toString());
return randomIndex;
};
const selectedHeadlineIndex = getSessionHeadlineIndex();
const currentBannerSet = [...taglines, randomTagLines[selectedHeadlineIndex]];
let index = $state(getRandomIndex(currentBannerSet));
let currentTagline = $derived(currentBannerSet[index]);
let shouldAnimate = $state(true);
const interval = setInterval(() => {
if (shouldAnimate) {
index = (index + 1) % taglines.length;
index = (index + 1) % currentBannerSet.length;
}
}, 5000);
onDestroy(() => {
clearInterval(interval);
});
const taglineHref = $derived(
`${MCBaseURL}${currentTagline.url.path}?${new URLSearchParams({
...commonParams,
...currentTagline.url.params
}).toString()}`
);
</script>
<div
@@ -77,15 +109,17 @@
<div class="grid grow">
{#key currentTagline}
<a
href={taglineHref}
href="{MCBaseURL}{currentTagline.url.path}?{new URLSearchParams({
...commonParams,
...currentTagline.url.params
}).toString()}"
target="_blank"
rel="noopener noreferrer"
class="col-start-1 row-start-1 flex items-center justify-center gap-4 no-underline"
in:fade={{ delay: 800 }}
out:fade={{ duration: 1000 }}>
<span class="text-base tracking-wider text-white">{currentTagline.label}</span>
<span class="text-sm tracking-wider text-white">{currentTagline.label}</span>
<Button
class="shrink-0 rounded-md bg-[#1E1A2E] px-3 py-1.5 text-base font-semibold tracking-wide text-white hover:bg-[#261A56]">
class="shrink-0 rounded-md bg-[#1E1A2E] px-3 py-1.5 text-sm font-semibold tracking-wide text-white hover:bg-[#261A56]">
Try now
</Button>
</a>
-31
View File
@@ -1,31 +0,0 @@
<script lang="ts">
import { Button } from '$/components/ui/button';
import { getCheckoutUrl } from '$/util/util';
import type { Snippet } from 'svelte';
interface Props {
closeBanner: Snippet;
}
let { closeBanner }: Props = $props();
const url = getCheckoutUrl({ utmCampaign: 'oss_coupon', utmMedium: 'banner_ad' });
</script>
<div class="flex w-full items-center bg-[#E0095F] p-1.5" role="banner">
<div class="grid grow">
<a
href={url}
target="_blank"
class="col-start-1 row-start-1 flex items-center justify-center gap-4 no-underline">
<span class="text-base tracking-wider text-white">
Try Mermaid Advanced Editor — OSS users get 10% off with code JS26
</span>
<Button
class="shrink-0 rounded-md bg-[#1E1A2E] px-3 py-1.5 text-base font-semibold tracking-wide text-white hover:bg-[#261A56]">
Get started
</Button>
</a>
</div>
{@render closeBanner()}
</div>
@@ -1,27 +1,21 @@
import { env } from '$lib/util/env';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import type { Component, Snippet } from 'svelte';
import { persisted } from '../persist.svelte';
import type { Component } from 'svelte';
import { get, writable, type Writable } from 'svelte/store';
import { localStorage, persist } from '../persist';
import April2025 from './April2025.svelte';
import JS2026 from './JS2026.svelte';
dayjs.extend(duration);
interface Promotion {
startDate: Date;
endDate: Date;
component: Component<{ closeBanner: Snippet }>;
component: Component;
hideDurationMs: number;
}
const promotions: Record<string, Promotion> = {
'promo-js-2026': {
startDate: new Date('2026-01-01'),
endDate: new Date('2026-12-31'),
component: JS2026,
hideDurationMs: dayjs.duration(1, 'week').asMilliseconds()
},
'promo-april-2025': {
startDate: new Date('2025-04-01'),
endDate: new Date('2028-12-31'),
@@ -34,21 +28,25 @@ export const dismissPromotion = (id?: string): void => {
if (!id || !promotions[id]) {
return;
}
hiddenPromotions.value = {
...hiddenPromotions.value,
[id]: dayjs().add(promotions[id].hideDurationMs).valueOf()
};
hiddenPromotionsStore.update((dismissedIDs) => {
dismissedIDs[id] = dayjs().add(promotions[id].hideDurationMs).valueOf();
return dismissedIDs;
});
};
const hiddenPromotions = persisted<Record<string, number>>('hiddenPromotions', {});
const hiddenPromotionsStore: Writable<Record<string, number>> = persist(
writable({}),
localStorage(),
'hiddenPromotions'
);
export const getActivePromotion = (): (Promotion & { id: string }) | undefined => {
if (!env.isEnabledMermaidChartLinks) {
return;
}
const hidePromotionsUntil = hiddenPromotions.value;
const now = dayjs();
const hidePromotionsUntil = get(hiddenPromotionsStore);
const now = new Date();
const promotionWithID = Object.entries(promotions)
.filter(
([id, p]) =>
-51
View File
@@ -1,51 +0,0 @@
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');
});
});
-17
View File
@@ -1,17 +0,0 @@
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 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { serializeState, deserializeState, type SerdeType } from './serde';
import { defaultState } from './state.svelte';
import { defaultState } from './state';
import type { State } from '$lib/types';
const verifySerde = (state: State, serde?: SerdeType): string => {
+3 -3
View File
@@ -1,6 +1,6 @@
import type { State } from '$lib/types';
import { fromBase64, fromUint8Array, toBase64, toUint8Array } from 'js-base64';
import { deflate, inflate } from 'pako';
import { toUint8Array, fromUint8Array, toBase64, fromBase64 } from 'js-base64';
import type { State } from '$lib/types';
interface Serde {
serialize: (state: string) => string;
@@ -30,7 +30,7 @@ export const pakoSerde: Serde = {
export type SerdeType = 'base64' | 'pako';
const serdes: Record<SerdeType, Serde> = {
const serdes: { [key in SerdeType]: Serde } = {
base64: base64Serde,
pako: pakoSerde
};
-88
View File
@@ -1,88 +0,0 @@
import type { State } from '$lib/types';
import { flushSync } from 'svelte';
import { describe, expect, it } from 'vitest';
import {
defaultState,
inputState,
loadState,
replaceInputState,
toggleDarkTheme,
updateCode,
updateCodeStore,
updateConfig,
verifyState
} from './state.svelte';
// Runs `body` inside an effect and reports how often the effect (re-)runs.
const countEffectRuns = (body: () => void): { runs: () => number; stop: () => void } => {
let runs = 0;
const stop = $effect.root(() => {
$effect(() => {
runs++;
body();
});
});
flushSync();
return { runs: () => runs, stop };
};
const readStoredState = (): State =>
JSON.parse(window.localStorage.getItem('codeStore') ?? '{}') as State;
describe('update functions called from effects', () => {
// Effects that call an update function must not subscribe to the input
// state the function reads, or unrelated state changes re-fire the effect
// (and self-reads loop, e.g. the dark-theme effect in +layout.svelte).
const cases: [string, () => void][] = [
['updateCodeStore', () => updateCodeStore({})],
['updateCode', () => updateCode('graph TD\n inside-effect')],
['updateConfig', () => updateConfig('{"theme":"default"}')],
['toggleDarkTheme', () => toggleDarkTheme(false)],
['replaceInputState', () => replaceInputState({ ...defaultState })],
['verifyState', () => verifyState()],
['loadState', () => loadState('')]
];
it.each(cases)('%s does not make the calling effect track input state', (_name, call) => {
const counter = countEffectRuns(call);
try {
expect(counter.runs()).toBe(1);
updateCode('graph TD\n external-change');
updateConfig('{"theme":"forest"}');
updateCodeStore({ pan: { x: 1, y: 2 } });
flushSync();
expect(counter.runs()).toBe(1);
} finally {
counter.stop();
}
});
});
describe('update functions persist input state', () => {
it('updateCode writes the new code to localStorage', () => {
updateCode('graph TD\n persisted-by-test');
expect(readStoredState().code).toBe('graph TD\n persisted-by-test');
});
it('updateCodeStore merges partial state and persists it', () => {
updateCodeStore({ rough: true });
expect(inputState.rough).toBe(true);
expect(readStoredState().rough).toBe(true);
});
it('replaceInputState drops keys absent from the next state and persists', () => {
updateCodeStore({ pan: { x: 1, y: 2 } });
expect(inputState.pan).toEqual({ x: 1, y: 2 });
replaceInputState({ ...defaultState });
expect(inputState.pan).toBeUndefined();
expect(readStoredState().pan).toBeUndefined();
expect(readStoredState().code).toBe(defaultState.code);
});
it('verifyState forces panZoom back on', () => {
updateCodeStore({ panZoom: false });
verifyState();
expect(inputState.panZoom).toBe(true);
expect(readStoredState().panZoom).toBe(true);
});
});
-375
View File
@@ -1,375 +0,0 @@
import type { ErrorHash, MarkerData, State, ValidatedState } from '$/types';
import { resolve } from '$app/paths';
import { debounce, get as lodashGet } from 'lodash-es';
import type { MermaidConfig } from 'mermaid';
import { untrack } from 'svelte';
import { env } from './env';
import {
extractErrorLineText,
findMostRelevantLineNumber,
replaceLineNumberInErrorMessage
} from './errorHandling';
import { defaultMermaidConfig, parse } from './mermaid';
import { readJSON, writeJSON } from './persist.svelte';
import { deserializeState, pakoSerde, serializeState } from './serde';
import { errorDebug, formatJSON, getUTMSource, MCBaseURL } from './util';
export const defaultState: State = {
code: `flowchart TD
A[Christmas] -->|Get money| B(Go shopping)
B --> C{Let me think}
C -->|One| D[Laptop]
C -->|Two| E[iPhone]
C -->|Three| F[fa:fa-car Car]
`,
grid: true,
mermaid: formatJSON({
theme: 'default'
}),
panZoom: true,
rough: false,
updateDiagram: true
};
const urlParseFailedState = `flowchart TD
A[Loading URL failed. We can try to figure out why.] -->|Decode JSON| B(Please check the console to see the JSON and error details.)
B --> C{Is the JSON correct?}
C -->|Yes| D(Please Click here to Raise an issue in github.<br/>Including the broken link in the issue <br/> will speed up the fix.)
C -->|No| E{Did someone <br/>send you this link?}
E -->|Yes| F[Ask them to send <br/>you the complete link]
E -->|No| G{Did you copy <br/> the complete URL?}
G --> |Yes| D
G --> |"No :("| H(Try using the Timeline tab in History <br/>from same browser you used to create the diagram.)
click D href "https://github.com/mermaid-js/mermaid-live-editor/issues/new?assignees=&labels=bug&template=bug_report.md&title=Broken%20link" "Raise issue"`;
const CODE_STORE_KEY = 'codeStore';
// The single mutable input state; only update() below may write to it.
// The fallback is cloned so mutations never write through to defaultState.
const input = $state<State>(readJSON(CODE_STORE_KEY, { ...defaultState }));
// inputState is shared externally when exporting via URL, History, etc.
// It is reactive for reads; the read-only type keeps writes inside this
// module, where update() persists and re-validates every change.
export const inputState: Readonly<State> = input;
const validatedStateOf = (state: State, serialized: string): ValidatedState => ({
...state,
editorMode: state.editorMode ?? 'code',
error: undefined,
errorMarkers: [],
serialized
});
const initialState = $state.snapshot(input) as State;
// Only ever replaced wholesale, so raw (shallow) reactivity is enough.
let validatedCurrent = $state.raw<ValidatedState>(
validatedStateOf(initialState, serializeState(initialState))
);
let lastDiagramType = '';
const processState = async (state: State) => {
const processed = validatedStateOf(state, '');
// No changes should be done to fields part of `state`.
try {
processed.serialized = serializeState(state);
const { diagramType } = await parse(state.code);
processed.diagramType = diagramType;
if (lastDiagramType === 'zenuml' && diagramType !== lastDiagramType) {
// Temp Hack to refresh page after displaying ZenUML.
setTimeout(() => window.location.reload(), 500);
}
lastDiagramType = diagramType;
JSON.parse(state.mermaid);
} catch (error) {
processed.error = error as Error;
errorDebug();
console.error(error);
if (error && typeof error === 'object' && 'hash' in error) {
try {
let errorString = processed.error.toString();
const errorLineText = extractErrorLineText(errorString);
const realLineNumber = findMostRelevantLineNumber(errorLineText, state.code);
let first_line: number, last_line: number, first_column: number, last_column: number;
try {
({ first_line, last_line, first_column, last_column } = (error.hash as ErrorHash).loc);
} catch {
const lineNo = findMostRelevantLineNumber(errorString, state.code);
first_line = lineNo;
last_line = lineNo + 1;
first_column = 0;
last_column = 0;
}
if (realLineNumber !== -1) {
errorString = replaceLineNumberInErrorMessage(errorString, realLineNumber);
}
processed.error = new Error(errorString);
const marker: MarkerData = {
endColumn: last_column + (first_column === last_column ? 0 : 5),
endLineNumber: last_line + (realLineNumber - first_line),
message: errorString || 'Syntax error',
severity: 8, // Error
startColumn: first_column,
startLineNumber: realLineNumber
};
processed.errorMarkers = [marker];
} catch (error) {
console.error('Error without line helper', error);
}
}
}
return processed;
};
// Replaces the old URL-hash store subscription; assigned by initURLSubscription.
let updateHash: ((serialized: string) => void) | undefined;
// Persist the current input state and asynchronously re-validate it,
// publishing the result to `validatedState` (and the URL hash, once
// initURLSubscription has run). Only called from update(), which suppresses
// dependency tracking.
const persistAndProcess = (): void => {
const snapshot = $state.snapshot(input) as State;
writeJSON(CODE_STORE_KEY, snapshot);
void processState(snapshot).then((processed) => {
validatedCurrent = processed;
updateHash?.(processed.serialized);
});
};
// The single mutation gateway: every update function funnels its writes
// through here. The mutator runs untracked so effects that call an update
// function never subscribe to the input state it reads, and the trailing
// persist + re-validate cannot be forgotten by a new update function.
const update = (mutate: (state: State) => void): void => {
untrack(() => {
mutate(input);
persistAndProcess();
});
};
// All internal reads should be done via validatedState, but it should not be
// persisted/shared externally.
export const validatedState = {
get current(): ValidatedState {
return validatedCurrent;
}
};
const urlsCurrent = $derived.by(() => {
const { code, serialized } = validatedCurrent;
const { krokiRendererUrl, rendererUrl } = env;
const png = rendererUrl ? `${rendererUrl}/img/${serialized}?type=png` : '';
return {
kroki: krokiRendererUrl ? `${krokiRendererUrl}/mermaid/svg/${pakoSerde.serialize(code)}` : '',
mdCode: png ? `[![](${png})](${window.location.href})` : '',
mermaidChart: ({
medium,
campaign
}: {
medium:
| 'ai_edit'
| 'ai_repair'
| 'main_menu'
| 'save_diagram'
| 'share'
| 'vibe_diagramming'
| 'visual_edit'
| 'voice_edit';
campaign?: string;
}) => {
const utmSource = getUTMSource();
const params = new URLSearchParams({
utm_source: utmSource,
utm_medium: medium,
...(campaign ? { utm_campaign: campaign } : {})
}).toString();
return {
save: `${MCBaseURL}/app/plugin/save?state=${serialized}&${params}`,
playground: `${MCBaseURL}/play?${params}#${serialized}`,
plugins: `${MCBaseURL}/plugins?${params}`,
home: `${MCBaseURL}/?${params}`
};
},
new: `${resolve('/edit', {})}#${serializeState(defaultState)}`,
png,
svg: rendererUrl ? `${rendererUrl}/svg/${serialized}` : '',
view: `${resolve('/view', {})}#${serialized}`
};
});
export const urls = {
get current() {
return urlsCurrent;
}
};
/**
* Gets a list of paths that contain unsafe keys which might pose security risks.
*
* @param object - The object to check for unsafe keys.
* @param unsafeKeys - List of unsafe keys.
* @param path - The current path being checked (used for recursion).
* @returns List of unsafe paths.
*/
function getUnsafePaths(object: object, unsafeKeys: string[], path: string[] = []) {
const unsafePaths = new Array<string[]>();
for (const key of unsafeKeys) {
// Copied from mermaid's sanitize function in case there's non-enumerable keys
if (Object.hasOwn(object, key)) {
unsafePaths.push([...path, key]);
continue;
}
}
Object.keys(object).forEach((key) => {
const value = (object as Record<string, unknown>)[key];
const currentPath = [...path, key];
// Prototype pollution check.
if (key.startsWith('__')) {
unsafePaths.push(currentPath);
return;
}
if (typeof value === 'object' && value !== null) {
unsafePaths.push(...getUnsafePaths(value as object, unsafeKeys, currentPath));
} else if (
typeof value === 'string' &&
// XSS prevention checks -- See mermaid `sanitize` function for reference.
(value.includes('<') || value.includes('>') || value.includes('url(data:'))
) {
unsafePaths.push(currentPath);
}
});
return unsafePaths;
}
/**
* Asks the user for confirmation if the config contains settings that might
* pose security risks, such as a relaxed `securityLevel`.
*
* @param config - The Mermaid configuration to sanitize.
* @returns The sanitized Mermaid configuration as a JSON string.
*/
export const sanitizeConfig = (config: string | MermaidConfig) => {
const mermaidConfig: MermaidConfig =
typeof config === 'string' ? (JSON.parse(config) as MermaidConfig) : config;
const secureKeys = defaultMermaidConfig.secure ?? [];
const unsafePaths = getUnsafePaths(mermaidConfig, secureKeys).filter((path) => {
return lodashGet(mermaidConfig, path) !== lodashGet(defaultMermaidConfig, path);
});
if (
unsafePaths.length > 0 &&
confirm(
`Removing ${unsafePaths
.map((unsafePath) => {
return `${JSON.stringify(unsafePath.join('.'))}: ${JSON.stringify(lodashGet(mermaidConfig, unsafePath))}`;
})
.join(
',\n'
)} from the config for safety.\nClick Cancel if you trust the source of this Diagram.`
)
) {
for (const unsafePath of unsafePaths) {
const pathToObject = [...unsafePath];
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- We know this exists since it was found in `getUnsafePaths`
const lastKey = pathToObject.pop()!;
const lastObject =
pathToObject.length === 0 ? mermaidConfig : lodashGet(mermaidConfig, pathToObject);
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Copied from mermaid code
delete lastObject[lastKey];
}
}
return formatJSON(mermaidConfig);
};
export const loadState = (data: string): void => {
console.log(`Loading '${data}'`);
update((state) => {
let next: State;
try {
next = deserializeState(data);
next.mermaid = sanitizeConfig(next.mermaid || defaultState.mermaid);
} catch (error) {
next = $state.snapshot(state) as State;
if (data) {
console.error('Init error', error);
next.code = urlParseFailedState;
next.mermaid = defaultState.mermaid;
}
}
applyPartial(state, next);
});
};
let renderCount = 0;
const applyPartial = (state: State, newState: Partial<State>): void => {
renderCount++;
Object.assign(state, newState, { renderCount });
};
export const updateCodeStore = (newState: Partial<State>): void => {
update((state) => applyPartial(state, newState));
};
export const updateCode = (
code: string,
{
updateDiagram = false,
resetPanZoom = false
}: { updateDiagram?: boolean; resetPanZoom?: boolean } = {}
): void => {
errorDebug();
update((state) => {
if (resetPanZoom) {
state.pan = undefined;
state.zoom = undefined;
}
state.code = code;
state.updateDiagram = updateDiagram;
});
};
export const updateConfig = (config: string): void => {
updateCodeStore({ mermaid: config });
};
export const toggleDarkTheme = (dark: boolean): void => {
update((state) => {
const config = JSON.parse(state.mermaid) as MermaidConfig;
if (!config.theme || ['dark', 'default'].includes(config.theme)) {
config.theme = dark ? 'dark' : 'default';
}
state.mermaid = formatJSON(config);
});
};
// Replaces the whole input state (e.g. when restoring a history entry),
// dropping keys the next state does not define.
export const replaceInputState = (next: State): void => {
update((state) => {
for (const key of Object.keys(state)) {
if (!(key in next)) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- full-replace semantics
delete (state as unknown as Record<string, unknown>)[key];
}
}
Object.assign(state, next);
});
};
export const initURLSubscription = (): void => {
updateHash = debounce((serialized: string) => {
history.replaceState(undefined, '', `#${serialized}`);
}, 250);
updateHash(validatedCurrent.serialized);
};
export const verifyState = (): void => {
update((state) => applyPartial(state, state.panZoom ? {} : { panZoom: true }));
};
+255
View File
@@ -0,0 +1,255 @@
import { C } from '$/constants';
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, 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 {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
({ 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}/img/${serialized}?type=png`;
return {
kroki: `${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' | 'toggle';
}) => {
const params = new URLSearchParams({
utm_source: C.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}/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);
};
-47
View File
@@ -1,47 +0,0 @@
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}/`);
});
});
+10 -25
View File
@@ -5,10 +5,7 @@ import { env } from './env';
export let plausible: ReturnType<typeof PlausibleInstance> | undefined;
export const initAnalytics = async (): Promise<void> => {
if (!env.analyticsUrl || !browser || plausible) {
return;
}
if (browser && !plausible) {
try {
const { default: Plausible } = await import('plausible-tracker');
plausible = Plausible({
@@ -21,15 +18,7 @@ export const initAnalytics = async (): Promise<void> => {
console.log(error);
console.info('Analytics blocked ;)');
}
};
/**
* 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 => {
@@ -86,20 +75,19 @@ const minutesToMilliSeconds = (minutes: number): number => {
return minutes * 60_000;
};
const noDelay = 0;
const defaultDelay = minutesToMilliSeconds(1);
const delaysPerEvent = {
bannerClick: noDelay,
chooseEditor: noDelay,
bannerClick: defaultDelay,
copyClipboard: defaultDelay,
copyMarkdown: defaultDelay,
download: defaultDelay,
history: defaultDelay,
loadGist: defaultDelay,
loadSampleDiagram: defaultDelay,
mermaidChartClick: noDelay,
migration: defaultDelay,
mobileViewToggle: defaultDelay,
panZoom: minutesToMilliSeconds(10),
playgroundToggle: 0,
pwaInstalled: defaultDelay,
render: minutesToMilliSeconds(5),
renderDiagram: defaultDelay,
@@ -113,9 +101,6 @@ export const logEvent = (
name: AnalyticsEvent,
data?: Record<string, string | number | boolean>
): void => {
if (browser && window.location.hostname === 'localhost') {
console.log('[plausible]', name, data);
}
if (!plausible) {
return;
}
@@ -123,14 +108,14 @@ export const logEvent = (
if (timeouts.has(key)) {
clearTimeout(timeouts.get(key));
} else {
plausible.trackEvent(name, { props: data }, { url: getAnalyticsSafeUrl() });
plausible.trackEvent(
name,
{ props: data },
{ url: window.location.origin + window.location.pathname }
);
}
timeouts.set(
key,
window.setTimeout(() => timeouts.delete(key), delaysPerEvent[name])
);
};
export const logMermaidChartClick = (source: string): void => {
logEvent('mermaidChartClick', { source });
};

Some files were not shown because too many files have changed in this diff Show More