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
95 changed files with 2791 additions and 4020 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
}
}
]
}
};
+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'
+3 -4
View File
@@ -5,7 +5,6 @@ on:
branches:
- master
- develop
merge_group:
jobs:
playwright:
@@ -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
-1
View File
@@ -5,7 +5,6 @@ on:
branches:
- master
- develop
merge_group:
jobs:
unit-tests:
+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 -9
View File
@@ -9,6 +9,7 @@
"corg",
"cssnano",
"esserializer",
"fontawesome",
"fsegurai",
"gantt",
"gitgraph",
@@ -16,7 +17,6 @@
"localstorage",
"mermaidchart",
"mindmap",
"NEWYEAR",
"Pageview",
"pako",
"panmove",
@@ -26,14 +26,12 @@
"pinchstart",
"pzoom",
"roughjs",
"sankey",
"Serde",
"serdes",
"Stackable",
"tailwindcss",
"treemap",
"uparrow",
"xychart",
"webfonts",
"zenuml"
],
"vitest.commandLine": "pnpm test:unit",
@@ -48,9 +46,5 @@
"tailwindCSS.experimental.classRegex": [
["([\"'`][^\"'`]*.*?[\"'`])", "[\"'`]([^\"'`]*).*?[\"'`]"]
],
"tailwindCSS.emmetCompletions": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "explicit"
}
"tailwindCSS.emmetCompletions": true
}
-3
View File
@@ -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'
+37 -42
View File
@@ -9,8 +9,8 @@
"dev:test": "pnpm dev",
"build": "vite build",
"preview": "vite preview",
"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)",
@@ -23,65 +23,62 @@
"test:e2e:debug": "playwright test --debug"
},
"devDependencies": {
"@eslint/compat": "^1.2.5",
"@eslint/eslintrc": "^3.3.3",
"@eslint/js": "^9.39.2",
"@fortawesome/fontawesome-free": "^6.7.2",
"@iconify-json/material-symbols": "^1.2.20",
"@iconify-json/mdi": "^1.2.3",
"@playwright/test": "^1.52.0",
"@sveltejs/adapter-static": "^3.0.9",
"@sveltejs/kit": "^2.37.0",
"@sveltejs/vite-plugin-svelte": "^6.1.3",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.18",
"@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": "^22.15.10",
"@types/pako": "2.0.3",
"@types/uuid": "9.0.8",
"@vitest/coverage-v8": "^3.2.4",
"@vitest/ui": "^3.2.4",
"@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": "^2.9.6",
"bits-ui": "^1.4.6",
"c8": "7.14.0",
"chai": "^4.5.0",
"clsx": "^2.1.1",
"cssnano": "^6.1.2",
"dotenv": "^17.3.1",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
"eslint": "8.57.1",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-es": "^4.1.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.0.0",
"eslint-plugin-tailwindcss": "^3.18.2",
"eslint-plugin-unicorn": "^60.0.0",
"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",
"globals": "^16.0.0",
"husky": "^8.0.3",
"jsdom": "^25.0.1",
"lint-staged": "^15.5.1",
"lucide-svelte": "^0.507.0",
"node-html-parser": "^6.1.13",
"paneforge": "1.0.0-next.6",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.38.6",
"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": "^1.0.5",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^3.1.0",
"tailwindcss": "^4.1.18",
"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.3.8",
"typescript": "^5.9.2",
"typescript-eslint": "^8.42.0",
"unplugin-icons": "^22.2.0",
"vite": "^7.1.4",
"vite-plugin-devtools-json": "^1.0.0",
"vitest": "^3.2.4",
"typescript": "^5.8.3",
"unplugin-icons": "^22.1.0",
"vite": "^5.4.19",
"vitest": "^2.1.9",
"vitest-dom": "^0.1.1"
},
"dependencies": {
@@ -94,16 +91,14 @@
"@fontsource-variable/recursive": "^5.2.5",
"@fsegurai/codemirror-theme-vscode-dark": "^6.1.4",
"@fsegurai/codemirror-theme-vscode-light": "^6.1.4",
"@mermaid-js/examples": "^1.0.0",
"@mermaid-js/layout-elk": "^0.1.9",
"@mermaid-js/layout-tidy-tree": "^0.2.1",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"@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.7",
"lodash-es": "^4.17.21",
"mermaid": "^11.13.0",
"mermaid": "^11.6.0",
"mode-watcher": "^0.5.1",
"monaco-editor": "0.52.2",
"pako": "2.1.0",
@@ -116,7 +111,7 @@
"lint-staged": {
"*.{ts,svelte,js,css,md,json}": [
"prettier --plugin-search-dir=. --write",
"eslint "
"eslint --ignore-path .gitignore "
]
},
"engines": {
+1692 -2076
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;
}
+1
View File
@@ -5,6 +5,7 @@
<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." />
+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>
+123 -101
View File
@@ -2,13 +2,12 @@
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 { inputStateStore, stateStore, urlsStore } from '$lib/util/state';
@@ -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 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 {
svg: svgElement.cloneNode(true) as HTMLElement,
box: svgElement.querySelector('g')!.getBoundingClientRect()
};
} finally {
setTimeout(() => {
$inputStateStore.panZoom = true;
}, 10000);
}
};
const currentY = parseFloat(foreignObj.getAttribute('y') || '0');
const newHeight = currentHeight * 1.5;
const heightDiff = newHeight - currentHeight;
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}`;
}
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 [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 getSvgElement = () => {
const svgElement = document.querySelector('#container svg')?.cloneNode(true) as HTMLElement;
svgElement.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
return svgElement;
};
const getBase64SVG = (svg?: HTMLElement, width?: number, height?: number): string => {
if (svg) {
// 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();
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());
}
if ($stateStore.rough) {
fixForeignObjectClipping(svg);
}
svg.style.backgroundColor = window
.getComputedStyle(document.body)
.getPropertyValue('--background');
console.log(box);
// Prevents the SVG size of the interface from being changed
// 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) => {
$inputStateStore.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)();
$inputStateStore.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 (!$inputStateStore.panZoom) {
$inputStateStore.panZoom = true;
}
}, 2000);
event.stopPropagation();
event.preventDefault();
// setTimeout(() => {
// if (!$inputStateStore.panZoom) {
// }
// }, 2000);
};
const downloadImage: Exporter = (context, image) => {
@@ -211,8 +235,8 @@ ${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'
});
@@ -255,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>
@@ -286,24 +310,22 @@ ${svgString}`);
<div class="flex gap-2">
{@render dualActionButton('PNG', onDownloadPNG, $urlsStore.png)}
{@render dualActionButton('SVG', onDownloadSVG, $urlsStore.svg)}
<ExternalLinkWrapper domain={getDomain($urlsStore.kroki)} isVisible={!!$urlsStore.kroki}>
{#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($urlsStore.png)}
isVisible={!!$urlsStore.mdCode}>
{#if $urlsStore.mdCode}
<CopyInput value={$urlsStore.mdCode} label="Copy Markdown" testID={TID.copyMarkdown} />
</ExternalLinkWrapper>
{/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}
+1 -1
View File
@@ -28,7 +28,7 @@
<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"
+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>
+5 -138
View File
@@ -1,9 +1,6 @@
<script lang="ts">
import type { EditorProps } from '$/types';
import { env } from '$/util/env';
import { stateStore, urlsStore } from '$/util/state';
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,28 +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 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 jsonModel = monaco.editor.createModel(
'',
@@ -45,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) {
@@ -110,7 +52,7 @@
schemas: [
{
fileMatch: ['config.json'],
uri: `${env.docsUrl}/schemas/config.schema.json`
uri: 'https://mermaid.js.org/schemas/config.schema.json'
}
]
});
@@ -118,18 +60,6 @@
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) {
@@ -148,12 +78,6 @@
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
@@ -162,32 +86,14 @@
editor.setScrollTop(0);
editor.setValue(newText);
currentText = newText;
renderAIPromptGutterGlyphIcon();
}
// Display/clear errors
monaco.editor.setModelMarkers(model, 'mermaid', errorMarkers);
});
editor.onMouseMove((e) => {
if (!editor) return;
if (showPopup) return;
if (editor.getModel()?.id !== mermaidModel.id) return;
lastMouseLine = e.target.position?.lineNumber ?? 0;
renderAIPromptGutterGlyphIcon();
});
editor.onMouseLeave(() => {
lastMouseLine = 0;
renderAIPromptGutterGlyphIcon();
});
const unsubscribeMode = mode.subscribe((mode) => {
if (editor) {
monaco.editor.setTheme(`mermaid${mode === 'dark' ? '-dark' : ''}`);
divElement?.classList.toggle('mermaid-dark', mode === 'dark');
}
editor && monaco.editor.setTheme(`mermaid${mode === 'dark' ? '-dark' : ''}`);
});
const resizeObserver = new ResizeObserver((entries) => {
editor?.layout({
@@ -200,52 +106,13 @@
resizeObserver.observe(divElement);
}
renderAIPromptGutterGlyphIcon();
return () => {
unsubscribeState();
unsubscribeMode();
resizeObserver.disconnect();
jsonModel.dispose();
mermaidModel.dispose();
aiPromptManager.destroy();
editor?.dispose();
};
});
</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($urlsStore.mermaidChart({ medium: 'vibe_diagramming' }).save, '_blank');
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 { 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'
+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"
+13 -41
View File
@@ -7,11 +7,9 @@
import { TID } from '$/constants';
import { env } from '$/util/env';
import { stateStore, updateCode, updateConfig, urlsStore } from '$lib/util/state';
import { logMermaidChartClick } from '$lib/util/stats';
import { debounce } from 'lodash-es';
import ExclamationCircleIcon from '~icons/material-symbols/error-outline-rounded';
const { isMobile } = $props<{ isMobile: boolean }>();
let { isMobile }: { isMobile: boolean } = $props();
const onUpdate = (text: string) => {
if ($stateStore.editorMode === 'code') {
updateCode(text);
@@ -19,25 +17,6 @@
updateConfig(text);
}
};
let showError = $state(false);
const showErrorDebounced = debounce(() => {
showError = true;
}, 3000);
$effect(() => {
if ($stateStore.error) {
showErrorDebounced();
} else {
showErrorDebounced.cancel();
showError = false;
}
return () => {
showErrorDebounced.cancel();
};
});
</script>
<div class="flex h-full flex-col">
@@ -46,34 +25,27 @@
{:else}
<DesktopEditor {onUpdate} />
{/if}
{#if showError && $stateStore.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 && $stateStore.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 $stateStore.editorMode === 'code'}
<McWrapper>
<Button
variant="accent"
size="sm"
data-testid={TID.aiRepairButton}
href={$urlsStore.mermaidChart({ medium: 'ai_repair' }).save}
target="_blank"
onclick={() => logMermaidChartClick('aiRepair')}>
<MermaidChartIcon />
AI Repair
</Button>
</McWrapper>
{/if}
<McWrapper>
<Button
variant="accent"
size="sm"
href={$urlsStore.mermaidChart({ medium: 'ai_repair' }).save}>
<MermaidChartIcon />
AI Repair
</Button>
</McWrapper>
</div>
<output class="max-h-32 overflow-auto bg-muted p-2" name="mermaid-error" for="editor">
<pre>{$stateStore.error?.toString()}</pre>
@@ -1,118 +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 { stateStore, urlsStore } from '$/util/state';
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 = $stateStore.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;
}
let currentActionMedium = $state<EnhancedEditAction['medium'] | undefined>(undefined);
let previousDiagramType = $state<string | undefined>(undefined);
const availableActions = $derived.by<EnhancedEditAction[]>(() => {
if (!$stateStore.diagramType) {
return [];
}
const actions: EnhancedEditAction[] = [
{ campaign: 'ai_1', label: 'with AI', medium: 'ai_edit', source: 'aiEdit' },
{ campaign: 'voice_1', label: 'with Voice', medium: 'voice_edit', source: 'voiceEdit' }
];
if (showVisualEdit) {
actions.unshift({
campaign: 'visual_1',
label: 'Visually',
medium: 'visual_edit',
source: 'visualEdit'
});
}
return actions;
});
const currentAction = $derived.by(() => {
const actions = availableActions;
if (actions.length === 0) {
return undefined;
}
return actions.find(({ medium }) => medium === currentActionMedium) ?? actions[0];
});
$effect(() => {
const actions = availableActions;
if (!diagramType || actions.length === 0) {
return;
}
const hasCurrentAction = actions.some(({ medium }) => medium === currentActionMedium);
if (diagramType !== previousDiagramType || !hasCurrentAction) {
currentActionMedium = actions[Math.floor(Math.random() * actions.length)].medium;
previousDiagramType = diagramType;
}
});
$effect(() => {
if (diagramType) {
return;
}
previousDiagramType = undefined;
});
</script>
{#if currentAction}
<McWrapper>
<Button
variant="secondary"
size="sm"
href={$urlsStore.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 { 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,
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 && $stateStore.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;
+2 -2
View File
@@ -175,13 +175,13 @@
{: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 size="icon" variant="ghost" onclick={() => restoreHistoryItem(state)}>
+11 -10
View File
@@ -1,14 +1,15 @@
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
import type { HistoryEntry } from '$lib/types';
import { defaultState } from '$lib/util/state';
import { get } from 'svelte/store';
import { describe, expect, it } from 'vitest';
import { describe, it, expect } from 'vitest';
import {
addHistoryEntry,
injectHistoryIDs,
clearHistoryData,
historyModeStore,
historyStore,
injectHistoryIDs
historyStore
} from './history';
import { defaultState } from '$lib/util/state';
import { get } from 'svelte/store';
describe('history', () => {
it('should handle saving individual history entry', () => {
@@ -104,11 +105,11 @@ describe('history migration', () => {
'[{"state":{"code":"graph TD\\n A[New Year] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":false},"time":0,"type":"auto","name":"barking-dog"},{"state":{"code":"graph TD\\n A[Christmas] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":true},"time":0,"type":"manual","name":"needy-mosquito"}]'
);
let manualHistoryStore = JSON.parse(
window.localStorage.getItem('manualHistoryStore') ?? '[]'
) as HistoryEntry[],
autoHistoryStore = JSON.parse(
window.localStorage.getItem('autoHistoryStore') ?? '[]'
) as HistoryEntry[];
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);
+1
View File
@@ -147,5 +147,6 @@ export const injectHistoryIDs = (): void => {
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;
};
+44 -100
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 { urlsStore } from '$/util/state';
import { logMermaidChartClick } from '$/util/stats';
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: (item: Omit<MenuItem, 'renderer'>) => ReturnType<Snippet>;
}
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: $urlsStore.new, renderer: menuItem },
{ label: 'Duplicate', icon: DuplicateIcon, href: window.location.href, renderer: menuItem },
const mermaidChartMenuItems = $derived([
{
href: $urlsStore.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: $urlsStore.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: $urlsStore.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: MenuItem)}
{#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: MenuItem)}
<McWrapper
side="right"
labelPrefix={item.sharesData === false ? 'Opens a new tab in' : undefined}
sharesData={item.sharesData}
shouldCheckDiagramType={item.checkDiagramType}>
{@render menuItem(item)}
</McWrapper>
{/snippet}
{#snippet darkModeMenuItem(options: MenuItem)}
<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
)}>
<span class="flex items-center gap-2">
<ContrastIcon />
Dark Mode
</span>
<Switch
checked={$mode === 'dark'}
onCheckedChange={(dark) => setMode(dark ? 'dark' : 'light')} />
</div>
{/snippet}
<Popover.Root>
<Popover.Trigger class="shrink-0">
<MenuIcon class="size-6" />
<img class="size-6" src="/favicon.svg" alt="Mermaid Live Editor" />
</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)}
<Popover.Content align="start" class="flex flex-col overflow-hidden p-0" sideOffset={16}>
{#each menuItems as item}
{@render menuItem(item)}
{/each}
<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">
<ThemeIcon />
Dark Mode
</span>
<Switch
checked={$mode === 'dark'}
onCheckedChange={(dark) => setMode(dark ? 'dark' : 'light')} />
</div>
{#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>
+42 -16
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()}>
{@render children()}
</ExternalLinkWrapper>
{#if env.isEnabledMermaidChartLinks}
<Tooltip.Provider>
<Tooltip.Root delayDuration={100}>
<Tooltip.Trigger>
<div class={[shouldDisableComponent && 'pointer-events-none cursor-not-allowed grayscale']}>
{@render children()}
</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" />
+52 -16
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', {
@@ -9,11 +9,14 @@
<script lang="ts">
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 { 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 MermaidIcon from '~icons/custom/mermaid';
import CloseIcon from '~icons/material-symbols/close-rounded';
import GithubIcon from '~icons/mdi/github';
import DropdownNavMenu from './DropdownNavMenu.svelte';
@@ -21,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'];
@@ -40,23 +44,22 @@
}
];
let activePromotion = $state(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}
@@ -80,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="/" class="whitespace-nowrap text-accent">
{#if !mobileToggle}
Mermaid
{/if}
Live Editor
</a>
<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"
+178 -17
View File
@@ -1,12 +1,171 @@
<script lang="ts">
import Card from '$/components/Card/Card.svelte';
import { Button } from '$/components/ui/button';
import { getSampleDiagrams } from '$/util/mermaid';
import { updateCode } from '$lib/util/state';
import { logEvent } from '$lib/util/stats';
import ShapesIcon from '~icons/material-symbols/account-tree-outline-rounded';
const extras = {
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
@@ -33,8 +192,8 @@
`
};
const samples = { ...getSampleDiagrams(), ...extras } as const;
const loadSampleDiagram = (diagramType: string): void => {
type SampleTypes = keyof typeof samples;
const loadSampleDiagram = (diagramType: SampleTypes): void => {
updateCode(samples[diagramType], {
resetPanZoom: true,
updateDiagram: true
@@ -42,26 +201,28 @@
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)}
{#each diagramOrder as sample}
<Button
size="sm"
class="w-fit min-w-20 flex-grow normal-case"
+46 -59
View File
@@ -1,66 +1,53 @@
<script>
import ExternalLinkWrapper from '$/components/ExternalLinkWrapper.svelte';
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">
<Dialog.Root>
<Dialog.Trigger>
<ShieldIcon />
</a>
{:else}
<Dialog.Root>
<Dialog.Trigger>
<ShieldIcon />
</Dialog.Trigger>
<Dialog.Content class="max-h-full overflow-hidden overflow-y-auto p-12">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2 text-xl">
<ShieldIcon class="size-8 text-green-700" />
Data security
</Dialog.Title>
</Dialog.Header>
</Dialog.Trigger>
<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" />
Data security
</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>
This is a fully open source, client-side app deployed 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
>.
</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
>.
</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.
</p>
{/if}
</Dialog.Content>
</Dialog.Root>
{/if}
<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>
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>.
</p>
<p>
It will also work as a fully offline
<a href="https://web.dev/explore/progressive-web-apps" target="_blank">
Progressive Web App.
</a>
</p>
<p>
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>
</Dialog.Content>
</Dialog.Root>
+1 -2
View File
@@ -4,7 +4,6 @@
import { Separator } from '$/components/ui/separator';
import { env } from '$/util/env';
import { urlsStore } from '$/util/state';
import { asset } from '$app/paths';
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} />
@@ -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,13 +11,11 @@
<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>
<Button variant="ghost" size="icon" title="Privacy & Security">
<Privacy />
</Button>
<Separator orientation="vertical" />
{/if}
<Separator orientation="vertical" />
<Button
variant="ghost"
size="icon"
+3 -8
View File
@@ -4,7 +4,7 @@
import { render as renderDiagram } from '$/util/mermaid';
import { PanZoomState } from '$/util/panZoom';
import { inputStateStore, stateStore, updateCodeStore } from '$/util/state';
import { saveStatistics } from '$/util/stats';
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);
}
panZoomState.updateElement(graphDiv, state);
};
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) {
@@ -142,7 +138,6 @@
// Queue state changes to avoid race condition
let pendingStateChange = Promise.resolve();
stateStore.subscribe((state) => {
// eslint-disable-next-line @typescript-eslint/no-empty-function
pendingStateChange = pendingStateChange.then(() => handleStateChange(state).catch(() => {}));
});
});
@@ -1,164 +0,0 @@
<script lang="ts">
import { Button } from '$/components/ui/button';
import * as Dialog from '$/components/ui/dialog';
import { dismissEditorChooser } from '$/util/migration/domainMigration';
import { logEvent, logMermaidChartClick } from '$/util/stats';
import { getCheckoutUrl } from '$/util/util';
import CodeIcon from '~icons/custom/code';
import ArrowIcon from '~icons/material-symbols/arrow-forward-rounded';
import OpenSourceIcon from '~icons/material-symbols/book-2-outline-rounded';
import ChatIcon from '~icons/material-symbols/chat-outline-rounded';
import EditIcon from '~icons/material-symbols/edit-outline-rounded';
import HistoryIcon from '~icons/material-symbols/history';
import HomeIcon from '~icons/material-symbols/home-storage-outline-rounded';
import SparklesIcon from '~icons/material-symbols/kid-star-outline';
import LanguageIcon from '~icons/material-symbols/language';
import WidthIcon from '~icons/material-symbols/width-rounded';
interface Props {
open: boolean;
}
let { open = $bindable() }: Props = $props();
const close = () => {
dismissEditorChooser();
open = false;
};
const handleStartTrial = () => {
logEvent('chooseEditor', { choice: 'plus' });
logMermaidChartClick('editorPicker');
close();
window.open(
getCheckoutUrl({ utmCampaign: 'start_plus', utmMedium: '2_editor_selection' }),
'_blank'
);
};
const handleStartFree = () => {
logEvent('chooseEditor', { choice: 'openSource' });
close();
};
const handleContinueToNewHome = () => {
logEvent('chooseEditor', { choice: 'newHome' });
close();
window.open('https://mermaid.ai/live', '_blank');
};
</script>
<Dialog.Root
bind:open
onOpenChange={(v) => {
if (!v) handleStartFree();
}}>
<Dialog.Content class="flex max-w-2xl flex-col gap-6 bg-pink-50 p-6 dark:bg-background">
<Dialog.Header>
<Dialog.Title class="text-center text-2xl font-semibold">Choose your editor</Dialog.Title>
<Dialog.Description class="text-center text-sm font-light">
You'll never see this again
</Dialog.Description>
</Dialog.Header>
<div class="grid gap-4 sm:grid-cols-2">
<!-- Mermaid Plus Card -->
<div
class="relative flex flex-col overflow-hidden rounded-xl border-2 border-accent bg-white shadow dark:bg-card">
<div class="bg-accent px-6 py-2 text-center text-sm font-semibold text-accent-foreground">
Recommended
</div>
<div class="flex flex-col gap-4 p-6">
<div>
<h3 class="text-xl font-bold">Mermaid Plus</h3>
<p class="text-sm text-muted-foreground">Unlock AI, storage and collaboration</p>
</div>
<div class="flex flex-col gap-2">
<div class="flex justify-center">
<span
class="rounded-full bg-pink-100 px-3 py-0.5 text-xs font-semibold text-pink-700 dark:bg-pink-950 dark:text-pink-300">
10% off with code JS26
</span>
</div>
<Button variant="accent" class="w-full" onclick={handleStartTrial}>
Start free trial
</Button>
</div>
<ul class="space-y-3 text-sm">
<li class="flex items-center gap-2">
<EditIcon class="size-4 shrink-0 text-muted-foreground" />
Visual editor
</li>
<li class="flex items-center gap-2">
<SparklesIcon class="size-4 shrink-0 text-muted-foreground" />
300 AI credits
</li>
<li class="flex items-center gap-2">
<HomeIcon class="size-4 shrink-0 text-muted-foreground" />
Unlimited diagram storage
</li>
<li class="flex items-center gap-2">
<WidthIcon class="size-4 shrink-0 text-muted-foreground" />
Limitless diagram size
</li>
<li class="flex items-center gap-2">
<ChatIcon class="size-4 shrink-0 text-muted-foreground" />
View & comment collaboration
</li>
</ul>
</div>
</div>
<!-- Open Source Card -->
<div class="flex flex-col gap-4 rounded-xl border bg-white p-6 shadow dark:bg-card">
<div class="flex flex-col justify-end pt-10">
<h3 class="text-xl font-bold">Open Source</h3>
<p class="text-sm text-muted-foreground">Code only, no login, always free</p>
</div>
<div class="flex flex-col gap-2">
<p class="mt-2 text-sm text-muted-foreground">Mermaid has a new home</p>
<Button variant="outline" class="w-full border-accent" onclick={handleContinueToNewHome}>
Continue to mermaid.ai/live
<ArrowIcon class="ml-1 size-4" />
</Button>
<Button variant="outline" class="w-full" onclick={handleStartFree}>
Stay on mermaid.live
</Button>
</div>
<ul class="space-y-3 text-sm">
<li class="flex items-center gap-2">
<LanguageIcon class="size-4 shrink-0 text-muted-foreground" />
Diagram stored in URL
</li>
<li class="flex items-center gap-2">
<CodeIcon class="size-4 shrink-0 text-muted-foreground" />
Code editor
</li>
<li class="flex items-center gap-2">
<OpenSourceIcon class="size-4 shrink-0 text-muted-foreground" />
Open source
</li>
<li class="flex items-center gap-2">
<HistoryIcon class="size-4 shrink-0 text-muted-foreground" />
Version history
</li>
</ul>
</div>
</div>
<div class="text-center">
<a
href="https://mermaid.ai/privacy-policy"
target="_blank"
class="text-sm text-foreground underline hover:text-accent">
mermaid.ai Privacy Policy
</a>
</div>
</Dialog.Content>
</Dialog.Root>
+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}>
+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}>
@@ -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;
+7 -6
View File
@@ -1,3 +1,6 @@
/* 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 { addHistoryEntry } from '$lib/components/History/history';
import type { State } from '$lib/types';
@@ -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 : ''}`
);
+1 -2
View File
@@ -49,10 +49,9 @@ export const loadDataFromUrl = async (): Promise<void> => {
}
}
}
if (loaded) {
loaded &&
updateCodeStore({
...state,
updateDiagram: true
});
}
};
+1 -23
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 (
@@ -41,23 +39,3 @@ export const standardizeDiagramType = (diagramType: string) => {
}
}
};
type DiagramDefinition = (typeof diagramData)[number];
const isValidDiagram = (diagram: DiagramDefinition): diagram is Required<DiagramDefinition> => {
return Boolean(diagram.name && diagram.examples && diagram.examples.length > 0);
};
export const getSampleDiagrams = () => {
const diagrams = diagramData
.filter((d) => isValidDiagram(d))
.map(({ examples, ...rest }) => ({
...rest,
example: examples?.filter(({ isDefault }) => isDefault)[0]
}));
const examples: Record<string, string> = {};
for (const diagram of diagrams) {
examples[diagram.name.replace(/ (Diagram|Chart|Graph)/, '')] = diagram.example.code;
}
return examples;
};
-74
View File
@@ -1,74 +0,0 @@
import { C } from '$/constants';
import { env } from '$/util/env';
const mermaidAiDomain = 'mermaid.ai';
const mermaidLiveDomain = 'mermaid.live';
/**
* 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 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).
*/
export const shouldShowEditorChooser = (): boolean => {
if (!env.isEnabledMermaidChartLinks) return false;
if (!isOnMermaidAI() && !isOnMermaidLive()) return false;
if (window.innerWidth < 640) 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');
};
+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,
+3
View File
@@ -185,11 +185,13 @@ function getBrowserStorage(
}
};
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);
}
@@ -229,6 +231,7 @@ function getBrowserStorage(
* @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);
}
+51 -12
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,25 +66,33 @@
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);
@@ -78,9 +117,9 @@
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>
-7
View File
@@ -5,7 +5,6 @@ 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);
@@ -17,12 +16,6 @@ interface Promotion {
}
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'),
-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}`;
};
+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
};
+12 -24
View File
@@ -1,3 +1,4 @@
import { C } from '$/constants';
import type { ErrorHash, MarkerData, State, ValidatedState } from '$/types';
import { debounce } from 'lodash-es';
import type { MermaidConfig } from 'mermaid';
@@ -11,7 +12,7 @@ import {
import { parse } from './mermaid';
import { localStorage, persist } from './persist';
import { deserializeState, pakoSerde, serializeState } from './serde';
import { errorDebug, formatJSON, getUTMSource, MCBaseURL } from './util';
import { errorDebug, formatJSON, MCBaseURL } from './util';
export const defaultState: State = {
code: `flowchart TD
@@ -80,7 +81,7 @@ const processState = async (state: State) => {
processed.error = error as Error;
errorDebug();
console.error(error);
if (error && typeof error === 'object' && 'hash' in error) {
if ('hash' in error) {
try {
let errorString = processed.error.toString();
const errorLineText = extractErrorLineText(errorString);
@@ -88,6 +89,7 @@ const processState = async (state: State) => {
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);
@@ -130,32 +132,18 @@ export const stateStore: Readable<ValidatedState> = derived(
export const urlsStore = derived([stateStore], ([{ code, serialized }]) => {
const { krokiRendererUrl, rendererUrl } = env;
const png = rendererUrl ? `${rendererUrl}/img/${serialized}?type=png` : '';
const png = `${rendererUrl}/img/${serialized}?type=png`;
return {
kroki: krokiRendererUrl ? `${krokiRendererUrl}/mermaid/svg/${pakoSerde.serialize(code)}` : '',
mdCode: png
? `[![](${png})](${window.location.protocol}//${window.location.host}${window.location.pathname}#${serialized})`
: '',
kroki: `${krokiRendererUrl}/mermaid/svg/${pakoSerde.serialize(code)}`,
mdCode: `[![](${png})](${window.location.protocol}//${window.location.host}${window.location.pathname}#${serialized})`,
mermaidChart: ({
medium,
campaign
medium
}: {
medium:
| 'ai_repair'
| 'ai_edit'
| 'main_menu'
| 'save_diagram'
| 'share'
| 'vibe_diagramming'
| 'visual_edit'
| 'voice_edit';
campaign?: string;
medium: 'ai_repair' | 'main_menu' | 'save_diagram' | 'share' | 'toggle';
}) => {
const utmSource = getUTMSource();
const params = new URLSearchParams({
utm_source: utmSource,
utm_medium: medium,
...(campaign ? { utm_campaign: campaign } : {})
utm_source: C.utmSource,
utm_medium: medium
}).toString();
return {
save: `${MCBaseURL}/app/plugin/save?state=${serialized}&${params}`,
@@ -166,7 +154,7 @@ export const urlsStore = derived([stateStore], ([{ code, serialized }]) => {
},
new: `${window.location.protocol}//${window.location.host}${window.location.pathname}#${serializeState(defaultState)}`,
png,
svg: rendererUrl ? `${rendererUrl}/svg/${serialized}` : '',
svg: `${rendererUrl}/svg/${serialized}`,
view: `/view#${serialized}`
};
});
-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}/`);
});
});
+21 -33
View File
@@ -5,31 +5,20 @@ 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({
// All tracked stats are public and available at https://p.mermaid.live/mermaid.live
apiHost: env.analyticsUrl,
domain: env.domain,
hashMode: false
});
} catch (error) {
console.log(error);
console.info('Analytics blocked ;)');
}
}
try {
const { default: Plausible } = await import('plausible-tracker');
plausible = Plausible({
// All tracked stats are public and available at https://p.mermaid.live/mermaid.live
apiHost: env.analyticsUrl,
domain: env.domain,
hashMode: false
});
} catch (error) {
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,
@@ -120,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 });
};
+3 -37
View File
@@ -1,17 +1,9 @@
import { C } from '$/constants';
import { env } from './env';
import { loadDataFromUrl } from './fileLoaders/loader';
import { initLoading } from './loading';
import { isOnMermaidAI } from './migration/domainMigration';
import { applyMigrations } from './migrations';
import { initURLSubscription, loadState, updateCodeStore, verifyState } from './state';
import { getAnalyticsSafeUrl, initAnalytics, plausible } from './stats';
export const getDomain = (url?: string): string => {
if (!url) return '';
const domain = new URL(url).hostname;
return domain;
};
import { initAnalytics, plausible } from './stats';
export const loadStateFromURL = (): void => {
loadState(window.location.hash.slice(1));
@@ -30,35 +22,16 @@ export const initHandler = async (): Promise<void> => {
syncDiagram();
initURLSubscription();
await initAnalytics();
plausible?.trackPageview({
url: getAnalyticsSafeUrl()
});
plausible?.trackPageview({ url: window.location.origin + window.location.pathname });
verifyState();
};
export const isMac = navigator.platform.toUpperCase().includes('MAC');
export const cmdKey = isMac ? 'Cmd' : 'Ctrl';
export const MCBaseURL = env.isEnabledMermaidChartLinks
? 'https://mermaid.ai' // 'http://localhost:5174'
? 'https://mermaidchart.com' // 'http://localhost:5174'
: 'https://example.com';
export const getCheckoutUrl = ({
utmCampaign,
utmMedium
}: {
utmCampaign: string;
utmMedium: string;
}): string => {
const params = new URLSearchParams({
coupon: 'arDfyFT8',
tier: 'plus',
utm_campaign: utmCampaign,
utm_medium: utmMedium,
utm_source: getUTMSource()
});
return `${MCBaseURL}/app/user/billing/checkout?${params.toString()}`;
};
let count = 0;
export const errorDebug = (limit = 1000) => {
count += 1;
@@ -109,10 +82,3 @@ function fallbackCopyToClipboard(text: string) {
textArea.remove();
}
}
export const getUTMSource = (): string => {
if (typeof window !== 'undefined' && isOnMermaidAI()) {
return C.aiLiveEditor;
}
return C.utmSource;
};
-7
View File
@@ -4,10 +4,3 @@ import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, 'child'> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, 'children'> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
+6 -5
View File
@@ -6,7 +6,7 @@
import { base } from '$app/paths';
import { mode, ModeWatcher } from 'mode-watcher';
import { onMount, type Snippet } from 'svelte';
import '../app.css';
import '../app.postcss';
interface Props {
children: Snippet;
@@ -20,10 +20,11 @@
window.addEventListener('hashchange', () => {
void initHandler();
});
if ('serviceWorker' in navigator) {
navigator.serviceWorker
.register(`${base}/service-worker.js`, { scope: `${base}/` })
.register(`${base}/service-worker.js`, {
scope: `${base}/`
})
.then(function (registration) {
console.log('Registration successful, scope is:', registration.scope);
})
@@ -41,13 +42,13 @@
<ModeWatcher />
<Toaster />
<main class="h-dvh">
<main class="h-[100dvh]">
{@render children()}
</main>
{#if $loadingStateStore.loading}
<div
class="absolute top-0 left-0 z-50 flex h-screen w-screen justify-center bg-gray-600 align-middle opacity-50">
class="absolute left-0 top-0 z-50 flex h-screen w-screen justify-center bg-gray-600 align-middle opacity-50">
<div class="my-auto text-4xl font-bold text-indigo-100">
<div class="loader mx-auto"></div>
<div>{$loadingStateStore.message}</div>
+8 -2
View File
@@ -1,10 +1,16 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { buildRedirectUrl } from '$lib/util/redirect';
import { onMount } from 'svelte';
import { base } from '$app/paths';
onMount(async () => {
await goto(buildRedirectUrl(window.location), {
// Handle old live editor links and redirect to new version
const hash = window.location.hash.split('/');
let newURL = 'edit';
if (hash.length > 2) {
newURL = `${hash[1]}#${hash[2]}`;
}
await goto(`${base}/${newURL}`, {
replaceState: true
});
});
+8 -14
View File
@@ -3,11 +3,9 @@
import Card from '$/components/Card/Card.svelte';
import DiagramDocButton from '$/components/DiagramDocumentationButton.svelte';
import Editor from '$/components/Editor.svelte';
import EnhancedEditsButton from '$/components/EnhancedEditsButton.svelte';
import History from '$/components/History/History.svelte';
import McWrapper from '$/components/McWrapper.svelte';
import MermaidChartIcon from '$/components/MermaidChartIcon.svelte';
import EditorChooserModal from '$/components/migration/EditorChooserModal.svelte';
import Navbar from '$/components/Navbar.svelte';
import PanZoomToolbar from '$/components/PanZoomToolbar.svelte';
import Preset from '$/components/Preset.svelte';
@@ -20,10 +18,9 @@
import VersionSecurityToolbar from '$/components/VersionSecurityToolbar.svelte';
import View from '$/components/View.svelte';
import type { EditorMode, Tab } from '$/types';
import { shouldShowEditorChooser } from '$/util/migration/domainMigration';
import { PanZoomState } from '$/util/panZoom';
import { stateStore, updateCodeStore, urlsStore } from '$/util/state';
import { logEvent, logMermaidChartClick } from '$/util/stats';
import { logEvent } from '$/util/stats';
import { initHandler } from '$/util/util';
import { onMount } from 'svelte';
import CodeIcon from '~icons/custom/code';
@@ -53,10 +50,8 @@
let width = $state(0);
let isMobile = $derived(width < 640);
let isViewMode = $state(true);
let showEditorChooser = $state(false);
onMount(async () => {
showEditorChooser = shouldShowEditorChooser();
await initHandler();
window.addEventListener('appinstalled', () => {
logEvent('pwaInstalled', { isMobile });
@@ -96,8 +91,7 @@
variant="accent"
size="sm"
href={$urlsStore.mermaidChart({ medium: 'save_diagram' }).save}
target="_blank"
onclick={() => logMermaidChartClick('saveDiagram')}>
target="_blank">
<MermaidChartIcon />
Save diagram
</Button>
@@ -137,14 +131,16 @@
<Resizable.Handle class="mr-1 hidden opacity-0 sm:block" />
<Resizable.Pane minSize={15} class="relative flex h-full flex-1 flex-col overflow-hidden">
<View {panZoomState} shouldShowGrid={$stateStore.grid} />
<div class="absolute top-0 left-5 hidden md:block"><EnhancedEditsButton /></div>
<div class="absolute top-0 right-0"><PanZoomToolbar {panZoomState} /></div>
<div class="absolute right-0 bottom-0"><VersionSecurityToolbar /></div>
<div class="absolute right-0 top-0"><PanZoomToolbar {panZoomState} /></div>
<div class="absolute bottom-0 right-0"><VersionSecurityToolbar /></div>
<div class="absolute bottom-0 left-0 sm:left-5"><SyncRoughToolbar /></div>
</Resizable.Pane>
{#if isHistoryOpen}
<Resizable.Handle class="ml-1 hidden opacity-0 sm:block" />
<Resizable.Pane minSize={15} defaultSize={30} class="hidden h-full grow flex-col sm:flex">
<Resizable.Pane
minSize={15}
defaultSize={30}
class="hidden h-full flex-grow flex-col sm:flex">
<History />
</Resizable.Pane>
{/if}
@@ -152,5 +148,3 @@
</div>
</div>
</div>
<EditorChooserModal bind:open={showEditorChooser} />
-4
View File
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg viewBox="40 40 410 410" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M407.48,111.18C335.587,108.103 269.573,152.338 245.08,220C220.587,152.338 154.573,108.103 82.68,111.18C80.285,168.229 107.577,222.632 154.74,254.82C178.908,271.419 193.35,298.951 193.27,328.27L193.27,379.13L296.9,379.13L296.9,328.27C296.816,298.953 311.255,271.42 335.42,254.82C382.596,222.644 409.892,168.233 407.48,111.18Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 689 B

-5
View File
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg viewBox="0 0 490 490" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<path d="M490.16,84.61C490.16,37.912 452.248,0 405.55,0L84.61,0C37.912,0 0,37.912 0,84.61L0,405.55C0,452.248 37.912,490.16 84.61,490.16L405.55,490.16C452.248,490.16 490.16,452.248 490.16,405.55L490.16,84.61Z" style="fill:#ff3670;"/>
<path d="M407.48,111.18C335.587,108.103 269.573,152.338 245.08,220C220.587,152.338 154.573,108.103 82.68,111.18C80.285,168.229 107.577,222.632 154.74,254.82C178.908,271.419 193.35,298.951 193.27,328.27L193.27,379.13L296.9,379.13L296.9,328.27C296.816,298.953 311.255,271.42 335.42,254.82C382.596,222.644 409.892,168.233 407.48,111.18Z" style="fill:white;fill-rule:nonzero;"/>
</svg>

Before

Width:  |  Height:  |  Size: 942 B

-9
View File
@@ -1,9 +0,0 @@
<svg id="mySvg" width="64" height="64" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"
aria-hidden="true" role="img">
<path class="p1"
d="M19 4C19 6.50909 19.8727 8.63636 21.6182 10.3818C23.3636 12.1273 25.4909 13 28 13C25.4909 13 23.3636 13.8727 21.6182 15.6182C19.8727 17.3636 19 19.4909 19 22C19 19.4909 18.1273 17.3636 16.3818 15.6182C14.6364 13.8727 12.5091 13 10 13C12.5091 13 14.6364 12.1273 16.3818 10.3818C18.1273 8.63636 19 6.50909 19 4Z"
fill="white" />
<path class="p2"
d="M8.5 16C8.5 17.2545 8.93636 18.3182 9.80909 19.1909C10.6818 20.0636 11.7455 20.5 13 20.5C11.7455 20.5 10.6818 20.9364 9.80909 21.8091C8.93636 22.6818 8.5 23.7455 8.5 25C8.5 23.7455 8.06364 22.6818 7.19091 21.8091C6.31818 20.9364 5.25455 20.5 4 20.5C5.25455 20.5 6.31818 20.0636 7.19091 19.1909C8.06364 18.3182 8.5 17.2545 8.5 16Z"
fill="white" />
</svg>

Before

Width:  |  Height:  |  Size: 879 B

-9
View File
@@ -1,9 +0,0 @@
<svg id="mySvg" width="64" height="64" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg"
aria-hidden="true" role="img">
<path class="p1"
d="M19 4C19 6.50909 19.8727 8.63636 21.6182 10.3818C23.3636 12.1273 25.4909 13 28 13C25.4909 13 23.3636 13.8727 21.6182 15.6182C19.8727 17.3636 19 19.4909 19 22C19 19.4909 18.1273 17.3636 16.3818 15.6182C14.6364 13.8727 12.5091 13 10 13C12.5091 13 14.6364 12.1273 16.3818 10.3818C18.1273 8.63636 19 6.50909 19 4Z"
fill="black" />
<path class="p2"
d="M8.5 16C8.5 17.2545 8.93636 18.3182 9.80909 19.1909C10.6818 20.0636 11.7455 20.5 13 20.5C11.7455 20.5 10.6818 20.9364 9.80909 21.8091C8.93636 22.6818 8.5 23.7455 8.5 25C8.5 23.7455 8.06364 22.6818 7.19091 21.8091C6.31818 20.9364 5.25455 20.5 4 20.5C5.25455 20.5 6.31818 20.0636 7.19091 19.1909C8.06364 18.3182 8.5 17.2545 8.5 16Z"
fill="black" />
</svg>

Before

Width:  |  Height:  |  Size: 879 B

+5 -5
View File
@@ -1,19 +1,19 @@
import adapter from '@sveltejs/adapter-static';
import 'dotenv/config';
import { sveltePreprocess } from 'svelte-preprocess';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://github.com/sveltejs/svelte-preprocess
// for more information about preprocessors
preprocess: [sveltePreprocess({})],
preprocess: [
sveltePreprocess({
postcss: true
})
],
kit: {
alias: {
'$/*': './src/lib/*'
},
paths: {
base: process.env.MERMAID_BASE_PATH ?? ''
},
adapter: adapter({
pages: 'docs',
fallback: '404.html'
+96
View File
@@ -0,0 +1,96 @@
import tailwindcssAnimate from 'tailwindcss-animate';
import { fontFamily } from 'tailwindcss/defaultTheme';
/** @type {import('tailwindcss').Config} */
const config = {
darkMode: ['class'],
content: ['./src/**/*.{html,js,svelte,ts}'],
safelist: ['dark'],
theme: {
container: {
center: true,
padding: '2rem',
screens: {
'2xl': '1400px'
}
},
extend: {
colors: {
border: 'hsl(var(--border) / <alpha-value>)',
input: 'hsl(var(--input) / <alpha-value>)',
ring: 'hsl(var(--ring) / <alpha-value>)',
background: 'hsl(var(--background) / <alpha-value>)',
foreground: 'hsl(var(--foreground) / <alpha-value>)',
primary: {
DEFAULT: 'hsl(var(--primary) / <alpha-value>)',
foreground: 'hsl(var(--primary-foreground) / <alpha-value>)'
},
secondary: {
DEFAULT: 'hsl(var(--secondary) / <alpha-value>)',
foreground: 'hsl(var(--secondary-foreground) / <alpha-value>)'
},
destructive: {
DEFAULT: 'hsl(var(--destructive) / <alpha-value>)',
foreground: 'hsl(var(--destructive-foreground) / <alpha-value>)'
},
muted: {
DEFAULT: 'hsl(var(--muted) / <alpha-value>)',
foreground: 'hsl(var(--muted-foreground) / <alpha-value>)'
},
accent: {
DEFAULT: 'hsl(var(--accent) / <alpha-value>)',
foreground: 'hsl(var(--accent-foreground) / <alpha-value>)'
},
popover: {
DEFAULT: 'hsl(var(--popover) / <alpha-value>)',
foreground: 'hsl(var(--popover-foreground) / <alpha-value>)'
},
card: {
DEFAULT: 'hsl(var(--card) / <alpha-value>)',
foreground: 'hsl(var(--card-foreground) / <alpha-value>)'
},
sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))',
foreground: 'hsl(var(--sidebar-foreground))',
primary: 'hsl(var(--sidebar-primary))',
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))',
accent: 'hsl(var(--sidebar-accent))',
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))',
border: 'hsl(var(--sidebar-border))',
ring: 'hsl(var(--sidebar-ring))'
}
},
borderRadius: {
xl: 'calc(var(--radius) + 4px)',
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)'
},
fontFamily: {
sans: [...fontFamily.sans]
},
keyframes: {
'accordion-down': {
from: { height: '0' },
to: { height: 'var(--bits-accordion-content-height)' }
},
'accordion-up': {
from: { height: 'var(--bits-accordion-content-height)' },
to: { height: '0' }
},
'caret-blink': {
'0%,70%,100%': { opacity: '1' },
'20%,50%': { opacity: '0' }
}
},
animation: {
'accordion-down': 'accordion-down 0.2s ease-out',
'accordion-up': 'accordion-up 0.2s ease-out',
'caret-blink': 'caret-blink 1.25s ease-out infinite'
}
}
},
plugins: [tailwindcssAnimate]
};
export default config;
+6 -5
View File
@@ -8,7 +8,8 @@ test.describe('Check actions', () => {
test('should update markdown code', async ({ editPage }) => {
const oldText = await editPage.markdownInput.inputValue();
await editPage.typeInEditor('C --> HistoryTest', { bottom: true, newline: true });
await expect(editPage.markdownInput).not.toHaveValue(oldText);
const newText = await editPage.markdownInput.inputValue();
expect(newText).not.toBe(oldText);
});
test.skip('should load gists from URL', async ({ page }) => {
@@ -20,15 +21,15 @@ test.describe('Check actions', () => {
});
test('should download png and svg', async ({ editPage }) => {
const firstPngSize = await editPage.checkAndDownloadPNG(20_000);
const firstPngSize = await editPage.checkAndDownloadPNG(80_000);
const firstSvgSize = await editPage.downloadSVG(10_000);
// Verify downloaded file is different for different diagrams
await editPage.toggleSampleDiagrams();
await editPage.loadSampleDiagram('Entity Relationship');
await editPage.loadSampleDiagram('ER');
const secondPngSize = await editPage.checkAndDownloadPNG(20_000);
const secondSvgSize = await editPage.downloadSVG(10_000);
const secondPngSize = await editPage.checkAndDownloadPNG(81_000);
const secondSvgSize = await editPage.downloadSVG(11_000);
// Verify files are actually different
expect(firstPngSize).not.toBe(secondPngSize);
-6
View File
@@ -4,9 +4,6 @@ test.describe('Auto sync tests', () => {
test('should automatically defer rendering when complex diagrams are edited', async ({
editPage
}) => {
test.slow(); // Complex diagram rendering can be slow under parallel load
// Wait for the default diagram to fully render before typing
await editPage.checkTextInView('Christmas');
await editPage.typeInEditor(
`
A & B & C & D & E --> F & G & H & I & J & K
@@ -30,12 +27,9 @@ A & B & C & D & E --> F & G & H & I & J & K & LongTest`
});
test('supports editing code when code is incorrect', async ({ editPage }) => {
test.slow(); // Error display has a 5s debounce; rendering pipeline adds more delay under load
await editPage.start(
'/edit#pako:eNpljjEKwzAMRa8SNOcEnlt6gK5eVFvYJsgOqkwpIXevg9smEE1PnyfxF3DFExgISW-CczQ2D21cYU7a-SGYXRwyvTp9jUhuKlVP-eHy7zA-leQsMEmg_QOM0BLG5FujZVMsaCQmC6ahR5ks2Lw2r84ela4-aREwKpVGwKrl_s7ut3fnkjAIcg_XDzuaUhs'
);
// Wait for the gitGraph code to load in the editor before typing
await editPage.checkInEditor('gitGraph');
await editPage.typeInEditor('branch test', { bottom: true, newline: true });
await editPage.checkTextNotInView('test');
await editPage.checkInEditor('branch test');
+1 -1
View File
@@ -12,7 +12,7 @@ test.describe('Editor docs tests', () => {
test('Test to see if the correct URL loads when changing from one diagram to other', async ({
editPage
}) => {
await editPage.loadSampleDiagram('Flowchart');
await editPage.loadSampleDiagram('Flow');
await editPage.checkDocURL(/syntax\/flowchart\.html/);
await editPage.setEditorMode('Config');
await editPage.checkDocURL(/syntax\/flowchart\.html#configuration/);
-34
View File
@@ -1,34 +0,0 @@
import { test } from './test';
test.describe('Error display tests', () => {
test('should show AI Repair button for syntax errors in Code tab', async ({ editPage }) => {
// Enter code with syntax error
await editPage.clearEditor();
await editPage.typeInEditor('graph TD\nA --> B -->');
// Verify error is displayed
await editPage.checkError('Syntax error');
// Verify AI Repair button and help text is shown in Code tab
await editPage.checkAIHelperVisibility(true);
});
test('should not show AI Repair button for errors in Config tab', async ({ editPage }) => {
// First enter valid diagram
await editPage.clearEditor();
await editPage.typeInEditor('graph TD\nA --> B');
// Switch to Config tab
await editPage.setEditorMode('Config');
// Enter invalid JSON in config
await editPage.clearEditor();
await editPage.typeInEditor('{\n "theme": "default",\n invalid json');
// Verify error is displayed
await editPage.checkError('Syntax error');
// Verify AI Repair button and help text is NOT shown in Config tab
await editPage.checkAIHelperVisibility(false);
});
});
+2 -16
View File
@@ -1,4 +1,4 @@
import { C, TID } from '$/constants';
import { TID } from '$/constants';
import { test as base, expect, type Locator, type Page } from '@playwright/test';
import { verifyFileSizeGreaterThan, type EditorOptions } from './utils';
@@ -17,7 +17,6 @@ export class EditorPage {
await this.page.goto(url);
await expect(this.page)
.toHaveURL(/.*\/edit#pako/)
// eslint-disable-next-line @typescript-eslint/no-empty-function
.catch(() => {});
}
@@ -71,9 +70,7 @@ export class EditorPage {
}
async checkError(text: string) {
await expect(this.page.getByTestId(TID.errorContainer)).toContainText(text, {
timeout: 10_000
});
await expect(this.page.getByTestId(TID.errorContainer)).toContainText(text);
}
async checkInEditor(text: string) {
@@ -106,21 +103,10 @@ export class EditorPage {
`Switch to ${theme === 'light' ? 'dark' : 'light'} theme`
);
}
async checkAIHelperVisibility(shouldBeVisible: boolean) {
const button = this.page.getByTestId(TID.aiRepairButton);
const helpText = this.page.getByTestId(TID.aiHelpText);
await expect(button)[shouldBeVisible ? 'toBeVisible' : 'toBeHidden']();
await expect(helpText)[shouldBeVisible ? 'toBeVisible' : 'toBeHidden']();
}
}
export const test = base.extend<{ editPage: EditorPage }>({
editPage: async ({ page }, use) => {
// Dismiss the editor chooser modal so it doesn't block interactions
await page.addInitScript((key) => {
window.localStorage.setItem(key, 'true');
}, C.editorChooserDismissedKey);
const editorPage = new EditorPage(page);
await editorPage.start();
await editorPage.toggleSampleDiagrams();
+6 -3
View File
@@ -8,10 +8,12 @@ export interface EditorOptions {
newline?: boolean;
}
interface WindowWithEditor {
editorLoaded: boolean;
}
declare global {
interface Window {
editorLoaded: boolean;
}
interface Window extends WindowWithEditor {}
}
export async function typeInEditor(
@@ -42,6 +44,7 @@ export async function verifyFileSizeGreaterThan(
if (!path) throw new Error('Download path not available');
const fileSize = statSync(path).size;
expect(fileSize).toBeGreaterThan(size);
expect(fileSize).toBeLessThan(size * 2);
return fileSize;
}
+14 -12
View File
@@ -1,9 +1,7 @@
import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from '@sveltejs/kit/vite';
import { FileSystemIconLoader } from 'unplugin-icons/loaders';
import Icons from 'unplugin-icons/vite';
import { defineConfig } from 'vite';
import devtoolsJson from 'vite-plugin-devtools-json';
import { defineConfig } from 'vitest/config';
/**
* HMR creates state inconsistencies, so we always reload the page.
@@ -19,7 +17,6 @@ const alwaysFullReload = {
export default defineConfig({
plugins: [
tailwindcss(),
sveltekit(),
Icons({
compiler: 'svelte',
@@ -27,12 +24,17 @@ export default defineConfig({
custom: FileSystemIconLoader('./static/icons')
}
}),
alwaysFullReload,
devtoolsJson()
alwaysFullReload
],
envPrefix: 'MERMAID_',
server: { port: 3000, host: true },
preview: { port: 3000, host: true },
server: {
port: 3000,
host: true
},
preview: {
port: 3000,
host: true
},
test: {
environment: 'jsdom',
// in-source testing
@@ -40,10 +42,10 @@ export default defineConfig({
// Ignore E2E tests
exclude: [
'tests/**/*',
'**/node_modules/**',
'**/dist/**',
'**/.{idea,git,cache,output,temp}/**',
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*'
'**\/node_modules/**',
'**\/dist/**',
'**\/.{idea,git,cache,output,temp}/**',
'**\/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*'
],
setupFiles: ['./src/tests/setup.ts'],
coverage: {