Merge branch 'develop' into pr/aloisklink/1496

* develop: (101 commits)
  fix: Flickering when rendering, PanZoom & Rough mode combined
  chore: Simplify queueing logic
  fix: Blank screen on initial load
  chore: Add delay for playground toggle
  fix: Record correct diagram type, send playground toggle event immediately.
  chore(deps): update dependency autoprefixer to v10.4.21
  chore(deps): update all non-major dependencies
  chore(deps): update dependency typescript to v5.8.2
  chore(deps): update all non-major dependencies
  build: update Browserslist db
  Use netlify to redirect users
  chore: Add sitemap to robots.txt
  fix: Add redirect for /index.html
  chore: Add sitemap
  fix: Do not index /view page
  chore(deps): update all non-major dependencies
  feat: Pause banner animation when mouse is on the banner
  fix: Layout for banner
  use snippets
  update urls and styling
  ...
This commit is contained in:
Sidharth Vinod
2025-03-14 18:08:52 -07:00
44 changed files with 2309 additions and 2212 deletions

9
.env
View File

@@ -1,2 +1,7 @@
MERMAID_DOMAIN="mermaid.live"
MERMAID_ANALYTICS_URL="https://p.mermaid.live"
MERMAID_DOMAIN=''
MERMAID_ANALYTICS_URL=''
MERMAID_RENDERER_URL='https://mermaid.ink'
MERMAID_KROKI_RENDERER='https://kroki.io'
MERMAID_IS_ENABLED_MERMAID_CHART_LINKS=''
# cp .env .env.local to make local changes

View File

@@ -86,6 +86,7 @@ module.exports = {
j: true,
k: true,
param: true,
Props: true,
req: true,
res: true,
str: true,

View File

@@ -23,6 +23,9 @@ jobs:
env:
MERMAID_DOMAIN: 'mermaid.live'
MERMAID_ANALYTICS_URL: 'https://p.mermaid.live'
MERMAID_RENDERER_URL: 'https://mermaid.ink'
MERMAID_KROKI_RENDERER_URL: 'https://kroki.io'
MERMAID_IS_ENABLED_MERMAID_CHART_LINKS: 'true'
run: |
export DEPLOY=true
[ "$GITHUB_EVENT_NAME" != "pull_request" ] && rm -rf docs/_app/

View File

@@ -17,9 +17,9 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v4
- uses: actions/cache@v3
- uses: actions/cache@v4
id: yarn-and-build-cache
with:
path: |
@@ -30,21 +30,20 @@ jobs:
restore-keys: |
${{ runner.os }}-node_modules-build-
- uses: actions/setup-node@v3
- uses: actions/setup-node@v4
with:
node-version: 18
node-version-file: '.node-version'
cache: 'yarn'
# Install NPM dependencies, cache them correctly
# and run all Cypress tests
- name: Cypress run
uses: cypress-io/github-action@v3
uses: cypress-io/github-action@v6
with:
build: yarn build
start: yarn preview
wait-on: 'http://localhost:3000'
record: true
headless: true
parallel: true
env:
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}

1
.gitignore vendored
View File

@@ -12,3 +12,4 @@ yarn-error.log
/cypress/downloads
/cypress/videos
/cypress/screenshots
.env.local

View File

@@ -1 +1 @@
20.15.1
20.19.0

View File

@@ -18,6 +18,7 @@ ARG MERMAID_RENDERER_URL
ARG MERMAID_KROKI_RENDERER_URL
ARG MERMAID_ANALYTICS_URL
ARG MERMAID_DOMAIN
ARG MERMAID_IS_ENABLED_MERMAID_CHART_LINKS
COPY . ./

View File

@@ -29,13 +29,18 @@ docker run --platform linux/amd64 --publish 8000:8080 ghcr.io/mermaid-js/mermaid
### To configure renderer URL
When building set the MERMAID_RENDERER_URL build argument to the rendering service.
Default is `https://mermaid.ink`
When building set the MERMAID_RENDERER_URL build argument to the rendering
service.
Example:
Default is`https://mermaid.ink`.
Set to empty string to disable PNG and SVG links under Actions
### To configure Kroki Instance URL
When building set the MERMAID_KROKI_RENDERER_URL build argument to your Kroki instance.
When building set the MERMAID_KROKI_RENDERER_URL build argument to your Kroki
instance.
Default is `https://kroki.io`
Set to empty string to disable Kroki link under Actions
### To configure Analytics
@@ -43,6 +48,18 @@ When building set the MERMAID_ANALYTICS_URL build argument to your plausible ins
Default is empty, disabling analytics.
### To enable Mermaid Chart links and promotion
When building set the MERMAID_IS_ENABLED_MERMAID_CHART_LINKS build argument to `true`
Default is empty, disabling button to save to Mermaid Chart and promotional banner.
### To update the Security modal
The modal shown on clicking the security link assumes analytics, renderer, Kroki
and Mermaid chart are enabled. You can update it by modifying `Privacy.svelte`
if you wish.
### Development
```bash

View File

@@ -37,7 +37,7 @@ describe('Check actions', () => {
cy.contains('ER').click();
cy.get(`#downloadPNG`).click();
verifyFileSizeGreaterThan('diagram', 'png', 40_000);
verifyFileSizeGreaterThan('diagram', 'png', 35_000);
cy.get(`#downloadSVG`).click();
verifyFileSizeGreaterThan('diagram', 'svg', 11_000);

View File

@@ -36,7 +36,7 @@ export const verifyFileSizeGreaterThan = (
mode: 'size'
}).then((fileSize: number) => {
expect(fileSize).to.be.gt(size);
expect(fileSize).to.be.lt(size * 1.3);
expect(fileSize).to.be.lt(size * 2);
});
};

View File

@@ -1,7 +1,7 @@
module.exports = {
"Site Loads": {
"Check Home page load": {
"1": "{\"code\":\"flowchart TD\\n A[Christmas] -->|Get money| B(Go shopping)\\n B --> C{Let me think}\\n C -->|One| D[Laptop]\\n C -->|Two| E[iPhone]\\n C -->|Three| F[fa:fa-car Car]\\n \",\"mermaid\":\"{\\n \\\"theme\\\": \\\"default\\\"\\n}\",\"autoSync\":true,\"updateDiagram\":true}"
"1": "{\"code\":\"flowchart TD\\n A[Christmas] -->|Get money| B(Go shopping)\\n B --> C{Let me think}\\n C -->|One| D[Laptop]\\n C -->|Two| E[iPhone]\\n C -->|Three| F[fa:fa-car Car]\\n \",\"mermaid\":\"{\\n \\\"theme\\\": \\\"default\\\"\\n}\",\"autoSync\":true,\"rough\":false,\"updateDiagram\":true,\"panZoom\":false}"
},
"Check Redirect from old URL": {
"1": "{\"code\":\"graph TD\\n A[Christmas] -->|Get money| B(Go shopping)\\n B --> C{Let me think}\\n C -->|One| D[Laptop]\\n C -->|Two| E[iPhone]\\n C -->|Three| F[fa:fa-car Car]\",\"mermaid\":\"{\\n \\\"theme\\\": \\\"default\\\"\\n}\",\"autoSync\":true,\"updateDiagram\":true}"
@@ -19,10 +19,10 @@ module.exports = {
"__version": "12.17.4",
"Auto sync tests": {
"should dim diagram when code is edited": {
"1": "{\"code\":\"flowchart TD\\n A[Christmas] -->|Get money| B(Go shopping)\\n B --> C{Let me think}\\n C -->|One| D[Laptop]\\n C -->|Two| E[iPhone]\\n C -->|Three| F[fa:fa-car Car]\\n C --> Test\",\"mermaid\":\"{\\n \\\"theme\\\": \\\"default\\\"\\n}\",\"autoSync\":false,\"updateDiagram\":false}"
"1": "{\"code\":\"flowchart TD\\n A[Christmas] -->|Get money| B(Go shopping)\\n B --> C{Let me think}\\n C -->|One| D[Laptop]\\n C -->|Two| E[iPhone]\\n C -->|Three| F[fa:fa-car Car]\\n C --> Test\",\"mermaid\":\"{\\n \\\"theme\\\": \\\"default\\\"\\n}\",\"autoSync\":false,\"rough\":false,\"updateDiagram\":false,\"panZoom\":false}"
},
"should not dim diagram when code is in sync": {
"1": "{\"code\":\"flowchart TD\\n A[Christmas] -->|Get money| B(Go shopping)\\n B --> C{Let me think}\\n C -->|One| D[Laptop]\\n C -->|Two| E[iPhone]\\n C -->|Three| F[fa:fa-car Car]\\n C --> Testing\",\"mermaid\":\"{\\n \\\"theme\\\": \\\"default\\\"\\n}\",\"autoSync\":true,\"updateDiagram\":false}"
"1": "{\"code\":\"flowchart TD\\n A[Christmas] -->|Get money| B(Go shopping)\\n B --> C{Let me think}\\n C -->|One| D[Laptop]\\n C -->|Two| E[iPhone]\\n C -->|Three| F[fa:fa-car Car]\\n C --> Testing\",\"mermaid\":\"{\\n \\\"theme\\\": \\\"default\\\"\\n}\",\"autoSync\":true,\"rough\":false,\"updateDiagram\":false,\"panZoom\":false}"
}
},
"Test themes": {

View File

@@ -1,3 +1,12 @@
[build.environment]
MERMAID_ANALYTICS_URL = 'https://p.mermaid.live'
MERMAID_DOMAIN = 'mermaid.live'
MERMAID_RENDERER_URL = 'https://mermaid.ink'
MERMAID_KROKI_RENDERER_URL = 'https://kroki.io'
MERMAID_IS_ENABLED_MERMAID_CHART_LINKS ='true'
[[redirects]]
from = "/index.html"
to = "/edit"
status = 301
force = true

View File

@@ -24,63 +24,65 @@
"devDependencies": {
"@cypress/snapshot": "2.1.7",
"@fortawesome/fontawesome-free": "^6.5.1",
"@sveltejs/adapter-static": "3.0.2",
"@sveltejs/kit": "2.5.19",
"@sveltejs/vite-plugin-svelte": "^3.0.1",
"@testing-library/svelte": "4.2.3",
"@sveltejs/adapter-static": "3.0.8",
"@sveltejs/kit": "2.19.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/svelte": "^5.2.4",
"@types/lodash-es": "^4.17.12",
"@types/pako": "2.0.3",
"@types/uuid": "9.0.8",
"@typescript-eslint/eslint-plugin": "6.21.0",
"@typescript-eslint/parser": "6.21.0",
"@vitest/ui": "^1.1.3",
"@vitest/ui": "^2.1.4",
"autoprefixer": "^10.4.14",
"c8": "7.14.0",
"chai": "^4.3.7",
"cssnano": "^6.0.0",
"cypress": "12.17.4",
"cypress-localstorage-commands": "2.2.6",
"eslint": "8.57.0",
"cypress-localstorage-commands": "2.2.7",
"eslint": "8.57.1",
"eslint-config-prettier": "9.1.0",
"eslint-plugin-cypress": "2.15.2",
"eslint-plugin-es": "^4.1.0",
"eslint-plugin-no-only-tests": "^3.1.0",
"eslint-plugin-postcss-modules": "^2.0.0",
"eslint-plugin-svelte": "^2.35.1",
"eslint-plugin-svelte": "^2.45.1",
"eslint-plugin-tailwindcss": "^3.13.1",
"eslint-plugin-unicorn": "^50.0.1",
"eslint-plugin-vitest": "^0.5.0",
"esserializer": "^1.3.11",
"husky": "^8.0.3",
"jsdom": "^21.1.2",
"jsdom": "^25.0.1",
"lint-staged": "^15.2.0",
"node-html-parser": "^6.1.5",
"postcss": "^8.4.33",
"postcss-load-config": "5.1.0",
"prettier": "^3.1.0",
"prettier-plugin-svelte": "^3.1.2",
"prettier-plugin-svelte": "^3.2.6",
"prettier-plugin-tailwindcss": "^0.6.0",
"svelte": "^4.2.8",
"svelte-preprocess": "^5.1.3",
"svelte": "^5.0.0",
"svelte-preprocess": "^6.0.0",
"tailwindcss": "^3.4.1",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"vite": "^5.0.11",
"vitest": "^1.1.3",
"typescript": "^5.5.0",
"vite": "^5.4.4",
"vitest": "^2.1.4",
"vitest-dom": "^0.1.1"
},
"dependencies": {
"@mermaid-js/layout-elk": "^0.1.4",
"@mermaid-js/mermaid-zenuml": "^0.2.0",
"daisyui": "2.52.0",
"dayjs": "^1.11.7",
"js-base64": "3.7.7",
"lodash-es": "^4.17.21",
"mermaid": "10.9.1",
"monaco-editor": "0.50.0",
"mermaid": "^11.3.0",
"monaco-editor": "0.52.2",
"pako": "2.1.0",
"plausible-tracker": "^0.3.8",
"random-word-slugs": "0.1.7",
"svg-pan-zoom": "3.6.1",
"svg-pan-zoom": "3.6.2",
"svg2roughjs": "^3.2.0",
"uuid": "9.0.1"
},
@@ -91,7 +93,7 @@
]
},
"volta": {
"node": "18.20.4",
"node": "18.20.7",
"yarn": "1.22.22"
},
"engines": {

View File

@@ -15,3 +15,7 @@
position: initial;
vertical-align: revert;
}
.d {
@apply border border-red-500;
}

1
src/env.d.ts vendored
View File

@@ -5,6 +5,7 @@ interface ImportMetaEnv {
readonly MERMAID_KROKI_RENDERER_URL?: string;
readonly MERMAID_ANALYTICS_URL?: string;
readonly MERMAID_DOMAIN?: string;
readonly MERMAID_IS_ENABLED_MERMAID_CHART_LINKS?: string;
// more env variables...
}

View File

@@ -6,9 +6,9 @@
import { pakoSerde } from '$lib/util/serde';
import { stateStore } from '$lib/util/state';
import { logEvent } from '$lib/util/stats';
import { version as FAVersion } from '@fortawesome/fontawesome-free/package.json';
import dayjs from 'dayjs';
import { toBase64 } from 'js-base64';
import { version as FAVersion } from '@fortawesome/fontawesome-free/package.json';
const FONT_AWESOME_URL = `https://cdnjs.cloudflare.com/ajax/libs/font-awesome/${FAVersion}/css/all.min.css`;
@@ -149,7 +149,7 @@ ${svgString}`);
logEvent('copyMarkdown');
};
let gistURL = '';
let gistURL = $state('');
stateStore.subscribe(({ loader }) => {
if (loader?.type === 'gist') {
// @ts-expect-error Gist will have url
@@ -165,14 +165,14 @@ ${svgString}`);
logEvent('loadGist');
};
let iUrl: string;
let svgUrl: string;
let krokiUrl: string;
let mdCode: string;
let imagemodeselected = 'auto';
let userimagesize = 1080;
let iUrl: string | undefined = $state();
let svgUrl: string | undefined = $state();
let krokiUrl: string | undefined = $state();
let mdCode: string | undefined = $state();
let imagemodeselected = $state('auto');
let userimagesize = $state(1080);
let isNetlify = false;
let isNetlify = $state(false);
if (browser && ['mermaid.live', 'netlify'].some((path) => window.location.host.includes(path))) {
isNetlify = true;
}
@@ -187,31 +187,35 @@ ${svgString}`);
<Card title="Actions" isOpen={false}>
<div class="m-2 flex flex-wrap gap-2">
{#if isClipboardAvailable()}
<button class="action-btn w-full" on:click={onCopyClipboard}
><i class="far fa-copy mr-2" /> Copy Image to clipboard
<button class="action-btn w-full" onclick={onCopyClipboard}
><i class="far fa-copy mr-2"></i> Copy Image to clipboard
</button>
{/if}
<button id="downloadPNG" class="action-btn flex-grow" on:click={onDownloadPNG}>
<i class="fas fa-download mr-2" /> PNG
<button id="downloadPNG" class="action-btn flex-grow" onclick={onDownloadPNG}>
<i class="fas fa-download mr-2"></i> PNG
</button>
<button id="downloadSVG" class="action-btn flex-grow" on:click={onDownloadSVG}>
<i class="fas fa-download mr-2" /> SVG
<button id="downloadSVG" class="action-btn flex-grow" onclick={onDownloadSVG}>
<i class="fas fa-download mr-2"></i> SVG
</button>
<a target="_blank" rel="noreferrer" class="flex-grow" href={iUrl}>
<button class="action-btn w-full">
<i class="fas fa-external-link-alt mr-2" /> PNG
</button>
</a>
<a target="_blank" rel="noreferrer" class="flex-grow" href={svgUrl}>
<button class="action-btn w-full">
<i class="fas fa-external-link-alt mr-2" /> SVG
</button>
</a>
<a target="_blank" rel="noreferrer" class="flex-grow" href={krokiUrl}>
<button class="action-btn w-full">
<i class="fas fa-external-link-alt mr-2" /> Kroki
</button>
</a>
{#if rendererUrl}
<a target="_blank" rel="noreferrer" class="flex-grow" href={iUrl}>
<button class="action-btn w-full">
<i class="fas fa-external-link-alt mr-2"></i> PNG
</button>
</a>
<a target="_blank" rel="noreferrer" class="flex-grow" href={svgUrl}>
<button class="action-btn w-full">
<i class="fas fa-external-link-alt mr-2"></i> SVG
</button>
</a>
{/if}
{#if krokiRendererUrl}
<a target="_blank" rel="noreferrer" class="flex-grow" href={krokiUrl}>
<button class="action-btn w-full">
<i class="fas fa-external-link-alt mr-2"></i> Kroki
</button>
</a>
{/if}
<div class="flex items-center gap-2">
PNG size
@@ -238,14 +242,16 @@ ${svgString}`);
{/if}
</div>
<div class="flex w-full items-center gap-2">
<input class="input" id="markdown" type="text" value={mdCode} on:click={onCopyMarkdown} />
<label for="markdown">
<button class="btn btn-primary btn-md flex-auto" on:click={onCopyMarkdown}>
Copy Markdown
</button>
</label>
</div>
{#if rendererUrl}
<div class="flex w-full items-center gap-2">
<input class="input" id="markdown" type="text" value={mdCode} onclick={onCopyMarkdown} />
<label for="markdown">
<button class="btn btn-primary btn-md flex-auto" onclick={onCopyMarkdown}>
Copy Markdown
</button>
</label>
</div>
{/if}
<div class="flex w-full items-center gap-2">
<input
@@ -255,7 +261,7 @@ ${svgString}`);
bind:value={gistURL}
placeholder="Enter Gist URL" />
<label for="gist">
<button class="btn btn-primary btn-md flex-auto" on:click={loadGist}> Load Gist </button>
<button class="btn btn-primary btn-md flex-auto" onclick={loadGist}> Load Gist </button>
</label>
</div>
{#if isNetlify}

View File

@@ -1,14 +1,38 @@
<script lang="ts">
import type { Tab } from '$lib/types';
import type { Snippet } from 'svelte';
import { slide } from 'svelte/transition';
import Tabs from './Tabs.svelte';
export let isCloseable = true;
export let isOpen = true;
export let tabs: Tab[] = [];
export let activeTabID = '';
export let title: string;
$: isOpen = isCloseable ? isOpen : true;
$: isTabsShown = isOpen && tabs.length > 0;
interface Props {
isClosable?: boolean;
isOpen?: boolean;
tabs?: Tab[];
activeTabID?: string;
title: string;
onselect?: (tab: Tab) => void;
actions?: Snippet;
children?: Snippet;
}
let {
isClosable = true,
isOpen = true,
tabs = [],
activeTabID = '',
title,
onselect,
actions,
children
}: Props = $props();
const toggleCardOpen = () => {
if (isClosable) {
isOpen = !isOpen;
}
};
let isTabsShown = $derived(isOpen && tabs.length > 0);
</script>
<div class="card m-2 flex flex-grow flex-col overflow-hidden rounded shadow-2xl">
@@ -16,18 +40,18 @@
role="toolbar"
tabindex="0"
class="bg-primary p-2 {isTabsShown ? 'pb-0' : ''} flex-none cursor-pointer"
on:click={() => (isOpen = !isOpen)}
on:keypress={() => (isOpen = !isOpen)}>
onclick={toggleCardOpen}
onkeypress={toggleCardOpen}>
<div class="flex justify-between">
<Tabs on:select {tabs} bind:isOpen {title} {isCloseable} {activeTabID} />
<Tabs {onselect} {tabs} bind:isOpen {title} {isClosable} {activeTabID} />
<div class="flex items-center gap-x-4 {isTabsShown ? '-mt-2' : ''}">
<slot name="actions" />
{@render actions?.()}
</div>
</div>
</div>
{#if isOpen}
<div class="card-body flex-grow overflow-auto p-0 text-base-content" transition:slide>
<slot />
{@render children?.()}
</div>
{/if}
</div>

View File

@@ -1,34 +1,43 @@
<script lang="ts">
import type { Tab, TabEvents } from '$lib/types';
import { createEventDispatcher } from 'svelte';
import type { Tab } from '$lib/types';
import { fade } from 'svelte/transition';
export let isCloseable = true;
export let tabs: Tab[];
export let title: string;
export let isOpen = false;
export let activeTabID: string;
let {
isClosable = true,
tabs,
title,
isOpen = $bindable(false),
activeTabID = $bindable(),
onselect
}: {
isClosable?: boolean;
tabs: Tab[];
title: string;
isOpen?: boolean;
activeTabID: string;
onselect?: (tab: Tab) => void;
} = $props();
if (!activeTabID && tabs.length > 0) {
activeTabID = tabs[0].id;
}
const dispatch = createEventDispatcher<TabEvents>();
const toggleTabs = (tab: Tab) => {
activeTabID = tab.id;
dispatch('select', tab);
return (event: Event) => {
event.stopPropagation();
activeTabID = tab.id;
onselect?.(tab);
};
};
</script>
<div class="flex cursor-default">
<span
role="menubar"
tabindex="0"
class="mr-2 font-semibold"
on:click|stopPropagation={() => (isOpen = !isOpen)}
on:keypress|stopPropagation={() => (isOpen = !isOpen)}>
{#if isCloseable}
<i class="fas fa-chevron-right icon" class:isOpen />
<span role="menubar" tabindex="0" class="mr-2 font-semibold">
{#if isClosable}
<i class="fas fa-chevron-right icon mr-1" class:isOpen></i>
{/if}
{title}</span>
{title}
</span>
{#if isOpen && tabs}
<ul class="tabs" transition:fade>
{#each tabs as tab}
@@ -36,9 +45,9 @@
role="tab"
tabindex="0"
class="tab tab-lifted {activeTabID === tab.id ? 'tab-active' : 'text-primary-content'}"
on:click|stopPropagation={() => toggleTabs(tab)}
on:keypress|stopPropagation={() => toggleTabs(tab)}>
<i class="mr-1 {tab.icon}" />
onclick={toggleTabs(tab)}
onkeypress={toggleTabs(tab)}>
<i class="mr-1 {tab.icon}"></i>
{tab.title}
</div>
{/each}

View File

@@ -1,5 +1,5 @@
import { cleanup, render } from '@testing-library/svelte';
import { describe, expect, it, afterEach } from 'vitest';
import { afterEach, describe, expect, it } from 'vitest';
import Card from './Card.svelte';
describe('card.svelte', () => {

View File

@@ -0,0 +1,44 @@
<script lang="ts">
interface Props {
links: { title: string; href: string }[];
label?: string;
icon?: string;
}
let { links, label, icon }: Props = $props();
</script>
<div class="dropdown dropdown-end">
<button class="btn btn-ghost">
{#if icon}
<i class={icon}></i>
{/if}
{#if label}
<span>{label}</span>
{/if}
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1792 1792"
class="ml-1 inline-block h-4 w-4 fill-current"
><path
d="M1395 736q0 13-10 23l-466 466q-10 10-23 10t-23-10l-466-466q-10-10-10-23t10-23l50-50q10-10 23-10t23 10l393 393 393-393q10-10 23-10t23 10l50 50q10 10 10 23z" /></svg>
</button>
<div
class="dropdown-content menu top-14 size-fit overflow-y-auto bg-base-200 text-base-content shadow-2xl">
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<ul tabindex="0" class="menu compact p-4">
{#each links as { href, title }}
<li>
<a
role="menuitem"
tabindex="0"
class="whitespace-nowrap underline"
target="_blank"
{href}>
{title}
</a>
</li>
{/each}
</ul>
</div>
</div>

View File

@@ -1,4 +1,4 @@
<script lang="ts" context="module">
<script lang="ts" module>
declare global {
interface Window {
Cypress: boolean;
@@ -19,7 +19,7 @@
import monacoJsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
import { onDestroy, onMount } from 'svelte';
let divElement: HTMLDivElement | undefined;
let divElement: HTMLDivElement | undefined = $state();
let editor: monaco.editor.IStandaloneCodeEditor | undefined;
let editorOptions: monaco.editor.IStandaloneEditorConstructionOptions = {
minimap: {
@@ -132,11 +132,11 @@
</script>
<div class="flex h-full flex-col">
<div bind:this={divElement} id="editor" class="h-full flex-grow overflow-hidden" />
<div bind:this={divElement} id="editor" class="h-full flex-grow overflow-hidden"></div>
{#if $stateStore.error instanceof Error}
<div class="flex flex-col text-sm text-neutral-100">
<div class="flex items-center gap-2 bg-red-700 p-2">
<i class="fa fa-exclamation-circle w-4" aria-hidden="true" />
<i class="fa fa-exclamation-circle w-4" aria-hidden="true"></i>
<p>Diagram syntax error</p>
</div>
<output class="max-h-32 overflow-auto bg-red-600 p-2" name="mermaid-error" for="editor">

View File

@@ -1,31 +1,33 @@
<script lang="ts">
import { stopPropagation } from 'svelte/legacy';
import Card from '$lib/components/Card/Card.svelte';
import { inputStateStore, getStateString } from '$lib/util/state';
import type { HistoryEntry, HistoryType, State, Tab } from '$lib/types';
import { notify, prompt } from '$lib/util/notify';
import { getStateString, inputStateStore } from '$lib/util/state';
import { logEvent } from '$lib/util/stats';
import dayjs from 'dayjs';
import dayjsRelativeTime from 'dayjs/plugin/relativeTime';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import {
addHistoryEntry,
historyModeStore,
clearHistoryData,
getPreviousState,
historyModeStore,
historyStore,
loaderHistoryStore,
restoreHistory
} from './history';
import { notify, prompt } from '$lib/util/notify';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import dayjs from 'dayjs';
import dayjsRelativeTime from 'dayjs/plugin/relativeTime';
import type { HistoryEntry, HistoryType, State, Tab } from '$lib/types';
import { logEvent } from '$lib/util/stats';
dayjs.extend(dayjsRelativeTime);
const HISTORY_SAVE_INTERVAL = 60_000;
const tabSelectHandler = (message: CustomEvent<Tab>) => {
historyModeStore.set(message.detail.id as HistoryType);
const tabSelectHandler = (tab: Tab) => {
historyModeStore.set(tab.id as HistoryType);
};
let tabs: Tab[] = [
let tabs: Tab[] = $state([
{
id: 'manual',
title: 'Saved',
@@ -36,7 +38,7 @@
title: 'Timeline',
icon: 'fas fa-history'
}
];
]);
const downloadHistory = () => {
const data = get(historyStore);
@@ -117,38 +119,42 @@
historyModeStore.set('loader');
}
});
let isOpen = false;
</script>
<Card on:select={tabSelectHandler} bind:isOpen {tabs} title="History">
<div slot="actions">
<button
id="uploadHistory"
class="btn btn-secondary btn-xs w-12"
on:click|stopPropagation={() => uploadHistory()}
title="Upload history"><i class="fa fa-upload" /></button>
{#if $historyStore.length > 0}
<Card onselect={tabSelectHandler} isOpen={false} {tabs} title="History">
{#snippet actions()}
<div>
<button
id="downloadHistory"
id="uploadHistory"
class="btn btn-secondary btn-xs w-12"
on:click|stopPropagation={() => downloadHistory()}
title="Download history"><i class="fa fa-download" /></button>
{/if}
|
<button
id="saveHistory"
class="btn btn-success btn-xs w-12"
on:click|stopPropagation={() => saveHistory()}
title="Save current state"><i class="far fa-save" /></button>
{#if $historyModeStore !== 'loader'}
onclick={stopPropagation(() => uploadHistory())}
title="Upload history"
aria-label="Upload history"><i class="fa fa-upload"></i></button>
{#if $historyStore.length > 0}
<button
id="downloadHistory"
class="btn btn-secondary btn-xs w-12"
onclick={stopPropagation(() => downloadHistory())}
title="Download history"
aria-label="Download history"><i class="fa fa-download"></i></button>
{/if}
|
<button
id="clearHistory"
class="btn btn-error btn-xs w-12"
on:click|stopPropagation={() => clearHistory()}
title="Delete all saved states"><i class="fas fa-trash-alt" /></button>
{/if}
</div>
id="saveHistory"
class="btn btn-success btn-xs w-12"
onclick={stopPropagation(() => saveHistory())}
title="Save current state"
aria-label="Save current state"><i class="far fa-save"></i></button>
{#if $historyModeStore !== 'loader'}
<button
id="clearHistory"
class="btn btn-error btn-xs w-12"
onclick={stopPropagation(() => clearHistory())}
title="Delete all saved states"
aria-label="Delete all saved states"><i class="fas fa-trash-alt"></i></button>
{/if}
</div>
{/snippet}
<ul class="h-56 space-y-2 overflow-auto p-2" id="historyList">
{#if $historyStore.length > 0}
{#each $historyStore as { id, state, time, name, url, type }}
@@ -169,11 +175,11 @@
</div>
</div>
<div class="flex content-center gap-2">
<button class="btn btn-success" on:click={() => restoreHistoryItem(state)}
><i class="fas fa-undo mr-1" />Restore</button>
<button class="btn btn-success" onclick={() => restoreHistoryItem(state)}
><i class="fas fa-undo mr-1"></i>Restore</button>
{#if type !== 'loader'}
<button class="btn btn-error" on:click={() => clearHistory(id)}
><i class="fas fa-trash-alt mr-1" />Delete</button>
<button class="btn btn-error" onclick={() => clearHistory(id)}
><i class="fas fa-trash-alt mr-1"></i>Delete</button>
{/if}
</div>
</div>

View File

@@ -1,55 +1,53 @@
<script context="module" lang="ts">
import { version } from 'mermaid/package.json';
<script module lang="ts">
import { logEvent, plausible } from '$lib/util/stats';
import { version } from 'mermaid/package.json';
void logEvent('version', {
mermaidVersion: version
});
</script>
<script lang="ts">
import Theme from './Theme.svelte';
import { env } from '$lib/util/env';
import { dismissPromotion, getActivePromotion } from '$lib/util/promos/promo';
import { stateStore } from '$lib/util/state';
import { MCBaseURL } from '$lib/util/util';
import type { ComponentProps } from 'svelte';
import DropdownNavMenu from './DropdownNavMenu.svelte';
import Privacy from './Privacy.svelte';
let isMenuOpen = false;
import Theme from './Theme.svelte';
const { isEnabledMermaidChartLinks } = env;
let isMenuOpen = $state(false);
const isReferral = document.referrer.includes(MCBaseURL);
function toggleMenu() {
isMenuOpen = !isMenuOpen;
}
interface Link {
href: string;
title?: string;
icon?: string;
img?: string;
}
const links: Link[] = [
type Links = ComponentProps<typeof DropdownNavMenu>['links'];
const githubLinks: Links = [
{ title: 'Mermaid JS', href: 'https://github.com/mermaid-js/mermaid' },
{
title: 'Documentation',
href: 'https://mermaid.js.org/intro/getting-started.html'
title: 'Mermaid Live Editor',
href: 'https://github.com/mermaid-js/mermaid-live-editor'
},
{
title: 'Tutorial',
href: 'https://mermaid.js.org/ecosystem/tutorials.html'
},
{
title: 'Mermaid',
href: 'https://github.com/mermaid-js/mermaid'
},
{
title: 'CLI',
title: 'Mermaid CLI',
href: 'https://github.com/mermaid-js/mermaid-cli'
},
{
href: 'https://github.com/mermaid-js/mermaid-live-editor',
icon: 'fab fa-github fa-lg'
},
{
href: 'https://mermaidchart.com',
img: './mermaidchart-logo.svg'
}
];
let activePromotion = getActivePromotion();
const documentationLinks: Links = [
{ title: 'Getting started', href: 'https://mermaid.js.org/intro/getting-started.html' },
{ title: 'Tutorials', href: 'https://mermaid.js.org/ecosystem/tutorials.html' },
{
title: 'Integrations',
href: 'https://mermaid.js.org/ecosystem/integrations-community.html'
}
];
let activePromotion = $state(getActivePromotion());
const trackBannerClick = () => {
if (!plausible || !activePromotion) {
@@ -62,43 +60,63 @@
</script>
{#if activePromotion}
<div
class="top-bar z-10 flex h-fit w-full items-center justify-center bg-gradient-to-r from-[#bd34fe] to-[#ff3670] p-1 text-center text-white">
<div class="top-bar z-10 flex h-fit w-full bg-primary">
<div
class="flex flex-grow"
role="button"
tabindex="0"
on:click={trackBannerClick}
on:keypress={trackBannerClick}>
<svelte:component this={activePromotion.component} />
onclick={trackBannerClick}
onkeypress={trackBannerClick}>
<activePromotion.component {closeBanner} />
</div>
<button
title="Dismiss banner"
on:click={() => {
dismissPromotion(activePromotion?.id);
activePromotion = undefined;
}}>
<i class="fa fa-close px-2" />
</button>
{#snippet closeBanner()}
<button
title="Dismiss banner"
aria-label="Dismiss banner"
onclick={() => {
dismissPromotion(activePromotion?.id);
activePromotion = undefined;
}}>
<i class="fa fa-close px-2"></i>
</button>
{/snippet}
</div>
{/if}
<div class="navbar bg-primary p-0 shadow-lg">
<div class="mx-2 flex-1 px-2">
<span class="text-lg font-bold">
<a href="/">Mermaid<span class="text-xs font-thin">v{version}</span> Live Editor</a>
</span>
<span class="ml-4">
<a
href="https://www.producthunt.com/products/mermaid-chart?utm_source=badge-follow&utm_medium=badge&utm_souce=badge-mermaid&#0045;chart"
target="_blank"
><img
src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=552855&theme=light&size=small"
alt="Mermaid&#0032;Chart - A&#0032;smarter&#0032;way&#0032;to&#0032;create&#0032;diagrams | Product Hunt"
style="width: 86px; height: 32px;"
width="86"
height="32" /></a>
</span>
<div class="navbar z-50 bg-primary p-0 shadow-lg">
<div class="mx-2 flex flex-1 gap-2 px-2">
<a href="/"><img class="size-6" src="./favicon.svg" alt="Mermaid Live Editor" /></a>
<div
id="switcher"
class="flex items-center justify-center gap-2 font-bold"
class:flex-row-reverse={isReferral}>
<a href="/">
{#if !isReferral}
Mermaid
{/if}
Live Editor
</a>
{#if isEnabledMermaidChartLinks}
<input
type="checkbox"
class="toggle toggle-primary"
id="editorMode"
checked={isReferral}
onclick={() => {
logEvent('playgroundToggle', { isReferred: isReferral });
// Wait for the event to be logged
setTimeout(() => {
window.open(
`${MCBaseURL}/play#${$stateStore.serialized}`,
'_self',
// Do not send referrer header, if the user already came from playground
isReferral ? 'noreferrer' : ''
);
}, 100);
}} />
<a href="{MCBaseURL}/play#{$stateStore.serialized}">Playground</a>
{/if}
</div>
</div>
<label
@@ -138,28 +156,31 @@
type="checkbox"
id="menu-toggle"
bind:checked={isMenuOpen}
on:click={toggleMenu} />
onclick={toggleMenu} />
<div class="hidden w-full lg:flex lg:w-auto lg:items-center" id="menu">
<Theme />
<span class="text-sm">v{version}</span>
<ul class="items-center justify-between pt-4 text-base lg:flex lg:pt-0">
<li>
<Privacy />
</li>
{#each links as { title, href, icon, img }}
<li>
<Theme />
</li>
<li>
<DropdownNavMenu label="Documentation" links={documentationLinks} />
</li>
<li>
<DropdownNavMenu icon="fab fa-github fa-lg" links={githubLinks} />
</li>
{#if isEnabledMermaidChartLinks}
<li>
<a class="btn btn-ghost" target="_blank" {href}>
{#if icon}
<i class={icon} />
{:else if img}
<img src={img} alt={title} />
{/if}
{#if title}
{title}
{/if}
<a class="btn btn-ghost" target="_blank" href="https://mermaidchart.com">
<img class="size-6" src="./mermaidchart-logo.svg" alt="Mermaid Chart" />
</a>
</li>
{/each}
{/if}
</ul>
</div>
</div>
@@ -172,13 +193,4 @@
background: #661ae6;
display: flex;
}
.navbar {
z-index: 10000;
}
img {
width: 1.5rem;
height: 1.5rem;
}
</style>

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { updateCode } from '$lib/util/state';
import Card from '$lib/components/Card/Card.svelte';
import { updateCode } from '$lib/util/state';
import { logEvent } from '$lib/util/stats';
const samples = {
@@ -165,7 +165,29 @@
}
}
}
`
`,
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)"
`
};
type SampleTypes = keyof typeof samples;
@@ -178,7 +200,7 @@
};
// Adding in this array will add an icon to the preset menu
const newDiagrams: SampleTypes[] = ['QuadrantChart', 'XYChart', 'Block', 'ZenUML'];
const newDiagrams: SampleTypes[] = ['QuadrantChart', 'XYChart', 'Block', 'Packet'];
const diagramOrder: SampleTypes[] = [
'Flow',
'Sequence',
@@ -190,10 +212,11 @@
'Git',
'Pie',
'Mindmap',
'ZenUML',
'QuadrantChart',
'XYChart',
'Block',
'ZenUML'
'Packet'
];
</script>
@@ -202,10 +225,10 @@
{#each diagramOrder as sample}
<button
class="btn btn-primary btn-sm w-fit min-w-20 flex-grow normal-case"
on:click={() => loadSampleDiagram(sample)}>
onclick={() => loadSampleDiagram(sample)}>
{sample}
{#if newDiagrams.includes(sample)}
<span class="fa fa-heart ml-2" />
<span class="fa fa-heart ml-2"></span>
{/if}
</button>
{/each}

View File

@@ -27,7 +27,7 @@
</script>
<div class="dropdown hidden lg:block">
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<div tabindex="0" class="btn btn-ghost">
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -49,7 +49,7 @@
</div>
<div
class="dropdown-content top-px mt-14 h-96 w-56 overflow-y-auto bg-base-200 text-base-content shadow-2xl">
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<ul tabindex="0" class="menu compact p-4">
{#each themes as theme}
<li class:bordered={$themeStore.theme !== undefined && theme.includes($themeStore.theme)}>
@@ -57,8 +57,8 @@
role="menuitem"
tabindex="0"
class="btn btn-ghost justify-start"
on:click={() => setTheme(theme)}
on:keypress={() => setTheme(theme)}>{theme}</span>
onclick={() => setTheme(theme)}
onkeypress={() => setTheme(theme)}>{theme}</span>
</li>
{/each}
</ul>

View File

@@ -5,6 +5,7 @@
import { inputStateStore, stateStore, updateCodeStore } from '$lib/util/state';
import { logEvent, saveStatistics } from '$lib/util/stats';
import { cmdKey } from '$lib/util/util';
import uniqueID from 'lodash-es/uniqueId';
import type { MermaidConfig } from 'mermaid';
import { onMount } from 'svelte';
import panzoom from 'svg-pan-zoom';
@@ -12,16 +13,14 @@
let code = '';
let config = '';
let container: HTMLDivElement;
let container: HTMLDivElement | undefined = $state();
let rough: boolean;
let view: HTMLDivElement;
let error = false;
let outOfSync = false;
let hide = false;
let view: HTMLDivElement | undefined = $state();
let error = $state(false);
let outOfSync = $state(false);
let manualUpdate = true;
let panZoomEnabled = $stateStore.panZoom;
let pzoom: typeof panzoom | undefined;
const handlePanZoomChange = () => {
if (!pzoom) {
return;
@@ -32,18 +31,13 @@
logEvent('panZoom');
};
const handlePanZoom = (state: State) => {
const handlePanZoom = (state: State, graphDiv: SVGSVGElement) => {
if (!state.panZoom) {
return;
}
hide = true;
pzoom?.destroy();
pzoom = undefined;
void Promise.resolve().then(() => {
const graphDiv = document.querySelector<HTMLElement>('#graph-div');
if (!graphDiv) {
return;
}
pzoom = panzoom(graphDiv, {
onPan: handlePanZoomChange,
onZoom: handlePanZoomChange,
@@ -56,7 +50,6 @@
pzoom.zoom(zoom);
pzoom.pan(pan);
}
hide = false;
});
};
@@ -67,6 +60,7 @@
return;
}
error = false;
let diagramType: string | undefined;
try {
if (container && state && (state.updateDiagram || state.autoSync)) {
if (!state.autoSync) {
@@ -93,18 +87,18 @@
config = state.mermaid;
panZoomEnabled = state.panZoom;
rough = state.rough;
const scroll = view.parentElement?.scrollTop;
const scroll = view?.parentElement?.scrollTop;
delete container.dataset.processed;
const { svg, bindFunctions } = await renderDiagram(
Object.assign({}, JSON.parse(state.mermaid)) as MermaidConfig,
code,
'graph-div'
);
const viewID = uniqueID('graph-');
const {
svg,
bindFunctions,
diagramType: detectedDiagramType
} = await renderDiagram(JSON.parse(state.mermaid) as MermaidConfig, code, viewID);
diagramType = detectedDiagramType;
if (svg.length > 0) {
handlePanZoom(state);
container.innerHTML = svg;
const graphDiv = document.querySelector<SVGSVGElement>('#graph-div');
let graphDiv = document.querySelector<SVGSVGElement>(`#${viewID}`);
if (!graphDiv) {
throw new Error('graph-div not found');
}
@@ -113,7 +107,7 @@
svg2roughjs.svg = graphDiv;
await svg2roughjs.sketch();
graphDiv.remove();
const sketch = document.querySelector<HTMLElement>('#container > svg');
const sketch = document.querySelector<SVGSVGElement>('#container > svg');
if (!sketch) {
throw new Error('sketch not found');
}
@@ -123,6 +117,7 @@
sketch.setAttribute('width', '100%');
sketch.setAttribute('viewBox', `0 0 ${width} ${height}`);
sketch.style.maxWidth = '100%';
graphDiv = sketch;
} else {
graphDiv.setAttribute('height', '100%');
graphDiv.style.maxWidth = '100%';
@@ -130,8 +125,9 @@
bindFunctions(graphDiv);
}
}
handlePanZoom(state, graphDiv);
}
if (view.parentElement && scroll) {
if (view?.parentElement && scroll) {
view.parentElement.scrollTop = scroll;
}
error = false;
@@ -145,15 +141,17 @@
error = true;
}
const renderTime = Date.now() - startTime;
saveStatistics({ code, renderTime, isRough: state.rough });
saveStatistics({ code, renderTime, isRough: state.rough, diagramType });
recordRenderTime(renderTime, () => {
$inputStateStore.updateDiagram = true;
});
};
onMount(() => {
// Queue state changes to avoid race condition
let pendingStateChange = Promise.resolve();
stateStore.subscribe((state) => {
void handleStateChange(state);
pendingStateChange = pendingStateChange.then(() => handleStateChange(state).catch(() => {}));
});
window.addEventListener('resize', () => {
if ($stateStore.panZoom && pzoom) {
@@ -165,36 +163,27 @@
{#if outOfSync}
<div
class="font-monotext-yellow-600 absolute z-10 w-full bg-base-100 bg-opacity-80 p-2 text-left"
class="absolute z-10 w-full bg-base-100 bg-opacity-80 p-2 text-left font-mono text-yellow-600"
id="errorContainer">
Diagram out of sync. <br />
{#if $stateStore.autoSync}
It will be updated automatically.
{:else}
Press <i class="fas fa-sync" /> (Sync button) or <kbd>{cmdKey} + Enter</kbd> to sync.
Press <i class="fas fa-sync"></i> (Sync button) or <kbd>{cmdKey} + Enter</kbd> to sync.
{/if}
</div>
{/if}
<div id="view" bind:this={view} class="h-full p-2" class:error class:outOfSync>
<div id="container" bind:this={container} class="h-full overflow-auto" class:hide />
<div id="container" bind:this={container} class="h-full overflow-auto"></div>
</div>
<style>
#view {
flex: 1;
}
#container {
transition: visibility 0.3s;
}
.error,
.outOfSync {
opacity: 0.5;
}
.hide {
visibility: hidden;
}
</style>

View File

@@ -1,6 +1,7 @@
export const env = {
rendererUrl: import.meta.env.MERMAID_RENDERER_URL ?? 'https://mermaid.ink',
krokiRendererUrl: import.meta.env.MERMAID_KROKI_RENDERER_URL ?? 'https://kroki.io',
rendererUrl: import.meta.env.MERMAID_RENDERER_URL ?? '',
krokiRendererUrl: import.meta.env.MERMAID_KROKI_RENDERER_URL ?? '',
analyticsUrl: import.meta.env.MERMAID_ANALYTICS_URL ?? '',
domain: import.meta.env.MERMAID_DOMAIN ?? 'mermaid.live'
domain: import.meta.env.MERMAID_DOMAIN ?? '',
isEnabledMermaidChartLinks: import.meta.env.MERMAID_IS_ENABLED_MERMAID_CHART_LINKS == 'true'
} as const;

View File

@@ -1,7 +1,9 @@
import mermaid from 'mermaid';
import type { MermaidConfig, RenderResult } from 'mermaid';
import elkLayouts from '@mermaid-js/layout-elk';
import zenuml from '@mermaid-js/mermaid-zenuml';
import type { MermaidConfig, RenderResult } from 'mermaid';
import mermaid from 'mermaid';
mermaid.registerLayoutLoaders(elkLayouts);
const init = mermaid.registerExternalDiagrams([zenuml]);
export const render = async (

View File

@@ -1,7 +0,0 @@
<a
href="https://www.mermaidchart.com/app/user/billing/checkout?coupon=HOLIDAYS2023"
target="_blank"
class="flex-grow tracking-wide">
Get AI, team collaboration, storage, and more with
<span class="font-bold underline">Mermaid Chart Pro. Start free trial today & get 25% off.</span>
</a>

View File

@@ -1,25 +0,0 @@
<script lang="ts">
const taglines = {
announcement_bar_ai_diagramming: 'Try diagramming with ChatGPT at Mermaid Chart',
announcement_bar_visual_editor: "Try Mermaid's Visual Editor at Mermaid Chart",
announcement_bar_live_collaboration: 'Enjoy live collaboration with teammates at Mermaid Chart'
};
const taglineKeys = Object.keys(taglines);
const taglineKey = taglineKeys[Math.floor(Math.random() * taglineKeys.length)];
const tagline = taglines[taglineKey];
const url =
'https://www.mermaidchart.com/landing/?' +
new URLSearchParams({
utm_source: 'mermaid_live_editor',
utm_medium: taglineKey,
utm_campaign: 'promo_2024'
}).toString();
</script>
<a
href={url}
target="_blank"
class="flex flex-grow justify-center gap-6 align-middle tracking-wide">
{tagline}
<button class="rounded bg-gray-800 p-1 px-4 text-sm font-light">Try it now</button>
</a>

View File

@@ -0,0 +1,147 @@
<script lang="ts">
import { onDestroy, type Snippet } from 'svelte';
import { fade } from 'svelte/transition';
interface Props {
closeBanner: Snippet;
}
let { closeBanner }: Props = $props();
interface Taglines {
label: string;
url: string;
}
const allTaglines: { [key: string]: { design: number; taglines: Taglines[] } } = {
A: {
design: 1,
taglines: [
{
label: 'Replace ChatGPT Pro, Mermaid.live, and Lucid Chart with Mermaid Chart',
url: 'https://www.mermaidchart.com/play?utm_source=mermaid_live_editor&utm_medium=banner_ad&utm_campaign=AIbundle_A'
},
{
label: 'Diagram live with teammates in Mermaid Chart',
url: 'https://www.mermaidchart.com/play?utm_source=mermaid_live_editor&utm_medium=banner_ad&utm_campaign=teams_A'
},
{
label: 'Use the Visual Editor in Mermaid Chart to design and build diagrams',
url: 'https://www.mermaidchart.com/play?utm_source=mermaid_live_editor&utm_medium=banner_ad&utm_campaign=visual_editor_A'
},
{
label: 'Explore the Mermaid Whiteboard from the creators of Mermaid',
url: 'https://www.mermaidchart.com/whiteboard?utm_source=mermaid_live_editor&utm_medium=banner_ad&utm_campaign=whiteboard_A'
}
]
},
B: {
design: 2,
taglines: [
{
label: 'Replace ChatGPT Pro, Mermaid.live, and Lucid Chart with Mermaid Chart',
url: 'https://www.mermaidchart.com/play?utm_source=mermaid_live_editor&utm_medium=banner_ad&utm_campaign=AIbundle_B'
},
{
label: 'Diagram live with teammates in Mermaid Chart',
url: 'https://www.mermaidchart.com/play?utm_source=mermaid_live_editor&utm_medium=banner_ad&utm_campaign=teams_B'
},
{
label: 'Use the Visual Editor in Mermaid Chart to design and build diagrams',
url: 'https://www.mermaidchart.com/play?utm_source=mermaid_live_editor&utm_medium=banner_ad&utm_campaign=visual_editor_B'
},
{
label: 'Explore the Mermaid Whiteboard from the creators of Mermaid',
url: 'https://www.mermaidchart.com/whiteboard?utm_source=mermaid_live_editor&utm_medium=banner_ad&utm_campaign=whiteboard_B'
}
]
},
C: {
design: 1,
taglines: [
{
label: 'Replace ChatGPT Pro, Mermaid.live, and Lucid Chart with Mermaid Pro',
url: 'https://www.mermaidchart.com/play?utm_source=mermaid_live_editor&utm_medium=banner_ad&utm_campaign=AIbundle_C'
},
{
label: 'Diagram live with teammates in Mermaid Pro',
url: 'https://www.mermaidchart.com/play?utm_source=mermaid_live_editor&utm_medium=banner_ad&utm_campaign=teams_C'
},
{
label: 'Use the Visual Editor in Mermaid Pro to design and build diagrams',
url: 'https://www.mermaidchart.com/play?utm_source=mermaid_live_editor&utm_medium=banner_ad&utm_campaign=visual_editor_C'
},
{
label: 'Explore the Mermaid Whiteboard from the creators of Mermaid',
url: 'https://www.mermaidchart.com/whiteboard?utm_source=mermaid_live_editor&utm_medium=banner_ad&utm_campaign=whiteboard_A'
}
]
},
D: {
design: 2,
taglines: [
{
label: 'Replace ChatGPT Pro, Mermaid.live, and Lucid Chart with Mermaid Pro',
url: 'https://www.mermaidchart.com/play?utm_source=mermaid_live_editor&utm_medium=banner_ad&utm_campaign=AIbundle_D'
},
{
label: 'Diagram live with teammates in Mermaid Pro',
url: 'https://www.mermaidchart.com/play?utm_source=mermaid_live_editor&utm_medium=banner_ad&utm_campaign=teams_D'
},
{
label: 'Use the Visual Editor in Mermaid Pro to design and build diagrams',
url: 'https://www.mermaidchart.com/play?utm_source=mermaid_live_editor&utm_medium=banner_ad&utm_campaign=visual_editor_D'
},
{
label: 'Explore the Mermaid Whiteboard from the creators of Mermaid',
url: 'https://www.mermaidchart.com/whiteboard?utm_source=mermaid_live_editor&utm_medium=banner_ad&utm_campaign=whiteboard_B'
}
]
}
};
const { design, taglines } =
Object.values(allTaglines)[Math.floor(Math.random() * Object.values(allTaglines).length)];
let index = Math.floor(Math.random() * taglines.length);
let currentTagline = $state(taglines[index]);
const interval = setInterval(() => {
if (shouldAnimate) {
index = (index + 1) % taglines.length;
currentTagline = taglines[index];
}
}, 5000);
onDestroy(() => {
clearInterval(interval);
});
let shouldAnimate = $state(true);
</script>
<div
class="flex w-full p-1.5 {design === 1
? 'bg-gradient-to-r from-[#bd34fe] to-[#ff3670]'
: 'bg-[#E0095F]'}"
role="banner"
onmouseenter={() => (shouldAnimate = false)}
onmouseleave={() => (shouldAnimate = true)}>
<div class="grid grow">
{#key currentTagline}
<a
href={currentTagline.url}
target="_blank"
class="col-start-1 row-start-1 flex items-center justify-center gap-4 no-underline"
in:fade={{ delay: 750 }}
out:fade={{ duration: 1000 }}>
<span class="text-sm tracking-wider">{currentTagline.label}</span>
<button
class="shrink-0 rounded-lg bg-[#1E1A2E] px-3 py-1.5 text-sm font-semibold tracking-wide">
Try now
</button>
</a>
{/key}
</div>
{@render closeBanner()}
</div>

View File

@@ -1,54 +1,66 @@
import { writable, type Writable, get } from 'svelte/store';
import { persist, localStorage } from '../persist';
import Holiday2023 from './Holiday2023.svelte';
import Jan2024 from './Jan2024.svelte';
import { env } from '$lib/util/env';
import dayjs from 'dayjs';
import duration from 'dayjs/plugin/duration';
import type { Component } from 'svelte';
import { get, writable, type Writable } from 'svelte/store';
import { localStorage, persist } from '../persist';
import January2025 from './January2025.svelte';
dayjs.extend(duration);
interface Promotion {
id: string;
startDate: Date;
endDate: Date;
component: ConstructorOfATypedSvelteComponent;
component: Component;
hideDurationMs: number;
}
const promotions: Promotion[] = [
{
id: 'holiday-2023',
startDate: new Date('2023-11-27'),
endDate: new Date('2024-01-09'),
component: Holiday2023
},
{
id: 'jan-2024',
startDate: new Date('2024-01-10'),
endDate: new Date('2024-12-31'),
component: Jan2024
const promotions: Record<string, Promotion> = {
'promo-january-2025': {
startDate: new Date('2025-01-01'),
endDate: new Date('2028-12-31'),
component: January2025,
hideDurationMs: dayjs.duration(1, 'week').asMilliseconds()
}
];
};
export const dismissPromotion = (id?: string): void => {
if (!id) {
if (!id || !promotions[id]) {
return;
}
dismissedPromotionsStore.update((dismissedIDs: string[]) => {
dismissedIDs.push(id);
hiddenPromotionsStore.update((dismissedIDs) => {
dismissedIDs[id] = dayjs().add(promotions[id].hideDurationMs).valueOf();
return dismissedIDs;
});
};
const dismissedPromotionsStore: Writable<string[]> = persist(
writable([]),
const hiddenPromotionsStore: Writable<Record<string, number>> = persist(
writable({}),
localStorage(),
'dismissedPromotions'
'hiddenPromotions'
);
export const getActivePromotion = (): Promotion | undefined => {
const dismissedPromotions = get(dismissedPromotionsStore);
export const getActivePromotion = (): (Promotion & { id: string }) | undefined => {
if (!env.isEnabledMermaidChartLinks) {
return;
}
const hidePromotionsUntil = get(hiddenPromotionsStore);
const now = new Date();
return promotions
const promotionWithID = Object.entries(promotions)
.filter(
(p: Promotion) =>
p.startDate <= now && p.endDate >= now && !dismissedPromotions.includes(p.id)
([id, p]) =>
dayjs(p.startDate).isBefore(now) &&
dayjs(p.endDate).isAfter(now) &&
(!hidePromotionsUntil[id] || dayjs(hidePromotionsUntil[id]).isBefore(now))
)
.sort((a: Promotion, b: Promotion) => b.endDate.getTime() - a.endDate.getTime())
.sort(([, a], [, b]) => dayjs(b.endDate).diff(dayjs(a.endDate)))
.pop();
if (!promotionWithID) {
return;
}
const [id, promotion] = promotionWithID;
return { ...promotion, id };
};

View File

@@ -124,6 +124,9 @@ export const loadState = (data: string): void => {
console.log(`Loading '${data}'`);
try {
state = deserializeState(data);
if (!state.mermaid) {
state.mermaid = defaultState.mermaid;
}
const mermaidConfig: MermaidConfig =
typeof state.mermaid === 'string'
? (JSON.parse(state.mermaid) as MermaidConfig)

View File

@@ -1,19 +0,0 @@
import { describe, it, expect } from 'vitest';
import { detectType } from './stats';
describe('diagram detection', () => {
it('should detect diagrams correctly', () => {
expect(
detectType(`%%{{
graph`)
).toBe('graph');
expect(detectType(`gitGraph`)).toBe('gitGraph');
expect(
detectType(`%%{{
flowChart
graph`)
).toBe('flowChart');
expect(detectType(`loki -> thor`)).toBe(undefined);
});
});

View File

@@ -1,6 +1,7 @@
import { browser } from '$app/environment';
import type PlausibleInstance from 'plausible-tracker';
import { env } from './env';
export let plausible: ReturnType<typeof PlausibleInstance> | undefined;
export const initAnalytics = async (): Promise<void> => {
@@ -20,29 +21,6 @@ export const initAnalytics = async (): Promise<void> => {
}
};
export const detectType = (text: string): string | undefined => {
const possibleDiagramTypes = [
'classDiagram',
'erDiagram',
'flowChart',
'gantt',
'gitGraph',
'graph',
'journey',
'pie',
'stateDiagram',
'quadrantChart',
'mindmap'
];
const firstLine = text
.replaceAll(/^\s*%%.*\n/g, '\n')
.trimStart()
.split(' ')[0]
.toLowerCase();
const detectedDiagram = possibleDiagramTypes.find((d) => firstLine.includes(d.toLowerCase()));
return detectedDiagram;
};
export const countLines = (code: string): number => {
return (code.match(/\n/g)?.length ?? 0) + 1;
};
@@ -50,50 +28,47 @@ export const countLines = (code: string): number => {
export const saveStatistics = ({
code,
renderTime,
isRough
isRough,
diagramType
}: {
code: string;
renderTime: number;
isRough: boolean;
diagramType?: string;
}): void => {
const graphType = detectType(code);
if (!graphType) {
if (!diagramType) {
return;
}
const length = countLines(code);
const lengthBucket = getBucket(length);
const renderTimeMsBucket = getBucket(renderTime);
logEvent('render', { graphType, length, lengthBucket, renderTimeMsBucket, isRough });
logEvent('render', { diagramType, lengthBucket, renderTimeMsBucket, isRough });
};
const getBucket = (length: number): string => {
return length < 10
? '0-10'
: length < 25
? '10-25'
: length < 50
? '25-50'
: length < 100
? '50-100'
: length < 200
? '100-200'
: length < 500
? '200-500'
: length < 700
? '500-700'
: length < 1000
? '700-1000'
: length < 1500
? '1000-1500'
: length < 2500
? '1500-2500'
: length < 4500
? '2500-4500'
: length < 7000
? '4500-7000'
: length < 10_000
? '7000-10000'
: '10000+';
const buckets = [
[10, '0-10'],
[25, '10-25'],
[50, '25-50'],
[100, '50-100'],
[200, '100-200'],
[500, '200-500'],
[700, '500-700'],
[1000, '700-1000'],
[1500, '1000-1500'],
[2500, '1500-2500'],
[4500, '2500-4500'],
[7000, '4500-7000'],
[10_000, '7000-10000']
] as const;
for (const [threshold, label] of buckets) {
if (length < threshold) {
return label;
}
}
return '10000+';
};
const minutesToMilliSeconds = (minutes: number): number => {
@@ -104,6 +79,7 @@ const defaultDelay = minutesToMilliSeconds(1);
const delaysPerEvent = {
render: minutesToMilliSeconds(5),
panZoom: minutesToMilliSeconds(10),
playgroundToggle: 0,
copyClipboard: defaultDelay,
download: defaultDelay,
copyMarkdown: defaultDelay,
@@ -115,7 +91,7 @@ const delaysPerEvent = {
themeChange: defaultDelay,
bannerClick: defaultDelay,
version: defaultDelay
};
} as const;
export type AnalyticsEvent = keyof typeof delaysPerEvent;
const timeouts: Map<string, number> = new Map<string, number>();
// manual debounce to reduce the number of events sent to analytics

View File

@@ -1,8 +1,8 @@
import { initURLSubscription, loadState, updateCodeStore } from './state';
import { plausible, initAnalytics } from './stats';
import { loadDataFromUrl } from './fileLoaders/loader';
import { initLoading } from './loading';
import { applyMigrations } from './migrations';
import { initURLSubscription, loadState, updateCodeStore } from './state';
import { initAnalytics, plausible } from './stats';
export const loadStateFromURL = (): void => {
loadState(window.location.hash.slice(1));
@@ -26,6 +26,7 @@ export const initHandler = async (): Promise<void> => {
export const isMac = navigator.platform.toUpperCase().includes('MAC');
export const cmdKey = isMac ? 'Cmd' : 'Ctrl';
export const MCBaseURL = 'https://mermaidchart.com'; // 'http://localhost:5174'
let count = 0;
export const errorDebug = (limit = 1000) => {

View File

@@ -1,11 +1,17 @@
<script lang="ts">
import '../app.postcss';
import { base } from '$app/paths';
import { onMount } from 'svelte';
import { loadingStateStore } from '$lib/util/loading';
import { setTheme, themeStore } from '$lib/util/theme';
import { toggleDarkTheme } from '$lib/util/state';
import { setTheme, themeStore } from '$lib/util/theme';
import { initHandler } from '$lib/util/util';
import { onMount, type Snippet } from 'svelte';
import '../app.postcss';
interface Props {
children?: Snippet;
}
let { children }: Props = $props();
// This can be removed once https://github.com/sveltejs/kit/issues/1612 is fixed.
// Then move it into src and vite will bundle it automatically.
@@ -42,14 +48,14 @@
</script>
<main class="h-screen text-primary-content">
<slot />
{@render children?.()}
</main>
{#if $loadingStateStore.loading}
<div
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 class="loader mx-auto"></div>
<div>{$loadingStateStore.message}</div>
</div>
</div>

View File

@@ -1,5 +1,4 @@
<script lang="ts">
import { dev } from '$app/environment';
import { base } from '$app/paths';
import Actions from '$lib/components/Actions.svelte';
import Card from '$lib/components/Card/Card.svelte';
@@ -9,11 +8,11 @@
import Preset from '$lib/components/Preset.svelte';
import View from '$lib/components/View.svelte';
import type { DocumentationConfig, EditorMode, Tab, ValidatedState } from '$lib/types';
import { env } from '$lib/util/env';
import { inputStateStore, stateStore, updateCodeStore } from '$lib/util/state';
import { cmdKey, initHandler, syncDiagram } from '$lib/util/util';
import { cmdKey, initHandler, MCBaseURL, syncDiagram } from '$lib/util/util';
import { onMount } from 'svelte';
const MCBaseURL = dev ? 'http://localhost:5174' : 'https://mermaidchart.com';
const docURLBase = 'https://mermaid.js.org';
const docMap: DocumentationConfig = {
graph: {
@@ -83,9 +82,9 @@
config: '/syntax/xyChart.html#chart-configurations'
}
};
let docURL = docURLBase;
let activeTabID = 'code';
let docKey = '';
let docURL = $state(docURLBase);
let activeTabID = $state('code');
let docKey = $state('');
stateStore.subscribe(({ code, editorMode }: ValidatedState) => {
activeTabID = editorMode;
const codeTypeMatch = /(\S+)\s/.exec(code);
@@ -96,8 +95,8 @@
}
});
const tabSelectHandler = (message: CustomEvent<Tab>) => {
const editorMode: EditorMode = message.detail.id === 'code' ? 'code' : 'config';
const tabSelectHandler = (tab: Tab) => {
const editorMode: EditorMode = tab.id === 'code' ? 'code' : 'config';
updateCodeStore({ editorMode });
};
@@ -144,35 +143,38 @@
<Navbar />
<div class="flex flex-1 overflow-hidden">
<div class="hidden flex-col md:flex" id="editorPane" style="width: 40%">
<Card on:select={tabSelectHandler} {tabs} isCloseable={false} {activeTabID} title="Mermaid">
<div slot="actions" class="flex flex-row items-center">
<div class="form-control flex-row items-center">
<label class="label cursor-pointer" for="autoSync">
<span> Auto sync</span>
<input
type="checkbox"
class="toggle {$stateStore.autoSync ? 'btn-secondary' : 'toggle-primary'} ml-1"
id="autoSync"
bind:checked={$inputStateStore.autoSync} />
</label>
</div>
<Card onselect={tabSelectHandler} {tabs} isClosable={false} {activeTabID} title="Mermaid">
{#snippet actions()}
<div class="flex flex-row items-center">
<div class="form-control flex-row items-center">
<label class="label cursor-pointer" for="autoSync">
<span> Auto sync</span>
<input
type="checkbox"
class="toggle {$stateStore.autoSync ? 'btn-secondary' : 'toggle-primary'} ml-1"
id="autoSync"
bind:checked={$inputStateStore.autoSync} />
</label>
</div>
{#if !$stateStore.autoSync}
<button
class="btn btn-secondary btn-xs mr-1"
title="Sync Diagram ({cmdKey} + Enter)"
aria-label="Sync Diagram"
data-cy="sync"
onclick={syncDiagram}><i class="fas fa-sync"></i></button>
{/if}
{#if !$stateStore.autoSync}
<button
class="btn btn-secondary btn-xs mr-1"
title="Sync Diagram ({cmdKey} + Enter)"
data-cy="sync"
on:click={syncDiagram}><i class="fas fa-sync" /></button>
{/if}
<button
class="btn btn-secondary btn-xs"
title="View documentation for {docKey.replace('Diagram', '')} diagram">
<a target="_blank" href={docURL} data-cy="docs">
<i class="fas fa-book mr-1" />Docs
</a>
</button>
</div>
class="btn btn-secondary btn-xs"
title="View documentation for {docKey.replace('Diagram', '')} diagram">
<a target="_blank" href={docURL} data-cy="docs">
<i class="fas fa-book mr-1"></i>Docs
</a>
</button>
</div>
{/snippet}
<Editor />
</Card>
@@ -183,46 +185,47 @@
<Actions />
</div>
</div>
<div id="resizeHandler" class="hidden md:block" />
<div id="resizeHandler" class="hidden md:block"></div>
<div class="flex flex-1 flex-col overflow-hidden">
<Card title="Diagram" isCloseable={false}>
<div slot="actions" class="flex flex-row items-center gap-2">
<label
class="label flex cursor-pointer gap-1 py-0"
title="Rough mode is in beta. Features like clickable nodes, Pan & Zoom, will be disabled."
for="rough">
<span>Rough</span>
<input
type="checkbox"
class="toggle {$stateStore.rough ? 'btn-secondary' : 'toggle-primary'}"
id="rough"
bind:checked={$inputStateStore.rough} />
</label>
<label
class="label flex cursor-pointer gap-1 py-0"
title={$stateStore.rough ? 'Pan & Zoom is disabled in rough mode.' : ''}
for="panZoom">
<span>Pan & Zoom</span>
<input
type="checkbox"
class="toggle {$stateStore.panZoom ? 'btn-secondary' : 'toggle-primary'}"
id="panZoom"
disabled={$stateStore.rough}
bind:checked={$inputStateStore.panZoom} />
</label>
<a
href={`${base}/view#${$stateStore.serialized}`}
target="_blank"
class="btn btn-secondary btn-xs gap-1"
title="View diagram in new page"><i class="fas fa-external-link-alt" />Full screen</a>
<a
href={`${MCBaseURL}/app/plugin/save?state=${$stateStore.serialized}`}
target="_blank"
class="btn btn-secondary btn-xs gap-1 bg-[#FF3570]"
title="Save diagram in Mermaid Chart"
><img src="./mermaidchart-logo.svg" class="h-5 w-5" alt="Mermaid chart logo" />Save to
Mermaid Chart</a>
</div>
<Card title="Diagram" isClosable={false}>
{#snippet actions()}
<div class="flex flex-row items-center gap-2">
<label
class="label flex cursor-pointer gap-1 py-0"
title="Rough mode is in beta. Features like clickable nodes and ZenUML diagram will not work."
for="rough">
<span>Rough</span>
<input
type="checkbox"
class="toggle {$stateStore.rough ? 'btn-secondary' : 'toggle-primary'}"
id="rough"
bind:checked={$inputStateStore.rough} />
</label>
<label class="label flex cursor-pointer gap-1 py-0" for="panZoom">
<span>Pan & Zoom</span>
<input
type="checkbox"
class="toggle {$stateStore.panZoom ? 'btn-secondary' : 'toggle-primary'}"
id="panZoom"
bind:checked={$inputStateStore.panZoom} />
</label>
<a
href={`${base}/view#${$stateStore.serialized}`}
target="_blank"
class="btn btn-secondary btn-xs gap-1"
title="View diagram in new page"
><i class="fas fa-external-link-alt"></i>Full screen</a>
{#if env.isEnabledMermaidChartLinks}
<a
href={`${MCBaseURL}/app/plugin/save?state=${$stateStore.serialized}`}
target="_blank"
class="btn btn-secondary btn-xs gap-1 bg-[#FF3570]"
title="Save diagram in Mermaid Chart"
><img src="./mermaidchart-logo.svg" class="h-5 w-5" alt="Mermaid chart logo" />Save
to Mermaid Chart</a>
{/if}
</div>
{/snippet}
<div class="flex-1 overflow-auto">
<View />

View File

@@ -5,4 +5,8 @@
onMount(initHandler);
</script>
<svelte:head>
<meta name="robots" content="noindex" />
</svelte:head>
<View />

View File

@@ -1,9 +1,11 @@
import 'vitest-dom/extend-expect';
import '@testing-library/jest-dom/vitest';
import { beforeAll, vi } from 'vitest';
import 'vitest-dom/extend-expect';
// TODO: Remove once https://github.com/sveltejs/kit/issues/6259 is closed.
beforeAll(() => {
vi.mock('$app/environment', () => ({
browser: 'window' in globalThis
browser: 'window' in globalThis,
dev: true
}));
});

View File

@@ -1,3 +1,4 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:
Sitemap: https://mermaid.live/sitemap.xml
Disallow:

13
static/sitemap.xml Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<url>
<loc>https://mermaid.live/</loc>
<loc>https://mermaid.live/edit</loc>
</url>
</urlset>

View File

@@ -1,8 +1,9 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { svelteTesting } from '@testing-library/svelte/vite';
import { defineConfig } from 'vitest/config';
export default defineConfig({
plugins: [sveltekit()],
plugins: [sveltekit(), svelteTesting()],
envPrefix: 'MERMAID_',
optimizeDeps: { include: ['mermaid'] },
server: {

3214
yarn.lock

File diff suppressed because it is too large Load Diff