Compare commits

..
Author SHA1 Message Date
Sidharth Vinod fb3b758389 fix: Case chang 2022-11-15 15:00:06 +05:30
Sidharth Vinod 9d701c2d40 chore: Capitalize components to be inline with svelte standards 2022-11-14 13:27:34 +05:30
193 changed files with 9497 additions and 14664 deletions
+1 -10
View File
@@ -2,13 +2,4 @@
**/.git
**/.svelte-kit
**/dist
**/docs
**/.github
**/.husky
**/.vscode
Dockerfile
.dockerignore
docker-compose.yml
README.md
**/docs
-9
View File
@@ -1,9 +0,0 @@
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'
# 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
+50
View File
@@ -0,0 +1,50 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
// 'plugin:@typescript-eslint/recommended-requiring-type-checking',
'prettier'
],
plugins: ['svelte3', 'tailwindcss', '@typescript-eslint', 'es', 'vitest'],
ignorePatterns: [
'docs/*',
'*.cjs',
'*.js',
'*.md',
'snapshots.js',
'svelte.config.js',
'renovate.json',
'package.json',
'tsconfig.json'
],
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
settings: {
'svelte3/typescript': () => require('typescript')
},
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
tsconfigRootDir: __dirname,
project: ['./tsconfig.json'],
extraFileExtensions: ['.svelte'],
allowAutomaticSingleRunInference: true
},
env: {
browser: true,
es2020: true
},
rules: {
'@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']
}
};
+1 -1
View File
@@ -12,6 +12,6 @@ Describe the way your implementation works or what design decisions you made if
Make sure you
- [ ] :book: have read the [contribution guidelines](https://mermaid.js.org/community/contributing.html)
- [ ] :book: have read the [contribution guidelines](https://github.com/mermaid-js/mermaid/blob/master/CONTRIBUTING.md)
- [ ] :computer: have added unit/e2e tests (if appropriate)
- [ ] :bookmark: targeted `develop` branch
+14 -38
View File
@@ -14,49 +14,25 @@ jobs:
- name: Checkout
uses: actions/checkout@v3
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@v3
with:
node-version-file: '.node-version'
cache: pnpm
- name: Setup Pages
uses: actions/configure-pages@v5
with:
static_site_generator: sveltekit
node-version: '16'
cache: yarn
- name: Build & Deploy
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/
pnpm install
pnpm build
yarn install
version=$(yarn version --patch --no-git-tag-version | grep "New version" | cut -d':' -f 2)
yarn build
yarn run lint
cd ..
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
if: ${{ github.ref == 'refs/heads/master' }}
with:
path: ./docs
deploy:
if: ${{ github.ref == 'refs/heads/master' }}
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
# Grant GITHUB_TOKEN the permissions required to make a Pages deployment
permissions:
pages: write # to deploy to Pages
id-token: write # to verify the deployment originates from an appropriate source
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs
keep_files: true
+53 -43
View File
@@ -2,11 +2,13 @@ name: Docker
on:
push:
# Publish `master` as Docker `latest` image.
branches:
# Publish `master` as Docker `latest` image.
- master
# Publish `develop` as Docker `nightly` image.
- develop
# Publish `v1.2.3` tags as releases.
tags:
- v*
# Run tests for all PRs to master and develop.
pull_request:
@@ -14,46 +16,54 @@ on:
- master
- develop
env:
IMAGE_NAME: mermaid-live-editor
jobs:
docker:
test:
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'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- 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' }}
- 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'
- uses: actions/checkout@v3
- name: Run tests
run: |
docker build . --file Dockerfile
push:
# Ensure test job passes before pushing image.
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v3
- name: Build image
run: docker build . --file Dockerfile --tag $IMAGE_NAME
- name: Log into registry
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Push image
run: |
IMAGE_ID=ghcr.io/${{ github.repository_owner }}/$IMAGE_NAME
# Change all uppercase to lowercase
IMAGE_ID=$(echo $IMAGE_ID | tr '[A-Z]' '[a-z]')
# Strip git ref prefix from version
VERSION=$(echo "${{ github.ref }}" | sed -e 's,.*/\(.*\),\1,')
# Strip "v" prefix from tag name
[[ "${{ github.ref }}" == "refs/tags/"* ]] && VERSION=$(echo $VERSION | sed -e 's/^v//')
# Use Docker `latest` tag convention
[ "$VERSION" == "master" ] && VERSION=latest
echo IMAGE_ID=$IMAGE_ID
echo VERSION=$VERSION
docker tag $IMAGE_NAME $IMAGE_ID:$VERSION
docker push $IMAGE_ID:$VERSION
-21
View File
@@ -1,21 +0,0 @@
name: Create release pull request
on:
push:
branches:
- develop
jobs:
productionPromotion:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
ref: master
- name: Reset promotion branch
run: |
git fetch origin develop:develop
git reset --hard develop
- name: Create Pull Request
uses: peter-evans/create-pull-request@v5
with:
branch: release-promotion
title: Release live editor
+20
View File
@@ -0,0 +1,20 @@
name: Mark stale issues and pull requests
on:
schedule:
- cron: '0 0 * * 4'
jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v6
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
exempt-issue-labels: 'retained'
exempt-pr-labels: 'retained'
stale-issue-message: 'This issue is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 30 days'
stale-pr-message: 'This pr is stale because it has been open 90 days with no activity. Remove stale label or comment or this will be closed in 30 days'
days-before-stale: 90
days-before-close: 30
days-before-pr-close: -1
+27 -31
View File
@@ -5,51 +5,47 @@ on:
branches:
- master
- develop
merge_group:
jobs:
playwright:
name: 'Playwright Tests'
cypress-run:
runs-on: ubuntu-latest
container:
image: mcr.microsoft.com/playwright:v1.52.0-jammy
strategy:
fail-fast: false
matrix:
# run 3 copies of the current job in parallel
containers: [1, 2, 3]
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v3
- uses: actions/cache@v4
id: pnpm-and-build-cache
- uses: actions/cache@v3
id: yarn-and-build-cache
with:
path: |
~/.cache/Cypress
build
node_modules
key: ${{ runner.os }}-node_modules-build-${{ hashFiles('**/pnpm-lock.yaml') }}
key: ${{ runner.os }}-node_modules-build-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-node_modules-build-
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@v3
with:
node-version-file: '.node-version'
cache: 'pnpm'
node-version: 16
cache: 'yarn'
- name: Install dependencies
run: pnpm install
- name: Build
run: pnpm build
- name: Run Playwright tests
run: pnpm test:e2e
# Install NPM dependencies, cache them correctly
# and run all Cypress tests
- name: Cypress run
uses: cypress-io/github-action@v3
with:
build: yarn build
start: yarn preview
wait-on: 'http://localhost:3000'
record: true
headless: true
parallel: true
env:
CI: true
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: test-results
path: test-results/
retention-days: 7
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
CYPRESS_CI: true
+10 -17
View File
@@ -5,7 +5,6 @@ on:
branches:
- master
- develop
merge_group:
jobs:
unit-tests:
@@ -16,28 +15,22 @@ jobs:
uses: actions/checkout@v3
- uses: actions/cache@v3
id: pnpm-and-build-cache
id: yarn-and-build-cache
with:
path: |
build
node_modules
key: ${{ runner.os }}-node_modules-build-${{ hashFiles('**/pnpm-lock.yaml') }}
key: ${{ runner.os }}-node_modules-build-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-node_modules-build-
- name: Install pnpm
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
- uses: actions/setup-node@v3
with:
node-version-file: '.node-version'
cache: 'pnpm'
node-version: 16
cache: 'yarn'
- name: Install dependencies
run: pnpm install
- name: Lint
run: pnpm lint
- name: Run unit tests
run: pnpm test:unit
- name: Lint & Test
run: |
yarn install
yarn lint
yarn test:unit
+13 -27
View File
@@ -1,32 +1,18 @@
name: Update Browserslist database
name: Update Browserslist
on:
schedule:
- cron: '0 2 1,15 * *'
permissions:
contents: write
pull-requests: write
push:
branches-ignore:
- 'develop'
- 'master'
jobs:
update-browserslist-database:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- uses: actions/checkout@v3
- run: npx browserslist@latest --update-db
- name: Commit changes
uses: EndBug/add-and-commit@v9
with:
fetch-depth: 0
- name: Configure git
run: |
git config --global user.email "action@github.com"
git config --global user.name "GitHub Action"
- name: Update Browserslist database and create PR if applies
uses: c2corg/browserslist-update-action@v2
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
branch: browserslist-update
base_branch: develop
commit_message: 'build: update Browserslist db'
title: 'build: update Browserslist db'
body: Auto-generated by [browserslist-update-action](https://github.com/c2corg/browserslist-update-action/)
labels: 'chores, github action'
author_name: ${{ github.actor }}
author_email: ${{ github.actor }}@users.noreply.github.com
message: 'Update Browserslist'
@@ -0,0 +1,25 @@
name: Update Monaco-editor
on:
workflow_dispatch:
push:
branches:
- 'dependabot/npm_and_yarn/develop/monaco-editor-**'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 18
- name: Update monaco version
run: |
yarn install
node ./bin/update-monaco.js
- name: Commit changes
uses: EndBug/add-and-commit@v9
with:
author_name: ${{ github.actor }}
author_email: ${{ github.actor }}@users.noreply.github.com
message: 'Update Monaco-editor'
+8 -11
View File
@@ -1,17 +1,14 @@
.DS_Store
node_modules/
coverage/
.cache/
.env.local
build/
yarn-error.log
.npmrc
.DS_Store
/.svelte-kit
/build
/coverage
/docs
/functions
/node_modules
/snapshots.js
# Playwright
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
/cypress/downloads
/cypress/videos
/cypress/screenshots
+1 -1
View File
@@ -1,4 +1,4 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
pnpm pre-commit
yarn pre-commit
-1
View File
@@ -1 +0,0 @@
22.15.0
-1
View File
@@ -1,2 +1 @@
engine-strict=true
auto-install-peers=true
+1 -2
View File
@@ -5,5 +5,4 @@ build/**
node_modules/**
coverage/**
__snapshots__/**
snapshots.js
pnpm-lock.yaml
snapshots.js
+1 -3
View File
@@ -3,7 +3,5 @@
"svelteSortOrder": "options-scripts-markup-styles",
"bracketSameLine": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"tailwindStylesheet": "./src/app.css"
"printWidth": 100
}
-10
View File
@@ -1,10 +0,0 @@
{
"servers": {
"svelte": {
"type": "stdio",
"command": "npx",
"args": ["-y", "@sveltejs/mcp"]
}
},
"inputs": []
}
+3 -50
View File
@@ -1,56 +1,9 @@
{
"editor.formatOnSave": true,
"cSpell.blockCheckingWhenLineLengthGreaterThan": 150,
"cSpell.words": [
"appinstalled",
"asyncable",
"Browserslist",
"ckppp",
"corg",
"cssnano",
"esserializer",
"fsegurai",
"gantt",
"gitgraph",
"KROKI",
"localstorage",
"mermaidchart",
"mindmap",
"NEWYEAR",
"Pageview",
"pako",
"panmove",
"panstart",
"panzoom",
"pinchmove",
"pinchstart",
"pzoom",
"roughjs",
"sankey",
"Serde",
"serdes",
"Stackable",
"tailwindcss",
"treemap",
"uparrow",
"xychart",
"zenuml"
],
"vitest.commandLine": "pnpm test:unit",
"cSpell.words": ["asyncable", "mindmap", "pako", "Serde", "serdes"],
"vitest.commandLine": "yarn test:unit",
"vitest.enable": true,
"testing.autoRun.mode": "rerun",
"svelte.enable-ts-plugin": true,
"githubPullRequests.ignoredPullRequestBranches": ["develop"],
"[svelte]": {
"editor.defaultFormatter": "svelte.svelte-vscode"
},
"tailwindCSS.classAttributes": ["class", "className", ".*Classes"],
"tailwindCSS.experimental.classRegex": [
["([\"'`][^\"'`]*.*?[\"'`])", "[\"'`]([^\"'`]*).*?[\"'`]"]
],
"tailwindCSS.emmetCompletions": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.organizeImports": "explicit"
}
"githubPullRequests.ignoredPullRequestBranches": ["develop"]
}
-1
View File
@@ -1 +0,0 @@
mermaid.live
+15 -35
View File
@@ -1,37 +1,17 @@
FROM docker.io/library/node:22.15.0-alpine3.21 AS mermaid-live-editor-dependencies
RUN apk --no-cache add build-base git python3 && \
rm -rf /var/cache/apk/*
RUN corepack enable pnpm
WORKDIR /app
COPY ./package.json .
COPY ./pnpm-lock.yaml .
RUN pnpm install
FROM mermaid-live-editor-dependencies AS mermaid-live-editor-builder
ARG MERMAID_RENDERER_URL
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 . ./
RUN pnpm build
FROM mermaid-live-editor-builder AS mermaid-dev
ENTRYPOINT ["pnpm", "dev"]
FROM nginx:1.28-alpine3.21 AS mermaid
# Two-stage docker container for mermaid-js/mermaid-live-editor
# Build : docker build -t mermaid-js/mermaid-live-editor .
# Run : docker run --name mermaid-live-editor --publish 8080:80 mermaid-js/mermaid-live-editor
# Start : docker start mermaid-live-editor
# Use webbrowser : http://localhost:8080
# Stop : press ctrl + c
# or
# docker stop mermaid-live-editor
FROM node:18.12.0 as mermaid-live-editor-builder
COPY --chown=node:node . /home
WORKDIR /home
RUN yarn install
RUN yarn build
FROM nginx:alpine as mermaid-live-editor-runner
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=mermaid-live-editor-builder /app/docs /usr/share/nginx/html
COPY --from=mermaid-live-editor-builder --chown=nginx:nginx /home/docs /usr/share/nginx/html
+8
View File
@@ -0,0 +1,8 @@
FROM node:18
WORKDIR /app
COPY package.json .
COPY yarn.lock .
RUN npm install
COPY . .
RUN ls
CMD ["yarn", "dev"]
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2020 - 2023 Knut Sveidqvist
Copyright (c) 2020 - 2021 Knut Sveidqvist
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+19 -69
View File
@@ -1,7 +1,10 @@
[![Join our Discord!](https://img.shields.io/static/v1?message=join%20chat&color=9cf&logo=discord&label=discord)](https://discord.gg/sKeNQX4Wtj)
[![Netlify Status](https://api.netlify.com/api/v1/badges/27fa023d-7c73-4a3f-9791-b3b657a47100/deploy-status)](https://app.netlify.com/sites/mermaidjs/deploys)
[![Mermaid Live Editor](https://img.shields.io/endpoint?url=https://dashboard.cypress.io/badge/detailed/2ckppp/master&style=flat&logo=cypress)](https://dashboard.cypress.io/projects/2ckppp/runs) [![Join our Slack!](https://img.shields.io/static/v1?message=join%20chat&color=9cf&logo=slack&label=slack)](https://join.slack.com/t/mermaid-talk/shared_invite/enQtNzc4NDIyNzk4OTAyLWVhYjQxOTI2OTg4YmE1ZmJkY2Y4MTU3ODliYmIwOTY3NDJlYjA0YjIyZTdkMDMyZTUwOGI0NjEzYmEwODcwOTE)[![Netlify Status](https://api.netlify.com/api/v1/badges/27fa023d-7c73-4a3f-9791-b3b657a47100/deploy-status)](https://app.netlify.com/sites/mermaidjs/deploys)
# Mermaid Live Editor
# Contributors are welcome!
If you want to speed up the progress for mermaid-live-editor, join the slack channel and contact knsv.
# mermaid-live-editor
Edit, preview and share mermaid charts/diagrams.
@@ -14,54 +17,25 @@ Edit, preview and share mermaid charts/diagrams.
## Live demo
You can try out a [live version](https://mermaid.live/).
# Contributors are welcome!
If you want to speed up the progress for mermaid-live-editor, join the Discord channel and contact knsv.
You can try out a live version [here](https://mermaid.live/).
## Docker
### Run published image
```bash
docker run --platform linux/amd64 --publish 8000:8080 ghcr.io/mermaid-js/mermaid-live-editor
docker run --publish 8000:80 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
service.
Example:
Default is`https://mermaid.ink`.
Set to empty string to disable PNG and SVG links under Actions
When building, Set the Environment variable MERMAID_RENDERER_URL to the rendering service.
Default is `https://mermaid.ink`
### To configure Kroki Instance URL
When building set the MERMAID_KROKI_RENDERER_URL build argument to your Kroki
instance.
When building, Set the Environment variable MERMAID_KROKI_RENDERER_URL to your Kroki instance.
Default is `https://kroki.io`
Set to empty string to disable Kroki link under Actions
### To configure Analytics
When building set the MERMAID_ANALYTICS_URL build argument to your plausible instance, and MERMAID_DOMAIN to your domain.
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
@@ -69,46 +43,22 @@ if you wish.
docker compose up --build
```
Then open http://localhost:3000
### Building and running images locally
#### Build
```bash
docker build -t mermaid-js/mermaid-live-editor .
```
#### Run
```bash
docker run --detach --name mermaid-live-editor --publish 8080:8080 mermaid-js/mermaid-live-editor
```
Visit: <http://localhost:8080>
#### Stop
```bash
docker stop mermaid-live-editor
```
Then open http://localhost:8000
## Setup
Below link will help you making a copy of the repository in your local system.
[Volta](https://volta.sh) is used for managing node and yarn versions.
https://docs.github.com/en/get-started/quickstart/fork-a-repo
This project is set up using [Yarn](https://classic.yarnpkg.com/en/docs/getting-started):
## Requirements
- [Node.js](https://nodejs.org/en/) current LTS version
- [pnpm](https://pnpm.io/) package manager. Install with `corepack enable pnpm`
```
yarn install
```
## Development
```sh
pnpm install
pnpm dev -- --open
```
yarn dev -- --open
```
This app is created with Svelte Kit.
+1 -1
View File
@@ -6,7 +6,7 @@
# git clone https://github.com/mermaid-js/docs.git
set -e
rm -rf docs
pnpm release
yarn release
pushd .
if [ ! -d ../docs ]; then
echo "Clone https://github.com/mermaid-js/docs to parent folder before continuing."
-16
View File
@@ -1,16 +0,0 @@
{
"$schema": "https://next.shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/app.css",
"baseColor": "slate"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"registry": "https://tw3.shadcn-svelte.com/registry/new-york"
}
+35
View File
@@ -0,0 +1,35 @@
import { defineConfig } from 'cypress';
import fs from 'fs';
import { isFileExist, findFiles } from 'cy-verify-downloads';
export default defineConfig({
projectId: '2ckppp',
viewportWidth: 1440,
viewportHeight: 768,
snapshotFileName: './cypress/snapshots.js',
defaultCommandTimeout: 16000,
requestTimeout: 16000,
retries: {
runMode: 4,
openMode: 0
},
e2e: {
setupNodeEvents(on, config) {
on('task', {
isFileExist,
findFiles,
deleteFile(path) {
fs.rmSync(path);
return null;
},
readFileMaybe(filename) {
if (fs.existsSync(filename)) {
return fs.readFileSync(filename, 'utf8');
}
return null;
}
});
},
baseUrl: 'http://localhost:3000',
specPattern: 'cypress/e2e/**/*.spec.ts'
}
});
+10
View File
@@ -0,0 +1,10 @@
{
"plugins": ["cypress"],
"extends": ["plugin:cypress/recommended"],
"rules": {
"jest/expect-expect": "off"
},
"env": {
"cypress/globals": true
}
}
+48
View File
@@ -0,0 +1,48 @@
import { verifyFileSize } from './util';
describe('Check actions', () => {
beforeEach(() => {
cy.clearLocalStorage();
cy.visit('/edit');
});
it('should update markdown code', () => {
cy.get('#markdown')
.invoke('val')
.then((oldText) => {
cy.get('#editor').click('bottom').type('{enter}C --> HistoryTest');
cy.get('#markdown')
.invoke('val')
.then((newText) => {
expect(oldText).to.not.eq(newText);
});
});
});
it('should load gists from URL', () => {
cy.get('#gist').type('https://gist.github.com/sidharthv96/6268a23e673a533dcb198f241fd7012a');
cy.contains('Load Gist').click();
cy.contains('Go shopping!!');
});
it('should download png and svg', () => {
cy.clock(new Date(2022, 0, 1).getTime());
cy.get(`#downloadPNG`).click();
verifyFileSize('diagram', 'png', 21_000);
cy.get(`#downloadSVG`).click();
verifyFileSize('diagram', 'svg', 10_000);
// Verify downloaded file is different for different diagrams
cy.contains('Sample Diagrams').click();
cy.contains('ER').click();
cy.get(`#downloadPNG`).click();
verifyFileSize('diagram', 'png', 46_000);
cy.get(`#downloadSVG`).click();
verifyFileSize('diagram', 'svg', 12_000);
cy.clock().invoke('restore');
});
});
+86
View File
@@ -0,0 +1,86 @@
import { getEditor, cmd } from './util';
describe('Auto sync tests', () => {
beforeEach(() => {
cy.clearLocalStorage();
cy.visit('/');
});
it('should dim diagram when code is edited', () => {
cy.contains('Auto sync').click();
cy.get('#view').should('not.have.class', 'outOfSync');
cy.get('#errorContainer').should('not.exist');
getEditor({ bottom: true, newline: true }).type(' C --> Test');
cy.get('#view').should('have.class', 'outOfSync');
cy.get('#errorContainer').should('contain.text', 'Diagram out of sync.');
cy.getLocalStorage('codeStore').snapshot();
});
it('should update diagram when shortcut is used', () => {
cy.contains('Auto sync').click();
cy.get('#view').should('not.have.class', 'outOfSync');
getEditor().type(' C --> Test');
cy.get('#view').should('have.class', 'outOfSync');
cy.get('#errorContainer').should('contain.text', 'Diagram out of sync.');
getEditor().type(`${cmd}{enter}`);
cy.get('#errorContainer').should('not.exist');
cy.get('#view').should('not.have.class', 'outOfSync');
});
it('should show/hide sync button with auto sync', () => {
cy.get('[data-cy=sync]').should('not.exist');
cy.contains('Auto sync').click();
cy.get('[data-cy=sync]').should('exist');
cy.get('#autoSync').check();
cy.get('[data-cy=sync]').should('not.exist');
});
it('should not dim diagram when code is in sync', () => {
cy.contains('Auto sync').click();
cy.get('#view').should('not.have.class', 'outOfSync');
getEditor().type(' C --> Test');
cy.get('#view').should('have.class', 'outOfSync');
cy.get('[data-cy=sync]').click();
cy.get('#view').should('not.have.class', 'outOfSync');
cy.get('#autoSync').check();
getEditor().type('ing');
cy.get('#view').should('not.have.class', 'outOfSync');
cy.getLocalStorage('codeStore').snapshot();
});
it('supports commenting code out/in', () => {
getEditor().type(`{uparrow}${cmd}/`);
cy.get('#view').contains('Car').should('not.exist');
getEditor().type(`{uparrow}${cmd}/`);
cy.get('#view').contains('Car').should('exist');
});
it('supports editing code when code is incorrect', () => {
cy.visit(
'/edit#pako:eNpljjEKwzAMRa8SNOcEnlt6gK5eVFvYJsgOqkwpIXevg9smEE1PnyfxF3DFExgISW-CczQ2D21cYU7a-SGYXRwyvTp9jUhuKlVP-eHy7zA-leQsMEmg_QOM0BLG5FujZVMsaCQmC6ahR5ks2Lw2r84ela4-aREwKpVGwKrl_s7ut3fnkjAIcg_XDzuaUhs'
);
cy.get('#errorContainer').should('not.exist');
getEditor({ newline: true }).type(`branch test`);
cy.get('#editor').contains('branch test').should('exist');
cy.get('#errorContainer')
.contains(
'Error: Trying to checkout branch which is not yet created. (Help try using "branch master")'
)
.should('exist');
});
});
describe('Pan and Zoom', () => {
beforeEach(() => {
cy.clearLocalStorage();
cy.visit('/');
});
it('should toggle pan and zoom', () => {
cy.get('#svg-pan-zoom-reset-pan-zoom').should('not.exist');
cy.contains('Pan & Zoom').click();
cy.get('#svg-pan-zoom-reset-pan-zoom').should('exist');
cy.contains('Pan & Zoom').click();
cy.get('#svg-pan-zoom-reset-pan-zoom').should('not.exist');
});
});
+36
View File
@@ -0,0 +1,36 @@
describe('Editor docs tests', () => {
beforeEach(() => {
cy.on('uncaught:exception', () => {
return false;
});
cy.clearLocalStorage();
cy.visit('/edit');
cy.contains('Sample Diagrams').click();
});
it('Test default loading', () => {
cy.get(`[data-cy=docs][href^="https://mermaid-js.github.io/mermaid"]`).should('exist');
});
it('Test to see if the correct URL loads when changing from one diagram to other', () => {
cy.contains('Flow').click();
cy.get(`[data-cy=docs][href$="/#/flowchart"]`).should('exist');
cy.contains('Config').click();
cy.get(`[data-cy=docs][href$="/#/flowchart?id=configuration"]`).should('exist');
cy.contains('Sequence').click();
cy.get(`[data-cy=docs][href$="/#/sequenceDiagram?id=configuration"]`).should('exist');
cy.contains('Code').click();
cy.get(`[data-cy=docs][href$="/#/sequenceDiagram"]`).should('exist');
});
it("Test to check URLs for a case where config URL doesn't exist", () => {
cy.contains('State').click();
cy.get(`[data-cy=docs][href$="/#/stateDiagram"]`).should('exist');
cy.contains('Config').click();
cy.get(`[data-cy=docs][href$="/#/stateDiagram"]`).should('exist');
});
});
+105
View File
@@ -0,0 +1,105 @@
import { getEditor, verifyFileSnapshot } from './util';
describe('Save History', () => {
beforeEach(() => {
cy.clock(new Date(2022, 0, 1).getTime());
cy.clearLocalStorage();
cy.visit('/edit');
cy.contains('Actions').click();
cy.contains('History').click();
});
afterEach(() => {
cy.clock().invoke('restore');
});
it('should load history from localstorage', () => {
cy.setLocalStorage(
'manualHistoryStore',
'[{"state":{"code":"graph TD\\n A[Halloween] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":false},"time":0,"type":"manual","id":"d7ea820e-21dd-418a-b984-fd58acde09df","name":"hollow-art"},{"state":{"code":"graph TD\\n A[Christmas] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":true},"time":0,"type":"manual","id":"b749ffc6-522b-4a44-86cf-7c1ffc3146b3","name":"helpful-ocean"}]'
);
cy.setLocalStorage(
'autoHistoryStore',
'[{"state":{"code":"graph TD\\n A[New Year] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":false},"time":0,"type":"auto","id":"69ea820e-522b-4a44-86cf-fd58acde09df","name":"barking-dog"},{"state":{"code":"graph TD\\n A[Christmas] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":true},"time":0,"type":"manual","id":"x749ffc6-21dd-418a-b984-7c1ffc3146b3","name":"needy-mosquito"}]'
);
cy.reload();
cy.contains('Actions').click();
cy.contains('History').click();
cy.get('#historyList').find('li').should('have.length', 2);
cy.get('#historyList').find('No items in History').should('not.exist');
cy.get('#historyList').contains('helpful-ocean');
cy.get('#historyList').contains('hollow-art');
cy.contains('Restore').click();
cy.contains('Halloween');
cy.contains('Timeline').click();
cy.get('#historyList').find('li').should('have.length', 2);
cy.get('#historyList').find('No items in History').should('not.exist');
cy.get('#historyList').contains('needy-mosquito');
cy.get('#historyList').contains('barking-dog');
cy.contains('Restore').click();
cy.contains('New Year');
});
it('should save when clicked', () => {
cy.get('#historyList').find('li').should('have.length', 0);
cy.get('#historyList').contains('No items in History');
cy.get('#saveHistory').click();
cy.get('#historyList').find('No items in History').should('not.exist');
cy.get('#historyList').find('li').should('have.length', 1);
cy.get('#saveHistory').click();
cy.on('window:alert', (str) => {
expect(str).to.equal('State already saved.');
});
cy.on('window:confirm', () => true);
getEditor().type(' C --> HistoryTest');
cy.get('#saveHistory').click();
cy.get('#historyList').find('li').should('have.length', 2);
});
it('should be able to restore and delete', () => {
cy.get('#saveHistory').click();
getEditor().type(' C --> HistoryTest');
cy.get('#historyList').find('No items in History').should('not.exist');
cy.get('#historyList').find('li').should('have.length', 1);
cy.contains('HistoryTest');
cy.contains('Restore').click();
cy.contains('HistoryTest').should('not.exist');
cy.contains('Delete').click();
cy.get('#historyList').find('li').should('have.length', 0);
cy.get('#historyList').contains('No items in History');
cy.get('#saveHistory').click();
getEditor().type(' C --> HistoryTest');
cy.get('#saveHistory').click();
cy.get('#editor').type('ing');
cy.get('#clearHistory').click();
cy.on('window:alert', (str) => {
expect(str).to.equal('Clear all saved items?');
});
cy.on('window:confirm', () => true);
cy.get('#historyList').contains('No items in History');
});
// TODO: Fix #639
xit('should auto save history', () => {
getEditor().type(' C --> HistoryTest');
cy.tick(70000);
cy.contains('Timeline').click();
cy.get('#historyList').find('li').should('have.length', 1);
cy.get('#editor').type('ing');
cy.tick(70000);
cy.get('#historyList').find('li').should('have.length', 2);
for (let i = 0; i < 31; i++) {
cy.get('#editor').type('.');
cy.tick(70000);
}
cy.get('#historyList').find('li').should('have.length', 30);
});
it('should download history', () => {
cy.get('#saveHistory').click();
cy.get(`#downloadHistory`).click();
verifyFileSnapshot('history', 'json', 'A[Christmas] -->|Get money| B(Go shopping)');
});
});
+122
View File
@@ -0,0 +1,122 @@
import { toBase64 } from 'js-base64';
describe('Site Loads', () => {
beforeEach(() => {
cy.clearLocalStorage();
cy.visit('/');
});
it('Check Home page load', () => {
cy.url().should('include', '/edit');
cy.contains('History').click();
cy.getLocalStorage('codeStore').snapshot();
});
it('Check Redirect from old URL', () => {
cy.visit(
'/#/edit/eyJjb2RlIjoiZ3JhcGggVERcbiAgICBBW0NocmlzdG1hc10gLS0-fEdldCBtb25leXwgQihHbyBzaG9wcGluZylcbiAgICBCIC0tPiBDe0xldCBtZSB0aGlua31cbiAgICBDIC0tPnxPbmV8IERbTGFwdG9wXVxuICAgIEMgLS0-fFR3b3wgRVtpUGhvbmVdXG4gICAgQyAtLT58VGhyZWV8IEZbZmE6ZmEtY2FyIENhcl0iLCJtZXJtYWlkIjp7InRoZW1lIjoiZGVmYXVsdCJ9LCJ1cGRhdGVFZGl0b3IiOmZhbHNlfQ'
);
cy.url().should('include', '/edit#pako:eNp');
});
it('should load sample diagrams when clicked', () => {
cy.contains('Sample Diagrams').click();
cy.contains('Pie').click();
cy.contains('pie title Pets adopted by volunteers');
cy.contains('Class').click();
cy.contains('classDiagram');
});
(Cypress.env('CI') === 'true' ? describe : describe.skip)('github', () => {
it('should load diagram from gist', () => {
cy.visit(`/edit?gist=https://gist.github.com/sidharthv96/6268a23e673a533dcb198f241fd7012a`);
cy.contains('History').click();
cy.contains('Go shopping!!');
cy.contains('Revisions');
cy.contains('sidharthv96 v8f8f1e2');
cy.contains('sidharthv96 v7851e19');
cy.getLocalStorage('codeStore').snapshot();
});
it('should load diagram from gist revision', () => {
cy.visit(
'/edit?gist=https://gist.github.com/sidharthv96/6268a23e673a533dcb198f241fd7012a/ec9b4ab0e41e4ff6287326cd3cb47affd7851e19'
);
cy.contains('History').click();
cy.contains('Party');
cy.contains('Revisions');
cy.contains('sidharthv96 v7851e19');
cy.getLocalStorage('codeStore').snapshot();
});
it('should load diagram from raw files', () => {
cy.visit(
'/edit?code=https://gist.githubusercontent.com/sidharthv96/6268a23e673a533dcb198f241fd7012a/raw/4eb03887e6a41397e80bdcdbf94017c498f8f1e2/code.mmd&config=https://gist.githubusercontent.com/sidharthv96/6268a23e673a533dcb198f241fd7012a/raw/4eb03887e6a41397e80bdcdbf94017c498f8f1e2/config.json'
);
cy.contains('Party');
cy.getLocalStorage('codeStore').snapshot();
});
});
// Disabled temporarily. Should be enabled after the issue is fixed in Mermaid.
// it('should prevent setting the "securityLevel" option via URL', () => {
// const b64State = toBase64(
// `{"code":"graph TD\\nA[\\"<img src='https://via.placeholder.com/64' width=64 />\\"]","mermaid":"{\\"securityLevel\\": \\"loose\\", \\"theme\\": \\"forest\\"}","autoSync":true,"updateDiagram":true}`,
// true
// );
// cy.on('window:confirm', () => true);
// cy.visit(`/edit#${b64State}`);
// cy.contains('Config').click();
// cy.contains('forest');
// cy.contains('securityLevel').should('not.exist');
// cy.get('#view').find('img').should('not.exist');
// cy.get('#view').contains('<img');
// cy.get('#view').contains(`src='https://via.placeholder.com/64'`);
// });
it.skip('should allow persisting "securityLevel" using confirm dialogue', () => {
const b64State = toBase64(
`{"code":"graph TD\\nA[\\"<img src='https://dummyimage.com/64' width=64/>\\"]","mermaid":"{\\"securityLevel\\": \\"loose\\", \\"theme\\": \\"forest\\"}","autoSync":true,"updateDiagram":true}`,
true
);
cy.on('window:confirm', () => false);
cy.visit(`/edit#${b64State}`);
cy.get('#editor').type(' ');
cy.contains('Config').click();
cy.contains('forest');
cy.contains('securityLevel');
cy.get('#view').find('img').should('be.visible');
});
it('should show troubleshooting steps if loading fails', () => {
cy.visit('/#/edit/eyJjb2RlIjoiZ3JhcGggVERcbiAg');
cy.reload(true);
cy.contains('Please Click here to Raise an issue in github.');
});
it('should load uncompressed URL', () => {
cy.visit(
'/edit/#eyJjb2RlIjoiZ3JhcGggVERcbiAgICBBW05ldyBZZWFyXSAtLT58R2V0IG1vbmV5fCBCKEdvIHNob3BwaW5nKVxuICAgIEIgLS0-IEN7TGV0IG1lIHRoaW5rfVxuICAgIEMgLS0-fE9uZXwgRFtMYXB0b3BdXG4gICAgQyAtLT58VHdvfCBFW2lQaG9uZV1cbiAgICBDIC0tPnxUaHJlZXwgRltmYTpmYS1jYXIgQ2FyXSIsIm1lcm1haWQiOiJ7XG4gIFwidGhlbWVcIjogXCJkZWZhdWx0XCJcbn0iLCJ1cGRhdGVFZGl0b3IiOmZhbHNlLCJhdXRvU3luYyI6dHJ1ZSwidXBkYXRlRGlhZ3JhbSI6ZmFsc2V9'
);
cy.contains('New Year');
cy.visit(
'/edit#eyJjb2RlIjoiY2xhc3NEaWFncmFtXG4gICAgQW5pbWFsIDx8LS0gRHVja1xuICAgIEFuaW1hbCA8fC0tIEZpc2hcbiAgICBBbmltYWwgPHwtLSBaZWJyYVxuICAgIEFuaW1hbCA6ICtpbnQgYWdlXG4gICAgQW5pbWFsIDogK1N0cmluZyBnZW5kZXJcbiAgICBBbmltYWw6ICtpc01hbW1hbCgpXG4gICAgQW5pbWFsOiArbWF0ZSgpXG4gICAgY2xhc3MgRHVja3tcbiAgICAgICtTdHJpbmcgYmVha0NvbG9yXG4gICAgICArc3dpbSgpXG4gICAgICArcXVhY2soKVxuICAgIH1cbiAgICBjbGFzcyBGaXNoe1xuICAgICAgLWludCBzaXplSW5GZWV0XG4gICAgICAtY2FuRWF0KClcbiAgICB9XG4gICAgY2xhc3MgWmVicmF7XG4gICAgICArYm9vbCBpc193aWxkXG4gICAgICArcnVuKClcbiAgICB9XG4gICAgICAgICAgICAiLCJtZXJtYWlkIjoie1xuICBcInRoZW1lXCI6IFwiZGFya1wiXG59IiwidXBkYXRlRWRpdG9yIjpmYWxzZSwiYXV0b1N5bmMiOnRydWUsInVwZGF0ZURpYWdyYW0iOmZhbHNlfQ'
);
cy.contains('Animal');
cy.visit(
'/edit/#base64:eyJjb2RlIjoiZ3JhcGggVERcbiAgICBBW05ldyBZZWFyXSAtLT58R2V0IG1vbmV5fCBCKEdvIHNob3BwaW5nKVxuICAgIEIgLS0-IEN7TGV0IG1lIHRoaW5rfVxuICAgIEMgLS0-fE9uZXwgRFtMYXB0b3BdXG4gICAgQyAtLT58VHdvfCBFW2lQaG9uZV1cbiAgICBDIC0tPnxUaHJlZXwgRltmYTpmYS1jYXIgQ2FyXSIsIm1lcm1haWQiOiJ7XG4gIFwidGhlbWVcIjogXCJkZWZhdWx0XCJcbn0iLCJ1cGRhdGVFZGl0b3IiOmZhbHNlLCJhdXRvU3luYyI6dHJ1ZSwidXBkYXRlRGlhZ3JhbSI6ZmFsc2V9'
);
cy.contains('New Year');
});
it('should load compressed URL', () => {
cy.visit(
'/edit#pako:eNpVkM2KwkAQhF-l6dMK5gVyEDRxvYi7sF6WjIcm0zqDzg_jBJEk725Hd2G3Tw31VVFUj23QjCWeEkUD-1p5kFs2O77BN1M6QFEshg1ncMHzfYDV2ybA1YQYrT_NXvhqgqDqtxPGkI315_ElVU__h-cB6mZLMYd4-Kvsb2GAdWM_jcT_V0xicb03RyqPVLSUoJI-OEfHyZHV0rqfDAqzYccKS3k1pbNC5Ufhuqgp81rbHBJKxuXKc6Quh6-7b7HMqeNfqLYkC7gfanwAlW1ZvQ'
);
cy.contains('New Year');
cy.visit(
'/edit#pako:eNptkU1PwzAMhv9K5BOI9Q9EXBDbJA477YYqITcxndV8QD40weh_Jy1rGR0-OY_tV2_sEyivCSQogzGuGduAtnaixINji0bcf1WVWGfVXdMtx8M1faYm4B8sxR27JLClJd6nwK4VLTlN4bI4jMQd2pLe3C4KFhNNcLQ92jv9ADGLNoTdozc-zIV4ZDsNlud7RtVN7_5Sb_jYrFcN3iN_0pPbEqUZK3QbTP_Ojyv4NdR4bwTHlyMbPcOQ3WJ2CliBpWCRdbnLqFJDOpClGmRJNYauhtr1pS-_6bKMjebkA8hXNJFWgDn5_YdTIFPINDWdb3vu6r8BaWOZRQ'
);
cy.contains('Animal');
});
});
+56
View File
@@ -0,0 +1,56 @@
describe('Test themes', () => {
describe('Test light themes', () => {
beforeEach(() => {
cy.clearLocalStorage();
cy.visit('/edit', {
onBeforeLoad(win) {
cy.stub(win, 'matchMedia')
.callThrough()
.withArgs('(prefers-color-scheme: dark)')
.returns({
matches: false
});
}
});
cy.contains('Change Theme').click();
});
it('should set light theme as default', () => {
cy.contains('light').parent().should('have.class', 'bordered');
cy.contains('dark').parent().should('not.have.class', 'bordered');
cy.getLocalStorage('themeStore').snapshot();
});
it('should change themes when clicked', () => {
cy.contains('light').parent().should('have.class', 'bordered');
cy.contains('cupcake').click();
cy.contains('cupcake').parent().should('have.class', 'bordered');
cy.contains('light').parent().should('not.have.class', 'bordered');
cy.contains('dark').parent().should('not.have.class', 'bordered');
cy.getLocalStorage('themeStore').snapshot();
});
});
describe('Test dark mode', () => {
beforeEach(() => {
cy.clearLocalStorage();
cy.visit('/edit', {
onBeforeLoad(win) {
cy.stub(win, 'matchMedia')
.callThrough()
.withArgs('(prefers-color-scheme: dark)')
.returns({
matches: true
});
}
});
cy.contains('Change Theme').click();
});
it('should set dark theme as default', () => {
cy.contains('light').parent().should('not.have.class', 'bordered');
cy.contains('dark').parent().should('have.class', 'bordered');
cy.getLocalStorage('themeStore').snapshot();
});
});
});
+41
View File
@@ -0,0 +1,41 @@
export const cmd = `{${Cypress.platform === 'darwin' ? 'meta' : 'ctrl'}}`;
export const getEditor = ({ bottom = true, newline = false } = {}) =>
cy
.get('#editor textarea:first')
.click()
.focused()
.type(`${bottom ? '{pageDown}' : cmd}`)
.type(`${newline ? '{enter}' : cmd}`);
const downloadsFolder = Cypress.config('downloadsFolder');
export const verifyFileSize = (
fileType: 'history' | 'diagram',
extension: string,
size: number
) => {
const fileName = `mermaid-${fileType}-2022-01-01-000000.${extension}`;
const filePath = `${downloadsFolder}/${fileName}`;
cy.verifyDownload(fileName);
cy.readFile(filePath, null, {
log: false
}).then((buffer) => expect((buffer as ArrayBuffer).byteLength).to.be.gt(size));
cy.task('deleteFile', filePath);
};
export const verifyFileSnapshot = (
fileType: 'history' | 'diagram',
extension: string,
content: string
) => {
const fileName = `mermaid-${fileType}-2022-01-01-000000.${extension}`;
const filePath = `${downloadsFolder}/${fileName}`;
cy.verifyDownload(fileName);
cy.readFile(filePath, null, {
log: false
}).then((buffer) =>
expect(new TextDecoder('utf-8').decode(buffer as ArrayBuffer)).to.contain(content)
);
cy.task('deleteFile', filePath);
};
+5
View File
@@ -0,0 +1,5 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io",
"body": "Fixtures are a great way to mock data for responses to routes"
}
+52
View File
@@ -0,0 +1,52 @@
module.exports = {
"Site Loads": {
"Check Home page load": {
"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]\\n \",\"mermaid\":\"{\\n \\\"theme\\\": \\\"default\\\"\\n}\",\"autoSync\":true,\"updateDiagram\":true}"
},
"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}"
},
"should load diagram from gist": {
"1": "{\"code\":\"graph TD\\n A[Party] -->|Get money| B(Go shopping!!)\\n \",\"mermaid\":\"{\\n \\\"theme\\\": \\\"forest\\\",\\n \\\"test\\\": \\\"hello world\\\"\\n}\",\"autoSync\":true,\"updateDiagram\":true,\"loader\":{\"type\":\"gist\",\"config\":{\"url\":\"https://gist.github.com/sidharthv96/6268a23e673a533dcb198f241fd7012a\"}}}"
},
"should load diagram from gist revision": {
"1": "{\"code\":\"graph TD\\n A[Party] -->|Get money| B(Go shopping)\\n \",\"mermaid\":\"{\\n \\\"theme\\\": \\\"forest\\\",\\n \\\"test\\\": \\\"hello\\\"\\n}\",\"autoSync\":true,\"updateDiagram\":true,\"loader\":{\"type\":\"gist\",\"config\":{\"url\":\"https://gist.github.com/sidharthv96/6268a23e673a533dcb198f241fd7012a/ec9b4ab0e41e4ff6287326cd3cb47affd7851e19\"}}}"
},
"should load diagram from raw files": {
"1": "{\"code\":\"graph TD\\n A[Party] -->|Get money| B(Go shopping!!)\\n \",\"mermaid\":\"{\\n \\\"theme\\\": \\\"forest\\\",\\n \\\"test\\\": \\\"hello world\\\"\\n}\",\"autoSync\":true,\"updateDiagram\":true,\"loader\":{\"type\":\"files\",\"config\":{\"codeURL\":\"https://gist.githubusercontent.com/sidharthv96/6268a23e673a533dcb198f241fd7012a/raw/4eb03887e6a41397e80bdcdbf94017c498f8f1e2/code.mmd\",\"configURL\":\"https://gist.githubusercontent.com/sidharthv96/6268a23e673a533dcb198f241fd7012a/raw/4eb03887e6a41397e80bdcdbf94017c498f8f1e2/config.json\"}}}"
}
},
"__version": "10.10.0",
"Auto sync tests": {
"should dim diagram when code is edited": {
"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]\\n C --> Test\",\"mermaid\":\"{\\n \\\"theme\\\": \\\"default\\\"\\n}\",\"autoSync\":false,\"updateDiagram\":false}"
},
"should not dim diagram when code is in sync": {
"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]\\n C --> Testing\",\"mermaid\":\"{\\n \\\"theme\\\": \\\"default\\\"\\n}\",\"autoSync\":true,\"updateDiagram\":false}"
}
},
"Test themes": {
"should set light theme as default": {
"1": "{\"theme\":\"light\",\"isDark\":false}"
},
"should change themes when clicked": {
"1": "{\"theme\":\"cupcake\",\"isDark\":false}"
},
"should set dark theme as default": {
"1": "{\"theme\":\"dark\",\"isDark\":true}"
},
"Test light themes": {
"should set light theme as default": {
"1": "{\"theme\":\"light\",\"isDark\":false}"
},
"should change themes when clicked": {
"1": "{\"theme\":\"cupcake\",\"isDark\":false}"
}
},
"Test dark mode": {
"should set dark theme as default": {
"1": "{\"theme\":\"dark\",\"isDark\":true}"
}
}
}
}
+38
View File
@@ -0,0 +1,38 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
//
//
// -- This is a parent command --
// Cypress.Commands.add('login', (email, password) => { ... })
//
//
// -- This is a child command --
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
import { register } from '@cypress/snapshot';
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
register();
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
snapshot(): void;
}
}
}
import 'cypress-localstorage-commands';
+28
View File
@@ -0,0 +1,28 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands';
require('cy-verify-downloads').addCustomCommand();
// Alternatively you can use CommonJS syntax:
// require('./commands')
Cypress.on('uncaught:exception', (err) => {
/* returning false here prevents Cypress from failing the test */
if (err.message.includes('ResizeObserver loop limit exceeded')) {
return false;
}
});
+7
View File
@@ -0,0 +1,7 @@
{
"compilerOptions": {
"allowJs": true,
"types": ["cypress", "cypress-localstorage-commands", "cy-verify-downloads"]
},
"include": ["**/*.ts"]
}
+2 -2
View File
@@ -3,9 +3,9 @@ services:
mermaid:
build:
context: .
target: mermaid-dev
dockerfile: Dockerfile.dev
volumes:
- ./src:/app/src
ports:
- 3000:3000
- 8080:8080
- 24678:24678
-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'
}
}
);
-13
View File
@@ -1,13 +0,0 @@
[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'
MERMAID_IS_ENABLED_MERMAID_CHART_LINKS ='true'
[[redirects]]
from = "/index.html"
to = "/edit"
status = 301
force = true
+1 -1
View File
@@ -1,5 +1,5 @@
server {
listen 8080;
listen 80;
server_name mermaid;
location / {
root /usr/share/nginx/html;
+69 -110
View File
@@ -5,132 +5,91 @@
"license": "MIT",
"scripts": {
"dev": "vite dev",
"dev:force": "MERMAID_LOCAL=true pnpm dev --force",
"dev:test": "pnpm dev",
"dev:force": "MERMAID_LOCAL=true yarn dev --force",
"dev:test": "yarn dev",
"build": "vite build",
"preview": "vite preview",
"lint": "prettier --check --cache . && eslint .",
"lint:fix": "prettier --write --cache . && eslint --fix .",
"format": "prettier --write --cache .",
"lint": "prettier --check --cache --plugin-search-dir=. .;eslint --ignore-path .gitignore .",
"lint:fix": "prettier --write --cache --plugin-search-dir=. .;eslint --fix --ignore-path .gitignore .",
"format": "prettier --write --cache --plugin-search-dir=. .",
"pre-commit": "lint-staged",
"postinstall": "husky install && svelte-kit sync && (git config blame.ignoreRevsFile .git-blame-ignore-revs || true)",
"test:unit": "vitest",
"test:unit:ui": "vitest --ui",
"test:unit:coverage": "vitest run --coverage",
"test": "pnpm test:unit && pnpm test:e2e",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug"
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"test:browser": "cypress run",
"test": "test:unit && test:browser",
"cy": "cypress open"
},
"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",
"@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",
"autoprefixer": "^10.4.21",
"bits-ui": "^2.9.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-plugin-es": "^4.1.0",
"eslint-plugin-no-only-tests": "^3.3.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",
"esserializer": "^1.3.11",
"globals": "^16.0.0",
"husky": "^8.0.3",
"jsdom": "^25.0.1",
"lint-staged": "^15.5.1",
"lucide-svelte": "^0.507.0",
"node-html-parser": "^6.1.13",
"paneforge": "1.0.0-next.6",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.38.6",
"svelte-preprocess": "^6.0.3",
"svelte-sonner": "^1.0.5",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^3.1.0",
"tailwindcss": "^4.1.18",
"tslib": "^2.8.1",
"tw-animate-css": "^1.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",
"vitest-dom": "^0.1.1"
"@cypress/snapshot": "2.1.7",
"@sveltejs/adapter-static": "1.0.0-next.46",
"@sveltejs/kit": "1.0.0-next.522",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/svelte": "3.2.2",
"@types/pako": "2.0.0",
"@types/uuid": "8.3.4",
"@typescript-eslint/eslint-plugin": "5.42.0",
"@typescript-eslint/parser": "5.42.0",
"@vitest/ui": "0.25.1",
"autoprefixer": "10.4.12",
"c8": "7.12.0",
"chai": "4.3.6",
"cssnano": "5.1.13",
"cy-verify-downloads": "0.1.11",
"cypress": "11.0.1",
"cypress-localstorage-commands": "2.2.1",
"eslint": "8.27.0",
"eslint-config-prettier": "8.5.0",
"eslint-plugin-cypress": "2.12.1",
"eslint-plugin-es": "4.1.0",
"eslint-plugin-postcss-modules": "2.0.0",
"eslint-plugin-svelte3": "4.0.0",
"eslint-plugin-tailwindcss": "3.6.2",
"eslint-plugin-vitest": "0.0.11",
"esserializer": "1.3.2",
"husky": "8.0.1",
"jsdom": "20.0.1",
"lint-staged": "13.0.3",
"node-html-parser": "6.1.1",
"postcss": "8.4.18",
"postcss-load-config": "4.0.1",
"prettier": "2.7.1",
"prettier-plugin-svelte": "2.8.0",
"svelte": "3.53.1",
"svelte-preprocess": "4.10.7",
"tailwindcss": "3.2.1",
"tslib": "2.4.0",
"typescript": "4.8.4",
"vite": "3.2.2",
"vitest": "0.25.1"
},
"dependencies": {
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-markdown": "^6.3.2",
"@codemirror/lang-yaml": "^6.1.2",
"@codemirror/language": "^6.11.0",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.7",
"@fontsource-variable/recursive": "^5.2.5",
"@fsegurai/codemirror-theme-vscode-dark": "^6.1.4",
"@fsegurai/codemirror-theme-vscode-light": "^6.1.4",
"@mermaid-js/examples": "^1.0.0",
"@mermaid-js/layout-elk": "^0.1.9",
"@mermaid-js/layout-tidy-tree": "^0.2.1",
"@mermaid-js/mermaid-zenuml": "^0.2.2",
"codemirror": "^6.0.1",
"dayjs": "^1.11.13",
"hammerjs": "^2.0.8",
"js-base64": "3.7.7",
"lodash-es": "^4.17.21",
"mermaid": "^11.13.0",
"mode-watcher": "^0.5.1",
"monaco-editor": "0.52.2",
"@mermaid-js/mermaid-mindmap": "9.2.2",
"analytics": "0.8.1",
"analytics-plugin-plausible": "0.0.6",
"daisyui": "2.39.1",
"js-base64": "3.7.2",
"mermaid": "9.2.2",
"moment": "2.29.4",
"monaco-editor": "0.34.1",
"monaco-mermaid": "1.0.6",
"pako": "2.1.0",
"plausible-tracker": "^0.3.9",
"random-word-slugs": "0.1.7",
"svg-pan-zoom": "3.6.2",
"svg2roughjs": "^3.2.1",
"uuid": "9.0.1"
"random-word-slugs": "0.1.6",
"svg-pan-zoom": "3.6.1",
"uuid": "9.0.0"
},
"lint-staged": {
"*.{ts,svelte,js,css,md,json}": [
"prettier --plugin-search-dir=. --write",
"eslint "
"eslint --ignore-path .gitignore "
]
},
"volta": {
"node": "18.12.0",
"yarn": "1.22.19"
},
"engines": {
"node": ">=20.19.0"
},
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39",
"pnpm": {
"onlyBuiltDependencies": [
"deasync",
"esbuild",
"svelte-preprocess"
],
"ignoredBuiltDependencies": [
"vue-demi"
]
"node": ">=16.7"
}
}
-28
View File
@@ -1,28 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
forbidOnly: !!process.env.CI,
fullyParallel: true,
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] }
}
],
reporter: process.env.CI ? [['github'], ['list']] : 'list',
retries: process.env.CI ? 2 : 0,
testDir: './tests',
use: {
baseURL: 'http://localhost:3000',
browserName: 'chromium',
permissions: ['clipboard-read', 'clipboard-write'],
trace: 'retain-on-failure',
viewport: { width: 1920, height: 1080 }
},
webServer: {
command: `pnpm ${process.env.CI ? 'preview' : 'dev'}`,
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI
},
workers: process.env.CI ? 3 : undefined
});
-8031
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'
})
]
};
+1 -6
View File
@@ -11,10 +11,6 @@
":automergePatch"
],
"packageRules": [
{
"matchPackagePatterns": ["^mermaid"],
"groupName": "Mermaid packages"
},
{
"matchUpdateTypes": ["minor", "patch", "pin", "digest"],
"automerge": true
@@ -24,6 +20,5 @@
"major": {
"dependencyDashboardApproval": true
},
"dependencyDashboardAutoclose": true,
"rangeStrategy": "bump"
"dependencyDashboardAutoclose": true
}
-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;
}
+40 -3
View File
@@ -1,17 +1,54 @@
<!doctype html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<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" />
<meta
name="og:image"
content="https://github.com/mermaid-js/mermaid/raw/develop/img/header.png" />
<link rel="canonical" href="https://mermaid.live" />
<meta
name="description"
content="Simplify documentation and avoid heavy tools. Open source Visio Alternative. Commonly used for explaining your code! Mermaid is a simple markdown-like script language for generating charts from text via javascript." />
<link rel="icon" type="image/png" href="%sveltekit.assets%/favicon.svg" />
<link rel="mask-icon" href="%sveltekit.assets%/favicon.svg" color="#000000" />
<meta name="theme-color" content="#ff3670" />
<meta name="theme-color" content="#6366F1" />
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.0/css/all.min.css"
integrity="sha512-xh6O/CkQoPOWDdYTDqeRdPCVd1SpvCA9XXcUnZS2FmJNp1coAFzvtCN9BmamE+4aHK8yyUHUSCcJHgXloTyT2A=="
crossorigin="anonymous"
referrerpolicy="no-referrer" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.34.1/min/vs/editor/editor.main.min.css"
integrity="sha512-GzcoZD7y5zvBofYtImXPZaPVhoY7xLPt+ysmbPb/vU+quSKFkcngxaSrxuwprDZL4MALUqGFmnqCxQZqMozv1Q=="
crossorigin="anonymous"
referrerpolicy="no-referrer" />
<script>
var require = {
paths: {
vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.34.0/min/vs'
}
};
</script>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.34.1/min/vs/loader.min.js"
integrity="sha512-6bIYsGqvLpAiEBXPdRQeFf5cueeBECtAKJjIHer3BhBZNTV3WLcLA8Tm3pDfxUwTMIS+kAZwTUvJ1IrMdX8C5w=="
crossorigin="anonymous"
referrerpolicy="no-referrer"></script>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.34.1/min/vs/editor/editor.main.nls.min.js"
integrity="sha512-CCv+DKWw+yZhxf4Z+ExT6HC5G+3S45TeMTYcJyYbdrv4BpK2vyALJ4FoVR/KGWDIPu7w4tNCOC9MJQIkYPR5FA=="
crossorigin="anonymous"
referrerpolicy="no-referrer"></script>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.34.1/min/vs/editor/editor.main.js"
integrity="sha512-BtSZPhzoyN8kq1axY6cgOFPSgLJgFwvAZ3WxeDGxEFXeFcFuK2s7Hr+zF75npVHASUY1dxudAfIVzwmgdR89Bw=="
crossorigin="anonymous"
referrerpolicy="no-referrer"></script>
%sveltekit.head%
</head>
<body>
+10
View File
@@ -0,0 +1,10 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.input {
@apply flex-1 border-primary border-solid border-2 rounded;
}
.action-btn {
@apply btn btn-primary;
}
+5 -8
View File
@@ -1,14 +1,11 @@
/// <reference types="vite/client" />
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;
readonly MERMAID_RENDERER_URL: string;
readonly MERMAID_KROKI_RENDERER_URL: string;
readonly MERMAID_CDN_URL: string;
readonly MERMAID_BASE_URL: string;
readonly MERMAID_LOCAL: boolean;
// more env variables...
}
+14
View File
@@ -1 +1,15 @@
/* eslint-disable @typescript-eslint/no-empty-interface */
/* eslint-disable @typescript-eslint/ban-types */
/* eslint-disable @typescript-eslint/no-unused-vars */
/// <reference types="@sveltejs/kit" />
import type { TestingLibraryMatchers } from '@testing-library/jest-dom/matchers';
declare global {
namespace jest {
interface Matchers<R = void>
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
extends TestingLibraryMatchers<typeof expect.stringContaining, R> {}
}
}
+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>
+162 -221
View File
@@ -1,100 +1,74 @@
<script lang="ts">
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 { browser } from '$app/environment';
import { waitForRender } from '$lib/util/autoSync';
import { inputStateStore, stateStore, urlsStore } from '$lib/util/state';
import Card from '$lib/components/Card/Card.svelte';
import { env } from '$lib/util/env';
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 DownloadIcon from '~icons/material-symbols/download';
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`;
import moment from 'moment';
const { krokiRendererUrl, rendererUrl } = env;
type Exporter = (context: CanvasRenderingContext2D, image: HTMLImageElement) => () => void;
const getFileName = (extension: string) =>
`mermaid-diagram-${dayjs().format('YYYY-MM-DD-HHmmss')}.${extension}`;
/**
* Fix text clipping in exported SVG for hand-drawn (rough) mode.
* svg2roughjs copies foreignObject elements but their height is often insufficient,
* causing text bottom edges to be cut off regardless of language.
*/
const fixForeignObjectClipping = (svg: HTMLElement) => {
const foreignObjects = svg.querySelectorAll('foreignObject');
foreignObjects.forEach((foreignObj) => {
const currentHeight = parseFloat(foreignObj.getAttribute('height') || '0');
if (currentHeight <= 0) return;
const currentY = parseFloat(foreignObj.getAttribute('y') || '0');
const newHeight = currentHeight * 1.5;
const heightDiff = newHeight - currentHeight;
foreignObj.setAttribute('height', newHeight.toString());
foreignObj.setAttribute('y', (currentY - heightDiff / 2).toString());
// Ensure inner HTML elements are vertically centered within the expanded area
const htmlElements = foreignObj.querySelectorAll('div, span, p');
htmlElements.forEach((htmlEl) => {
const el = htmlEl as HTMLElement;
el.style.display = 'flex';
el.style.alignItems = 'center';
el.style.justifyContent = 'center';
el.style.height = '100%';
});
});
};
const getSvgElement = () => {
const svgElement = document.querySelector('#container svg')?.cloneNode(true) as HTMLElement;
svgElement.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
return svgElement;
};
const getFileName = (ext: string) =>
`mermaid-diagram-${moment().format('YYYY-MM-DD-HHmmss')}.${ext}`;
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
svg?.setAttribute('height', `${height}px`);
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();
svg = getSvgEl();
}
if ($stateStore.rough) {
fixForeignObjectClipping(svg);
}
svg.style.backgroundColor = window
.getComputedStyle(document.body)
.getPropertyValue('--background');
const svgString = svg.outerHTML
.replaceAll('<br>', '<br/>')
.replaceAll(/<img([^>]*)>/g, (m, g: string) => `<img ${g} />`);
return toBase64(svgString);
};
return toBase64(`<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet href="${FONT_AWESOME_URL}" type="text/css"?>
${svgString}`);
const exportImage = (event: Event, exporter: Exporter) => {
const canvas: HTMLCanvasElement = document.createElement('canvas');
const svg: HTMLElement = document.querySelector('#container svg');
const box: DOMRect = svg.getBoundingClientRect();
canvas.width = box.width;
canvas.height = box.height;
if (imagemodeselected === 'width') {
const ratio = box.height / box.width;
canvas.width = userimagesize;
canvas.height = userimagesize * ratio;
} else if (imagemodeselected === 'height') {
const ratio = box.width / box.height;
canvas.width = userimagesize * ratio;
canvas.height = userimagesize;
}
const context = canvas.getContext('2d');
context.fillStyle = 'white';
context.fillRect(0, 0, canvas.width, canvas.height);
const image = new Image();
image.onload = exporter(context, image);
image.src = `data:image/svg+xml;base64,${getBase64SVG(svg, canvas.width, canvas.height)}`;
event.stopPropagation();
event.preventDefault();
};
const getSvgEl = () => {
const svgEl: HTMLElement = document
.querySelector('#container svg')
.cloneNode(true) as HTMLElement;
svgEl.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
const fontAwesomeCdnUrl = Array.from(document.head.getElementsByTagName('link'))
.map((l) => l.href)
.find((h) => h && h.includes('font-awesome'));
if (fontAwesomeCdnUrl == null) {
return svgEl;
}
const styleEl = document.createElement('style');
styleEl.innerText = `@import url("${fontAwesomeCdnUrl}");'`;
svgEl.prepend(styleEl);
return svgEl;
};
const simulateDownload = (download: string, href: string): void => {
@@ -104,65 +78,6 @@ ${svgString}`);
a.click();
a.remove();
};
const exportImage = async (event: Event, exporter: Exporter) => {
$inputStateStore.panZoom = false;
await new Promise((resolve) => setTimeout(resolve, 1000));
await waitForRender();
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;
if (imageSizeMode === 'width') {
const ratio = contentHeight / contentWidth;
canvas.width = imageSize;
canvas.height = imageSize * ratio;
} else if (imageSizeMode === 'height') {
const ratio = contentWidth / contentHeight;
canvas.width = imageSize * ratio;
canvas.height = imageSize;
} else {
const multiplier = 2;
canvas.width = contentWidth * multiplier;
canvas.height = contentHeight * multiplier;
}
const context = canvas.getContext('2d');
if (!context) {
throw new Error('context not found');
}
context.fillStyle = 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)}`;
// 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();
};
const downloadImage: Exporter = (context, image) => {
return () => {
const { canvas } = context;
@@ -175,7 +90,7 @@ ${svgString}`);
};
const isClipboardAvailable = (): boolean => {
return Object.prototype.hasOwnProperty.call(window, 'ClipboardItem');
return Object.prototype.hasOwnProperty.call(window, 'ClipboardItem') as boolean;
};
const clipboardCopy: Exporter = (context, image) => {
@@ -184,10 +99,10 @@ ${svgString}`);
context.drawImage(image, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
try {
if (!blob) {
throw new Error('blob is empty');
}
// @ts-ignore: https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/1004/files
void navigator.clipboard.write([
/* eslint-disable no-undef */
// @ts-ignore: https://github.com/microsoft/TypeScript/issues/43821
new ClipboardItem({
[blob.type]: blob
})
@@ -199,13 +114,13 @@ ${svgString}`);
};
};
const onCopyClipboard = async (event: Event) => {
await exportImage(event, clipboardCopy);
const onCopyClipboard = (event: Event) => {
exportImage(event, clipboardCopy);
logEvent('copyClipboard');
};
const onDownloadPNG = async (event: Event) => {
await exportImage(event, downloadImage);
const onDownloadPNG = (event: Event) => {
exportImage(event, downloadImage);
logEvent('download', {
type: 'png'
});
@@ -218,99 +133,125 @@ ${svgString}`);
});
};
let gistURL = $state('');
const onCopyMarkdown = () => {
(document.getElementById('markdown') as HTMLInputElement).select();
document.execCommand('Copy');
logEvent('copyMarkdown');
};
let gistURL = '';
stateStore.subscribe(({ loader }) => {
if (loader?.type === 'gist') {
// @ts-ignore Gist will have url
gistURL = loader.config.url;
}
});
const loadGist = () => {
if (!gistURL) {
return alert('Please enter a Gist URL first');
alert('Please enter a Gist URL first');
}
window.location.href = `${window.location.pathname}?gist=${gistURL}`;
logEvent('loadGist');
};
let imageSizeMode: 'auto' | 'width' | 'height' = $state('auto');
let iUrl: string;
let svgUrl: string;
let krokiUrl: string;
let mdCode: string;
let imagemodeselected = 'auto';
let userimagesize = 1080;
$effect(() => {
if (!imageSizeMode) {
imageSizeMode = 'auto';
}
let isNetlify = false;
if (browser && ['mermaid.live', 'netlify'].some((path) => window.location.host.includes(path))) {
isNetlify = true;
}
stateStore.subscribe(async (state) => {
const { code, serialized } = await state;
iUrl = `${rendererUrl}/img/${serialized}?type=png`;
svgUrl = `${rendererUrl}/svg/${serialized}`;
krokiUrl = `${krokiRendererUrl}/mermaid/svg/${pakoSerde.serialize(code)}`;
mdCode = `[![](${iUrl})](${window.location.protocol}//${window.location.host}${window.location.pathname}#${serialized})`;
});
let imageSize = $state(1080);
const isNetlify = browser && window.location.host.includes('netlify');
</script>
{#snippet dualActionButton(text: string, download: (event: Event) => unknown, url?: string)}
<div class="flex flex-grow gap-0.5">
<Button
class={['flex-grow', url && 'rounded-r-none']}
onclick={download}
data-testid="download-{text}">
<DownloadIcon />
{text}
</Button>
<ExternalLinkWrapper domain={getDomain(url)} isVisible={!!url}>
<Button class="rounded-l-none" href={url} target="_blank" rel="noreferrer noopener">
<ExternalLinkIcon />
</Button>
</ExternalLinkWrapper>
</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">
PNG size
<ToggleGroup.Root type="single" variant="outline" bind:value={imageSizeMode}>
<ToggleGroup.Item value="auto">Auto</ToggleGroup.Item>
<ToggleGroup.Item value="width">Width</ToggleGroup.Item>
<ToggleGroup.Item value="height">Height</ToggleGroup.Item>
</ToggleGroup.Root>
{#if imageSizeMode !== 'auto'}
<WidthIcon
class={['size-6 shrink-0 transition-all', imageSizeMode === 'width' && 'rotate-90']} />
{/if}
<Input
type="number"
min="3"
max="10000"
disabled={imageSizeMode === 'auto'}
bind:value={imageSize} />
</div>
<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}>
<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>
</div>
<Separator />
<Card title="Actions" isOpen={true}>
<div class="flex flex-wrap gap-2 m-2">
{#if isClipboardAvailable()}
<CopyButton onclick={onCopyClipboard} label="Copy Image" />
<button class="action-btn w-full" on:click={onCopyClipboard}
><i class="far fa-copy mr-2" /> Copy Image to clipboard
</button>
{/if}
<ExternalLinkWrapper
labelPrefix="Thumbnail generated by"
domain={getDomain($urlsStore.png)}
isVisible={!!$urlsStore.mdCode}>
<CopyInput value={$urlsStore.mdCode} label="Copy Markdown" testID={TID.copyMarkdown} />
</ExternalLinkWrapper>
<div class="flex w-full items-center gap-2">
<Input type="url" bind:value={gistURL} placeholder="Enter Gist URL" />
<Button onclick={loadGist}>Load Gist</Button>
<button id="downloadPNG" class="action-btn flex-grow" on:click={onDownloadPNG}>
<i class="fas fa-download mr-2" /> PNG
</button>
<button id="downloadSVG" class="action-btn flex-grow" on:click={onDownloadSVG}>
<i class="fas fa-download mr-2" /> 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>
<div class="flex gap-2 items-center">
PNG size
<label for="autosize">
<input type="radio" value="auto" id="autosize" bind:group={imagemodeselected} /> Auto
</label>
<label for="width">
<input type="radio" value="width" id="width" bind:group={imagemodeselected} /> Width
</label>
<label for="height">
<input type="radio" value="height" id="height" bind:group={imagemodeselected} /> Height
</label>
{#if imagemodeselected !== 'auto'}
<input
id="height"
class="input"
type="number"
min="3"
max="10000"
bind:value={userimagesize} />
{/if}
</div>
<div class="w-full flex gap-2 items-center">
<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>
<div class="w-full flex gap-2 items-center">
<input
class="input"
id="gist"
type="text"
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>
</label>
</div>
{#if isNetlify}
<div class="flex w-full items-center justify-center">
<a class="link text-sm text-gray-500 underline" href="https://netlify.com">
<div class="w-full flex items-center justify-center">
<a class="link underline text-gray-500 text-sm" href="https://netlify.com">
This site is powered by Netlify
</a>
</div>
+20 -74
View File
@@ -1,85 +1,31 @@
<script lang="ts">
import type { Tab } from '$/types';
import type { Component, Snippet } from 'svelte';
import { quintOut } from 'svelte/easing';
import type { Tab } from '$lib/types';
import { slide } from 'svelte/transition';
import CollapseAllIcon from '~icons/material-symbols/collapse-all-rounded';
import Tabs from './Tabs.svelte';
interface Props {
isClosable?: boolean;
isOpen?: boolean;
isStackable?: boolean;
tabs?: Tab[];
activeTabID?: string;
title?: string;
icon?: {
component: Component;
class?: string;
};
onselect?: (tab: Tab) => void;
actions?: Snippet;
children: Snippet;
}
let {
isClosable = true,
isOpen = false,
isStackable = false,
tabs = [],
activeTabID = '',
title,
icon,
onselect,
actions,
children
}: Props = $props();
const toggleCardOpen = () => {
if (isClosable) {
isOpen = !isOpen;
}
};
let isTabsShown = $derived(isOpen && tabs.length > 0);
export let isCloseable = true;
export let isOpen = true;
export let tabs: Tab[] = [];
export let activeTabID: string = '';
export let title: string;
$: isOpen = isCloseable ? isOpen : true;
$: isTabsShown = isOpen && tabs.length > 0;
</script>
<div
class={[
'card flex h-fit flex-col overflow-hidden rounded-2xl border-2 border-muted',
isOpen && 'isOpen flex-grow',
isStackable ? 'flex-1 group-has-[.isOpen]:w-full group-has-[.isOpen]:flex-none' : 'w-full'
]}>
<div class="card rounded overflow-hidden m-2 flex-grow flex flex-col shadow-2xl">
<div
role="toolbar"
tabindex="0"
class={[
'flex h-11 flex-none cursor-pointer items-center justify-between bg-muted p-2 whitespace-nowrap',
isTabsShown && 'pb-1'
]}
onclick={toggleCardOpen}
onkeypress={toggleCardOpen}>
{#if icon || title}
<span role="menubar" tabindex="0" class="flex w-fit items-center gap-3">
{#if icon}
<icon.component class={icon.class} />
{/if}
{title}
</span>
{/if}
{#if isOpen && tabs && tabs.length > 0}
<Tabs {onselect} {tabs} {activeTabID} />
{/if}
{@render actions?.()}
{#if isOpen && isClosable}
<CollapseAllIcon />
{/if}
class="bg-primary p-2 {isTabsShown ? 'pb-0' : ''} flex-none cursor-pointer"
on:click={() => (isOpen = !isOpen)}
on:keypress={() => (isOpen = !isOpen)}>
<div class="flex justify-between">
<Tabs on:select {tabs} bind:isOpen {title} {isCloseable} {activeTabID} />
<div class="flex gap-x-4 items-center {isTabsShown ? '-mt-2' : ''}">
<slot name="actions" />
</div>
</div>
</div>
{#if isOpen}
<div class="flex-grow overflow-x-auto" transition:slide={{ easing: quintOut }}>
{@render children()}
<div class="card-body p-0 flex-grow overflow-auto text-base-content" transition:slide>
<slot />
</div>
{/if}
</div>
+40 -40
View File
@@ -1,52 +1,52 @@
<script lang="ts">
import { Button } from '$/components/ui/button';
import { Separator } from '$/components/ui/separator';
import type { Tab } from '$lib/types';
import type { Tab, TabEvents } from '$lib/types';
import { createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
let {
tabs,
activeTabID,
onselect
}: {
tabs: Tab[];
activeTabID: string;
onselect?: (tab: Tab) => void;
} = $props();
export let isCloseable = true;
export let tabs: Tab[];
export let title: string;
export let isOpen = false;
export let activeTabID: string;
if (!activeTabID && tabs.length > 0) {
activeTabID = tabs[0].id;
}
const dispatch = createEventDispatcher<TabEvents>();
const toggleTabs = (tab: Tab) => {
return (event: Event) => {
event.stopPropagation();
onselect?.(tab);
};
activeTabID = tab.id;
dispatch('select', tab);
};
</script>
<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)}
<Button
role="tab"
variant="ghost"
class={[
'px-2',
activeTabID === tab.id && 'rounded-b-none border-b-2 border-b-primary-foreground/50'
]}
onclick={toggleTabs(tab)}
onkeypress={toggleTabs(tab)}>
<tab.icon />
{tab.title}
</Button>
{#if index < tabs.length - 1}
<div class="my-2">
<Separator orientation="vertical" class="w-0.5 bg-slate-300" />
<div class="flex cursor-default">
<span
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 />
{/if}
{title}</span>
{#if isOpen && tabs}
<ul class="tabs" transition:fade>
{#each tabs as tab}
<div
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}" />
{tab.title}
</div>
{/if}
{/each}
</ul>
{/each}
</ul>
{/if}
</div>
<style>
.icon {
transition-duration: 0.5s;
}
.isOpen {
transform: rotate(90deg);
}
</style>
@@ -0,0 +1,3 @@
// Vitest Snapshot v1
exports[`card.svelte > mounts 1`] = `"<div><div class=\\"card rounded overflow-hidden m-2 flex-grow flex flex-col shadow-2xl\\"><div class=\\"bg-primary p-2 pb-0 flex-none cursor-pointer\\"><div class=\\"flex justify-between\\"><div class=\\"flex cursor-default s-_wx1E_JHsCoF\\"><span class=\\"mr-2 font-semibold s-_wx1E_JHsCoF\\"><i class=\\"fas fa-chevron-right icon s-_wx1E_JHsCoF isOpen\\"></i> TabTest</span> <ul class=\\"tabs s-_wx1E_JHsCoF\\"><div class=\\"tab tab-lifted tab-active s-_wx1E_JHsCoF\\"><i class=\\"mr-1 undefined s-_wx1E_JHsCoF\\"></i> title1 </div><div class=\\"tab tab-lifted text-primary-content s-_wx1E_JHsCoF\\"><i class=\\"mr-1 undefined s-_wx1E_JHsCoF\\"></i> title2 </div></ul></div><!--<Tabs>--> <div class=\\"flex gap-x-4 items-center -mt-2\\"></div></div></div> <div class=\\"card-body p-0 flex-grow overflow-auto text-base-content\\"></div></div><!--<Card>--></div>"`;
+24
View File
@@ -0,0 +1,24 @@
import { cleanup, render } from '@testing-library/svelte';
import { describe, expect, it, afterEach } from 'vitest';
import Card from './Card.svelte';
describe('card.svelte', () => {
// TODO: @testing-library/svelte claims to add this automatically but it doesn't work without explicit afterEach
afterEach(() => cleanup());
it('mounts', () => {
const { container } = render(Card, {
title: 'TabTest',
tabs: [
{ id: 't1', title: 'title1' },
{ id: 't2', title: 'title2' }
]
});
expect(container).toBeTruthy();
expect(container).toHaveTextContent('TabTest');
expect(container).toHaveTextContent('title1');
expect(container).toHaveTextContent('title2');
expect(container).not.toHaveTextContent('title3');
expect(container.innerHTML).toMatchSnapshot();
});
});
-40
View File
@@ -1,40 +0,0 @@
<script lang="ts">
import { Button } from '$/components/ui/button';
import { notify } from '$/util/notify';
import { scale } from 'svelte/transition';
import CheckIcon from '~icons/material-symbols/check-rounded';
import CopyIcon from '~icons/material-symbols/content-copy-outline-rounded';
let {
onclick,
label = 'Copy'
}: { onclick: (event?: Event) => Promise<unknown>; label?: string } = $props();
let showCheckIcon = $state(false);
</script>
<Button
onclick={async (event) => {
try {
showCheckIcon = true;
setTimeout(() => {
showCheckIcon = false;
}, 1000);
await onclick(event);
} catch {
notify('Failed to copy');
}
}}>
<div class="grid">
{#key showCheckIcon}
<span transition:scale class="col-start-1 row-start-1">
{#if showCheckIcon}
<CheckIcon />
{:else}
<CopyIcon />
{/if}
</span>
{/key}
</div>
{label}
</Button>
-25
View File
@@ -1,25 +0,0 @@
<script lang="ts">
import CopyButton from '$/components/CopyButton.svelte';
import { Input } from '$/components/ui/input';
import type { InputType } from '$/types';
import { copyToClipboard } from '$/util/util';
let {
value,
label = 'Copy',
type = 'url',
testID
}: { value: string; label?: string; type?: InputType; testID?: string } = $props();
</script>
<div class="flex w-full items-center gap-2">
<Input
{type}
{value}
data-testid={testID}
onclick={(event) => {
event.currentTarget.setSelectionRange(0, event.currentTarget.value.length);
}} />
<CopyButton onclick={() => copyToClipboard(value)} {label} />
</div>
-251
View File
@@ -1,251 +0,0 @@
<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 { initEditor } from '$lib/util/monacoExtra';
import { errorDebug } from '$lib/util/util';
import { mode } from 'mode-watcher';
import * as monaco from 'monaco-editor';
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
} 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(
'',
'json',
monaco.Uri.parse('internal://config.json')
);
const mermaidModel = monaco.editor.createModel(
'',
'mermaid',
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) {
if (label === 'json') {
return new monacoJsonWorker();
}
return new monacoEditorWorker();
}
};
if (!divElement) {
throw new Error('divEl is undefined');
}
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
enableSchemaRequest: true,
schemas: [
{
fileMatch: ['config.json'],
uri: `${env.docsUrl}/schemas/config.schema.json`
}
]
});
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) {
return;
}
currentText = newText;
onUpdate(currentText);
});
const unsubscribeState = stateStore.subscribe(({ errorMarkers, editorMode, code, mermaid }) => {
if (!editor) {
return;
}
const model = editorMode === 'code' ? mermaidModel : jsonModel;
if (editor.getModel()?.id !== model.id) {
editor.setModel(model);
renderAIPromptGutterGlyphIcon();
}
// Clear decorations if not in 'code' mode, or if the model changes
if (editorMode !== 'code' || editor.getModel()?.id !== mermaidModel.id) {
decorationsCollection?.clear();
}
// Update editor text if it's different
const newText = editorMode === 'code' ? code : mermaid;
if (newText !== currentText) {
editor.setScrollTop(0);
editor.setValue(newText);
currentText = newText;
renderAIPromptGutterGlyphIcon();
}
// Display/clear errors
monaco.editor.setModelMarkers(model, 'mermaid', errorMarkers);
});
editor.onMouseMove((e) => {
if (!editor) return;
if (showPopup) return;
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');
}
});
const resizeObserver = new ResizeObserver((entries) => {
editor?.layout({
height: entries[0].contentRect.height,
width: entries[0].contentRect.width
});
});
if (divElement.parentElement) {
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>
@@ -1,114 +0,0 @@
<script lang="ts">
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 docMap = {
architecture: {
code: '/syntax/architecture.html'
},
block: {
code: '/syntax/block.html'
},
c4: {
code: '/syntax/c4.html'
},
class: {
code: '/syntax/classDiagram.html',
config: '/syntax/classDiagram.html#configuration'
},
er: {
code: '/syntax/entityRelationshipDiagram.html',
config: '/syntax/entityRelationshipDiagram.html#styling'
},
flowchart: {
code: '/syntax/flowchart.html',
config: '/syntax/flowchart.html#configuration'
},
gantt: {
code: '/syntax/gantt.html',
config: '/syntax/gantt.html#configuration'
},
gitGraph: {
code: '/syntax/gitgraph.html',
config: '/syntax/gitgraph.html#gitgraph-specific-configuration-options'
},
journey: {
code: '/syntax/userJourney.html'
},
kanban: {
code: '/syntax/kanban.html',
config: '/syntax/kanban.html#configuration-options'
},
mindmap: {
code: '/syntax/mindmap.html'
},
packet: {
code: '/syntax/packet.html',
config: '/config/schema-docs/config-defs-packet-diagram-config.html'
},
pie: {
code: '/syntax/pie.html',
config: '/syntax/pie.html#configuration'
},
quadrantChart: {
code: '/syntax/quadrantChart.html',
config: '/syntax/quadrantChart.html#chart-configurations'
},
requirement: {
code: '/syntax/requirementDiagram.html'
},
sankey: {
code: '/syntax/sankey.html',
config: '/syntax/sankey.html#configuration'
},
sequence: {
code: '/syntax/sequenceDiagram.html',
config: '/syntax/sequenceDiagram.html#configuration'
},
stateDiagram: {
code: '/syntax/stateDiagram.html'
},
timeline: {
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'
},
zenuml: {
code: '/syntax/zenuml.html'
}
} as const satisfies DocumentationConfig;
const doc = $derived.by(() => {
const { editorMode, diagramType } = $stateStore;
if (!diagramType) {
return { key: '', url: docURLBase };
}
const key = standardizeDiagramType(diagramType);
const docConfig = docMap[key] ?? { code: '' };
const url = docURLBase + (docConfig[editorMode] ?? docConfig.code ?? '');
return { key, url };
});
</script>
<Button
variant="ghost"
data-testid={TID.diagramDocumentationButton}
href={doc.url}
target="_blank"
title="View documentation for {doc.key.replace('Diagram', '')} diagram">
<BookIcon />
Docs
</Button>
-38
View File
@@ -1,38 +0,0 @@
<script lang="ts">
import { Button } from '$/components/ui/button';
import * as Popover from '$/components/ui/popover';
import type { Component } from 'svelte';
interface Props {
links: { title: string; href: string }[];
icon?: Component;
class?: string;
}
let props: Props = $props();
</script>
<Popover.Root>
<Popover.Trigger class="flex items-center gap-0">
<Button variant="ghost" size="sm">
<props.icon class={props.class} />
</Button>
</Popover.Trigger>
<Popover.Content>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<ul tabindex="0" class="flex flex-col">
{#each props.links as { href, title } (title)}
<li class="rounded-md p-2 hover:bg-muted">
<a
role="menuitem"
tabindex="0"
class="whitespace-nowrap underline"
target="_blank"
{href}>
{title}
</a>
</li>
{/each}
</ul>
</Popover.Content>
</Popover.Root>
+100 -68
View File
@@ -1,83 +1,115 @@
<script lang="ts">
import DesktopEditor from '$/components/DesktopEditor.svelte';
import McWrapper from '$/components/McWrapper.svelte';
import MermaidChartIcon from '$/components/MermaidChartIcon.svelte';
import MobileEditor from '$/components/MobileEditor.svelte';
import { Button } from '$/components/ui/button';
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';
import type { EditorMode } from '$lib/types';
import { stateStore, updateCode, updateConfig } from '$lib/util/state';
import { themeStore } from '$lib/util/theme';
import { errorDebug, syncDiagram } from '$lib/util/util';
import type monaco from 'monaco-editor';
import { onMount } from 'svelte';
import initEditor from 'monaco-mermaid';
import { logEvent } from '$lib/util/stats';
const { isMobile } = $props<{ isMobile: boolean }>();
const onUpdate = (text: string) => {
if ($stateStore.editorMode === 'code') {
let divEl: HTMLDivElement = null;
let editor: monaco.editor.IStandaloneCodeEditor;
let Monaco: typeof monaco;
let editorOptions: monaco.editor.IStandaloneEditorConstructionOptions = {
minimap: {
enabled: false
},
theme: 'mermaid',
overviewRulerLanes: 0
};
let text = '';
stateStore.subscribe(({ errorMarkers, editorMode, code, mermaid }) => {
console.log('editor store subscription', { code, mermaid });
if (!editor) return;
// Update editor text if it's different
const newText = editorMode === 'code' ? code : mermaid;
if (newText !== text) {
console.log('updating editor text', newText);
editor.setValue(newText);
text = newText;
}
// Update editor mode if it's different
const language = editorMode === 'code' ? 'mermaid' : 'json';
if (editor.getModel().getLanguageId() !== language) {
Monaco?.editor.setModelLanguage(editor.getModel(), language);
}
// Display/clear errors
Monaco?.editor.setModelMarkers(editor.getModel(), 'mermaid', errorMarkers);
});
themeStore.subscribe(({ isDark }) => {
editor && Monaco?.editor.setTheme(isDark ? 'mermaid-dark' : 'mermaid');
});
const handleUpdate = (text: string, mode: EditorMode) => {
console.log('editor HandleUpdate', { text, mode });
if (mode === 'code') {
updateCode(text);
} else {
updateConfig(text);
}
};
let showError = $state(false);
const showErrorDebounced = debounce(() => {
showError = true;
}, 3000);
$effect(() => {
if ($stateStore.error) {
showErrorDebounced();
} else {
showErrorDebounced.cancel();
showError = false;
const loadMonaco = async () => {
console.log('Loading Monaco...');
// errorDebug();
let i = 0;
while (i++ < 500) {
// @ts-ignore : This is a hack to handle a svelte-kit error when importing monaco.
Monaco = window.monaco;
if (Monaco !== undefined) {
return;
}
await new Promise((r) => setTimeout(r, 100));
}
alert('Loading Monaco Editor failed. Please try refreshing the page.');
};
onMount(async () => {
await loadMonaco(); // Fix https://github.com/mermaid-js/mermaid-live-editor/issues/175
initEditor(Monaco);
errorDebug(100);
editor = Monaco.editor.create(divEl, editorOptions);
editor.onDidChangeModelContent(({ isFlush, changes }) => {
const newText = editor.getValue();
console.log('editor onDidChangeModelContent', { text, newText, isFlush, changes });
if (text === newText || isFlush) {
return;
}
text = newText;
handleUpdate(text, $stateStore.editorMode);
});
editor.addAction({
id: 'mermaid-render-diagram',
label: 'Render Diagram',
keybindings: [Monaco.KeyMod.CtrlCmd | Monaco.KeyCode.Enter],
run: function () {
syncDiagram();
logEvent('renderDiagram', {
method: 'keyboadShortcut'
});
}
});
Monaco?.editor.setTheme($themeStore.isDark ? 'mermaid-dark' : 'mermaid');
const resizeObserver = new ResizeObserver((entries) => {
editor.layout({
height: entries[0].contentRect.height,
width: entries[0].contentRect.width
});
});
resizeObserver.observe(divEl.parentElement);
console.log(`editor mounted`);
return () => {
showErrorDebounced.cancel();
console.log(`editor disposed`);
editor.dispose();
};
});
</script>
<div class="flex h-full flex-col">
{#if isMobile}
<MobileEditor {onUpdate} />
{:else}
<DesktopEditor {onUpdate} />
{/if}
{#if showError && $stateStore.error instanceof Error}
<div class="flex flex-col text-sm" data-testid={TID.errorContainer}>
<div class="flex items-center justify-between gap-2 bg-slate-900 p-2 text-white">
<div class="flex w-fit items-center gap-2">
<ExclamationCircleIcon class="size-6 text-destructive" aria-hidden="true" />
<div class="flex flex-col">
<p>Syntax error</p>
{#if env.isEnabledMermaidChartLinks && $stateStore.editorMode === 'code'}
<p class="text-xs text-white/60" data-testid={TID.aiHelpText}>
Create a free account to repair with AI
</p>
{/if}
</div>
</div>
{#if $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}
</div>
<output class="max-h-32 overflow-auto bg-muted p-2" name="mermaid-error" for="editor">
<pre>{$stateStore.error?.toString()}</pre>
</output>
</div>
{/if}
</div>
<div bind:this={divEl} id="editor" class="overflow-hidden" />
@@ -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}
-13
View File
@@ -1,13 +0,0 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface Props {
children: Snippet;
}
let { children }: Props = $props();
</script>
<div class="flex h-12 items-center justify-between gap-2 rounded-2xl bg-muted p-3">
{@render children()}
</div>
-53
View File
@@ -1,53 +0,0 @@
<!--
@component
Exports a `waitForFontAwesomeToLoad` function that waits for FontAwesome to load.
Usage:
```svelte
<script lang="ts">
import FontAwesome from '$lib/client/components/FontAwesome';
let waitForFontAwesomeToLoad: FontAwesome["waitForFontAwesomeToLoad"];
</script>
<FontAwesome bind:waitForFontAwesomeToLoad />
```
-->
<script context="module" lang="ts">
/**
* Returns `true` if the code may contain FontAwesome icons, and we should
* wait for the fonts to load before rendering.
*/
export function mayContainFontAwesome(code: string) {
// taken from https://github.com/mermaid-js/mermaid/blob/7043892e871d0c413ec63dc1570a8ef738d15568/packages/mermaid/src/diagrams/flowchart/flowRenderer-v2.js#L63
// Not ideal, since we're looking at unparsed code.
const regex = /fa[blrs]?:fa-[\w-]+/g;
return regex.test(code);
}
</script>
<script lang="ts">
// Vite will automatically take care of adding this to our `<head>`
let lazyLoadFontAwesomeCSS = import('./FontAwesomeCSS.svelte');
let fontsLoaded = (async () => {
await lazyLoadFontAwesomeCSS;
// Once the stylesheet has been parsed, the fonts will be in document.fonts
// TODO: Maybe we should scan the CSS style sheet only, and only load FontAwesome fonts
await Promise.allSettled(Array.from(document.fonts, (font) => font.load()));
})();
/**
* Wait for FontAwesome to load.
*
* @returns A promise that resolves when FontAwesome is
* loaded, or there was an error that we ignore.
*/
export async function waitForFontAwesomeToLoad() {
return await fontsLoaded;
}
</script>
{#await lazyLoadFontAwesomeCSS then FontAwesomeCSS}
<FontAwesomeCSS.default />
{/await}
-14
View File
@@ -1,14 +0,0 @@
<!--
@component
Imports the FontAwesome CSS file.
Needed, since the `@sveltejs/adapter-node` plugin doesn't seem to support
lazy-importing CSS, even though `@sveltejs/adapter-vercel` is fine with it!
However, making a `.svelte` file that imports the CSS file, and then
lazy-loading the `.svelte` file works.
-->
<script lang="ts">
import '@fortawesome/fontawesome-free/css/all.css';
</script>
+84 -104
View File
@@ -1,53 +1,39 @@
<script lang="ts">
import Card from '$lib/components/Card/Card.svelte';
import type { HistoryEntry, HistoryType, State, Tab } from '$lib/types';
import { notify, prompt } from '$lib/util/notify';
import { getStateString, inputStateStore } from '$lib/util/state';
import { logEvent } from '$lib/util/stats';
import dayjs from 'dayjs';
import dayjsRelativeTime from 'dayjs/plugin/relativeTime';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import BookmarkIcon from '~icons/material-symbols/bookmark-outline-rounded';
import TrashAltIcon from '~icons/material-symbols/delete-outline-rounded';
import DownloadIcon from '~icons/material-symbols/download-rounded';
import SaveIcon from '~icons/material-symbols/save-outline-rounded';
import UndoIcon from '~icons/material-symbols/settings-backup-restore-rounded';
import UploadIcon from '~icons/material-symbols/upload-rounded';
import HistoryIcon from '~icons/mdi/clock-outline';
import GitAltIcon from '~icons/mdi/git';
import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import { inputStateStore, getStateString } from '$lib/util/state';
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 moment from 'moment';
import type { HistoryType, State, Tab } from '$lib/types';
import { logEvent } from '$lib/util/stats';
dayjs.extend(dayjsRelativeTime);
const HISTORY_SAVE_INTERVAL = 60000;
const HISTORY_SAVE_INTERVAL = 60_000;
const tabSelectHandler = (tab: Tab) => {
historyModeStore.set(tab.id as HistoryType);
const tabSelectHandler = (message: CustomEvent<Tab>) => {
historyModeStore.set(message.detail.id as HistoryType);
};
let tabs: Tab[] = $state([
let tabs: Tab[] = [
{
id: 'manual',
title: 'Saved',
icon: BookmarkIcon
icon: 'far fa-bookmark'
},
{
id: 'auto',
title: 'Timeline',
icon: HistoryIcon
icon: 'fas fa-history'
}
]);
];
const downloadHistory = () => {
const data = get(historyStore);
@@ -55,7 +41,7 @@
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `mermaid-history-${dayjs().format('YYYY-MM-DD-HHmmss')}.json`;
a.download = `mermaid-history-${moment().format('YYYY-MM-DD-HHmmss')}.json`;
a.click();
URL.revokeObjectURL(url);
logEvent('history', {
@@ -67,13 +53,17 @@
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json';
input.addEventListener('change', async ({ target }: Event) => {
const file = (target as HTMLInputElement)?.files?.[0];
input.addEventListener('change', ({ target }: Event) => {
const file = (<HTMLInputElement>target).files[0];
if (!file) {
return;
}
const data: HistoryEntry[] = JSON.parse(await file.text());
restoreHistory(data);
const reader = new FileReader();
reader.onload = (e) => {
const data = JSON.parse(e.target.result as string);
restoreHistory(data);
};
reader.readAsText(file);
});
input.click();
};
@@ -103,6 +93,11 @@
inputStateStore.set({ ...state, updateDiagram: true });
};
const relativeTime = (time: number) => {
const t = new Date(time);
return `${new Date(t).toLocaleString()} (${moment(t).fromNow()})`;
};
onMount(() => {
historyModeStore.set('manual');
setInterval(() => {
@@ -116,93 +111,78 @@
{
id: 'loader',
title: 'Revisions',
icon: GitAltIcon
icon: 'fab fa-git-alt'
},
...tabs
];
historyModeStore.set('loader');
}
});
let isOpen = false;
</script>
<Card onselect={tabSelectHandler} isOpen isClosable={false} {tabs}>
{#snippet actions()}
<div class="flex items-center gap-2">
<Button
size="icon"
variant="ghost"
id="uploadHistory"
onclick={uploadHistory}
title="Upload history"><UploadIcon /></Button>
{#if $historyStore.length > 0}
<Button
id="downloadHistory"
size="icon"
variant="ghost"
onclick={downloadHistory}
title="Download history"><DownloadIcon /></Button>
{/if}
<Separator orientation="vertical" />
<Button
id="saveHistory"
size="icon"
variant="ghost"
onclick={() => saveHistory()}
title="Save current state"><SaveIcon /></Button>
{#if $historyModeStore !== 'loader'}
<Button
id="clearHistory"
size="icon"
variant="ghost"
class="hover:text-destructive"
onclick={() => clearHistory()}
title="Delete all saved states"><TrashAltIcon /></Button>
{/if}
</div>
{/snippet}
<ul class="flex h-full min-w-fit flex-col gap-2 overflow-auto p-2" id="historyList">
<Card on:select={tabSelectHandler} bind:isOpen {tabs} title="History">
<div slot="actions">
<button
id="uploadHistory"
class="btn btn-xs btn-secondary w-12"
on:click|stopPropagation={() => uploadHistory()}
title="Upload history"><i class="fa fa-upload" /></button>
{#if $historyStore.length > 0}
{#each $historyStore as { id, state, time, name, url, type } (id)}
<li class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<div class="flex flex-col">
{#if url}
<a
href={url}
target="_blank"
title="Open revision in new tab"
class="text-blue-500 hover:underline">{name}</a>
{:else}
<span class="whitespace-nowrap">{name}</span>
{/if}
<span class="text-xs whitespace-nowrap text-primary-foreground/30">
{new Date(time).toLocaleString()}
</span>
<button
id="downloadHistory"
class="btn btn-xs btn-secondary w-12"
on:click|stopPropagation={() => downloadHistory()}
title="Download history"><i class="fa fa-download" /></button>
{/if}
|
<button
id="saveHistory"
class="btn btn-xs btn-success w-12"
on:click|stopPropagation={() => saveHistory()}
title="Save current state"><i class="far fa-save" /></button>
{#if $historyModeStore !== 'loader'}
<button
id="clearHistory"
class="btn btn-xs btn-error w-12"
on:click|stopPropagation={() => clearHistory()}
title="Delete all saved states"><i class="fas fa-trash-alt" /></button>
{/if}
</div>
<ul class="p-2 space-y-2 overflow-auto h-56" id="historyList">
{#if $historyStore.length > 0}
{#each $historyStore as { id, state, time, name, url, type }}
<li class="rounded p-2 shadow flex-col">
<div class="flex">
<div class="flex-1">
<div class="flex flex-col text-base-content">
{#if url}
<a
href={url}
target="_blank"
rel="noreferrer"
title="Open revision in new tab"
class="hover:underline text-blue-500">{name}</a>
{:else}
<span>{name}</span>
{/if}
<span class="text-gray-400 text-sm">{relativeTime(time)}</span>
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-sm whitespace-nowrap text-primary-foreground/50">
{dayjs(time).fromNow()}
</span>
<Button size="icon" variant="ghost" onclick={() => restoreHistoryItem(state)}>
<UndoIcon />
</Button>
<div class="flex gap-2 content-center">
<button class="btn btn-success" on:click={() => restoreHistoryItem(state)}
><i class="fas fa-undo mr-1" />Restore</button>
{#if type !== 'loader'}
<Button
size="icon"
variant="ghost"
class="hover:text-destructive"
onclick={() => clearHistory(id)}>
<TrashAltIcon />
</Button>
<button class="btn btn-error" on:click={() => clearHistory(id)}
><i class="fas fa-trash-alt mr-1" />Delete</button>
{/if}
</div>
</div>
<Separator />
</li>
{/each}
{:else}
<div class="m-2 text-center">
<div class="m-2">
No items in History<br />
Click the Save button to save current state and restore it later.<br />
Timeline will automatically be saved every minute.
+25 -31
View File
@@ -1,14 +1,14 @@
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', () => {
@@ -17,30 +17,28 @@ describe('history', () => {
addHistoryEntry({
state: defaultState,
time: 12_345,
time: 12345,
type: 'manual'
});
const [manualEntry] = JSON.parse(
window.localStorage.getItem('manualHistoryStore') ?? '[]'
) as HistoryEntry[];
const [manualEntry]: HistoryEntry[] = JSON.parse(
window.localStorage.getItem('manualHistoryStore')
);
expect(manualEntry.time).toBe(12_345);
expect(manualEntry.time).toBe(12345);
expect(manualEntry.type).toBe('manual');
expect(manualEntry.name).not.toBeNull();
expect(manualEntry.state).not.toBeNull();
addHistoryEntry({
state: defaultState,
time: 54_321,
time: 54321,
type: 'auto'
});
const [autoEntry] = JSON.parse(
window.localStorage.getItem('autoHistoryStore') ?? '[]'
) as HistoryEntry[];
const [autoEntry]: HistoryEntry[] = JSON.parse(window.localStorage.getItem('autoHistoryStore'));
expect(autoEntry.time).toBe(54_321);
expect(autoEntry.time).toBe(54321);
expect(autoEntry.type).toBe('auto');
expect(autoEntry.name).not.toBeNull();
expect(autoEntry.state).not.toBeNull();
@@ -56,12 +54,12 @@ describe('history', () => {
it('should clear history entries', () => {
addHistoryEntry({
state: defaultState,
time: 12_345,
time: 12345,
type: 'manual'
});
addHistoryEntry({
state: { ...defaultState, code: 'graph TD\\n A[Christmas] -->|Get money| B(Go shopping)' },
time: 123_456,
time: 123456,
type: 'manual'
});
@@ -76,12 +74,12 @@ describe('history', () => {
historyModeStore.set('auto');
addHistoryEntry({
state: defaultState,
time: 54_321,
time: 54321,
type: 'auto'
});
addHistoryEntry({
state: { ...defaultState, code: 'graph TD\\n A[Christmas] -->|Get money| B(Go shopping)' },
time: 654_321,
time: 654321,
type: 'auto'
});
expect(get(historyStore).length).toBe(2);
@@ -103,23 +101,19 @@ describe('history migration', () => {
'autoHistoryStore',
'[{"state":{"code":"graph TD\\n A[New Year] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":false},"time":0,"type":"auto","name":"barking-dog"},{"state":{"code":"graph TD\\n A[Christmas] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":true},"time":0,"type":"manual","name":"needy-mosquito"}]'
);
let manualHistoryStore = JSON.parse(
window.localStorage.getItem('manualHistoryStore') ?? '[]'
) as HistoryEntry[],
autoHistoryStore = JSON.parse(
window.localStorage.getItem('autoHistoryStore') ?? '[]'
) as HistoryEntry[];
let manualHistoryStore: HistoryEntry[] = JSON.parse(
window.localStorage.getItem('manualHistoryStore')
);
let autoHistoryStore: HistoryEntry[] = JSON.parse(
window.localStorage.getItem('autoHistoryStore')
);
expect(manualHistoryStore.every(({ id }) => id !== undefined)).toBe(false);
expect(autoHistoryStore.every(({ id }) => id !== undefined)).toBe(false);
injectHistoryIDs();
manualHistoryStore = JSON.parse(
window.localStorage.getItem('manualHistoryStore') ?? '[]'
) as HistoryEntry[];
autoHistoryStore = JSON.parse(
window.localStorage.getItem('autoHistoryStore') ?? '[]'
) as HistoryEntry[];
manualHistoryStore = JSON.parse(window.localStorage.getItem('manualHistoryStore'));
autoHistoryStore = JSON.parse(window.localStorage.getItem('autoHistoryStore'));
expect(manualHistoryStore.every(({ id }) => id !== undefined)).toBe(true);
expect(autoHistoryStore.every(({ id }) => id !== undefined)).toBe(true);
});
+26 -36
View File
@@ -1,10 +1,10 @@
import type { HistoryEntry, HistoryType, Optional } from '$lib/types';
import { localStorage, persist } from '$lib/util/persist';
import { logEvent } from '$lib/util/stats';
import { generateSlug } from 'random-word-slugs';
import { derived, writable, get } from 'svelte/store';
import type { Readable, Writable } from 'svelte/store';
import { derived, get, writable } from 'svelte/store';
import { persist, localStorage } from '$lib/util/persist';
import { generateSlug } from 'random-word-slugs';
import type { HistoryEntry, HistoryType, Optional } from '$lib/types';
import { v4 as uuidV4 } from 'uuid';
import { logEvent } from '$lib/util/stats';
const MAX_AUTO_HISTORY_LENGTH = 30;
@@ -26,27 +26,19 @@ const manualHistoryStore: Writable<HistoryEntry[]> = persist(
'manualHistoryStore'
);
export const loaderHistoryStore: Writable<HistoryEntry[]> = writable([]);
export const loaderHistoryStore: Writable<HistoryEntry[]> = writable([] as HistoryEntry[]);
export const historyStore: Readable<HistoryEntry[]> = derived(
[historyModeStore, autoHistoryStore, manualHistoryStore, loaderHistoryStore],
([historyMode, autoHistories, manualHistories, loadedHistories], set) => {
switch (historyMode) {
case 'auto': {
set(autoHistories);
break;
}
case 'manual': {
set(manualHistories);
break;
}
case 'loader': {
set(loadedHistories);
break;
}
default: {
set(autoHistories);
}
if (historyMode === 'auto') {
set(autoHistories);
} else if (historyMode === 'manual') {
set(manualHistories);
} else if (historyMode === 'loader') {
set(loadedHistories);
} else {
set(autoHistories);
}
}
);
@@ -73,10 +65,10 @@ export const addHistoryEntry = (entryToAdd: Optional<HistoryEntry, 'id'>): void
}
return [entry, ...entries];
});
} else if (entry.type === 'manual') {
manualHistoryStore.update((entries) => [entry, ...entries]);
logEvent('history', { action: 'save' });
}
manualHistoryStore.update((entries) => [entry, ...entries]);
logEvent('history', { action: 'save' });
};
export const clearHistoryData = (idToClear?: string): void => {
@@ -98,7 +90,7 @@ export const getPreviousState = (auto: boolean): string => {
};
export const restoreHistory = (data: HistoryEntry[]) => {
const entries = data.filter((element) => validateEntry(element));
const entries = data.filter(validateEntry);
const invalidEntryCount = data.length - entries.length;
if (invalidEntryCount > 0) {
console.error(`${invalidEntryCount} invalid history entries were removed.`);
@@ -130,22 +122,20 @@ export const restoreHistory = (data: HistoryEntry[]) => {
alert('No valid entries found.');
}
};
const setIDs = (entries: HistoryEntry[]) => {
for (const entry of entries) {
if (!entry.id) {
entry.id = uuidV4();
}
}
return entries;
};
export const injectHistoryIDs = (): void => {
const setIDs = (entries: HistoryEntry[]) => {
for (const entry of entries) {
if (!entry.id) {
entry.id = uuidV4();
}
}
return entries;
};
autoHistoryStore.update(setIDs);
manualHistoryStore.update(setIDs);
};
const validateEntry = (entry: HistoryEntry): boolean => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-expect-error
return entry.type && entry.state && entry.time && true;
};
-143
View File
@@ -1,143 +0,0 @@
<script lang="ts">
import McWrapper from '$/components/McWrapper.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 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 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: MenuItem[] = $derived([
{ label: 'New', icon: AddIcon, href: $urlsStore.new, renderer: menuItem },
{ label: 'Duplicate', icon: DuplicateIcon, href: window.location.href, renderer: menuItem },
{
href: $urlsStore.mermaidChart({ medium: 'main_menu' }).playground,
icon: PlaygroundIcon,
isSectionEnd: true,
label: 'Edit in Playground',
onclick: () => logMermaidChartClick('editInPlayground'),
renderer: mcMenuItem
},
{
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
},
{
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,
icon: MermaidChartIcon,
label: 'Mermaid',
onclick: () => logMermaidChartClick('mermaidHome'),
renderer: mcMenuItem,
sharesData: false
}
]);
</script>
{#snippet menuItem(options: MenuItem)}
<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',
options.class
)}>
<options.icon class="size-5" />
{options.label}
</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" />
</Popover.Trigger>
<Popover.Content align="start" class="flex flex-col overflow-hidden border-2 p-0" sideOffset={16}>
{#each menuItems as { renderer, ...item } (item.label)}
{@render renderer(item)}
{/each}
</Popover.Content>
</Popover.Root>
-24
View File
@@ -1,24 +0,0 @@
<script lang="ts">
import { env } from '$/util/env';
import { isOnMermaidAI } from '$/util/migration/domainMigration';
import type { ComponentProps } from 'svelte';
import ExternalLinkWrapper from './ExternalLinkWrapper.svelte';
let {
children,
...props
}: Omit<
ComponentProps<typeof ExternalLinkWrapper>,
'isVisible' | 'domain' | 'showPopup'
> = $props();
const mermaidChartDomain = 'mermaid.ai';
</script>
<ExternalLinkWrapper
{...props}
domain={mermaidChartDomain}
isVisible={env.isEnabledMermaidChartLinks}
showPopup={!isOnMermaidAI()}>
{@render children()}
</ExternalLinkWrapper>
@@ -1,8 +0,0 @@
<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" />
-99
View File
@@ -1,99 +0,0 @@
<script lang="ts">
import type { EditorProps } from '$/types';
import { stateStore } from '$/util/state';
import { json, jsonLanguage } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
import { yamlFrontmatter } from '@codemirror/lang-yaml';
import { language } from '@codemirror/language';
import { Compartment, EditorState } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
import { vsCodeDark } from '@fsegurai/codemirror-theme-vscode-dark';
import { vsCodeLight } from '@fsegurai/codemirror-theme-vscode-light';
import { basicSetup } from 'codemirror';
import { mode } from 'mode-watcher';
import { onMount } from 'svelte';
let editorView: EditorView | undefined;
let editorContainer: HTMLDivElement;
let currentText = $state('');
const { onUpdate }: EditorProps = $props();
onMount(() => {
const themeCompartment = new Compartment();
const languageCompartment = new Compartment();
editorView = new EditorView({
state: EditorState.create({
doc: currentText,
extensions: [
basicSetup,
languageCompartment.of([]),
themeCompartment.of([]),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
const newText = update.state.doc.toString();
if (currentText === newText) {
return;
}
currentText = newText;
onUpdate(newText);
}
}),
EditorView.theme({
'&.cm-focused': {
outline: 'none'
},
'&.cm-editor': {
height: '100%'
},
'&.cm-scroller': {
overflow: 'auto'
}
})
]
}),
parent: editorContainer
});
const unsubscribeMode = mode.subscribe((mode) => {
editorView?.dispatch({
effects: themeCompartment.reconfigure(mode === 'dark' ? vsCodeDark : vsCodeLight)
});
});
const unsubscribeState = stateStore.subscribe(({ editorMode, code, mermaid }) => {
const text = editorMode === 'code' ? code : mermaid;
if (currentText === text || !editorView) {
return;
}
currentText = text;
editorView.dispatch({
changes: {
from: 0,
to: editorView.state.doc.length,
insert: text
}
});
const stateLanguage = editorView.state.facet(language);
const isStateJson = stateLanguage === jsonLanguage;
const isCodeJson = editorMode === 'config';
if (stateLanguage && isStateJson === isCodeJson) {
return;
}
editorView.dispatch({
effects: languageCompartment.reconfigure(
isCodeJson ? json() : yamlFrontmatter({ content: markdown() })
)
});
});
return () => {
unsubscribeMode();
unsubscribeState();
editorView?.destroy();
};
});
</script>
<div bind:this={editorContainer} class="size-full"></div>
+63 -82
View File
@@ -1,101 +1,82 @@
<script lang="ts" module>
import { logEvent, logMermaidChartClick } from '$lib/util/stats';
<script context="module">
import { version } from 'mermaid/package.json';
void logEvent('version', {
import { analytics } from '$lib/util/stats';
analytics?.track('version', {
mermaidVersion: version
});
</script>
<script lang="ts">
import MainMenu from '$/components/MainMenu.svelte';
import { Button } from '$/components/ui/button';
import { Separator } from '$/components/ui/separator';
import { dismissPromotion, getActivePromotion } from '$lib/util/promos/promo';
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';
import Theme from './Theme.svelte';
interface Props {
mobileToggle?: Snippet;
children: Snippet;
hidePromotion?: boolean;
interface Link {
title: string;
href: string;
icon?: string;
}
let { children, mobileToggle, hidePromotion = false }: Props = $props();
type Links = ComponentProps<typeof DropdownNavMenu>['links'];
const githubLinks: Links = [
{ title: 'Mermaid JS', href: 'https://github.com/mermaid-js/mermaid' },
const links: Link[] = [
{
title: 'Mermaid Live Editor',
href: 'https://github.com/mermaid-js/mermaid-live-editor'
title: 'Documentation',
href: 'https://mermaid-js.github.io/mermaid/#/n00b-gettingStarted'
},
{
title: 'Mermaid CLI',
title: 'Tutorial',
href: 'https://github.com/mermaid-js/mermaid/blob/develop/docs/Tutorials.md'
},
{
title: 'Mermaid',
href: 'https://github.com/mermaid-js/mermaid'
},
{
title: 'CLI',
href: 'https://github.com/mermaid-js/mermaid-cli'
},
{
title: '',
href: 'https://github.com/mermaid-js/mermaid-live-editor',
icon: 'fab fa-github fa-lg'
}
];
let activePromotion = $state(hidePromotion ? undefined : getActivePromotion());
const trackBannerClick = () => {
if (!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"
role="button"
tabindex="0"
onclick={trackBannerClick}
onkeypress={trackBannerClick}>
<activePromotion.component {closeBanner} />
</div>
{#snippet closeBanner()}
<Button
title="Dismiss banner"
variant="ghost"
class="hover:bg-transparent hover:text-[#261A56]"
size="sm"
onclick={() => {
dismissPromotion(activePromotion?.id);
activePromotion = undefined;
}}>
<CloseIcon />
</Button>
{/snippet}
<div class="navbar shadow-lg bg-primary p-0">
<div class="flex-1 px-2 mx-2">
<span class="text-lg font-bold">
<a href="/">Mermaid<span class="text-xs font-thin">v{version}</span> Live Editor</a>
</span>
</div>
{/if}
<label for="menu-toggle" class="pointer-cursor lg:hidden block"
><svg
class="fill-current "
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 20 20"
><title>Menu</title><path d="M0 3h20v2H0V3zm0 6h20v2H0V9zm0 6h20v2H0v-2z" /></svg
></label>
<input class="hidden" type="checkbox" id="menu-toggle" />
<nav class="z-50 flex p-4 sm:p-6">
<div class="flex flex-1 items-center gap-2">
<MainMenu />
<MermaidIcon class="size-6" />
<a href="/" class="whitespace-nowrap text-accent">
{#if !mobileToggle}
Mermaid
{/if}
Live Editor
</a>
<Theme />
<div class="hidden lg:flex lg:items-center lg:w-auto w-full" id="menu">
<ul class="lg:flex items-center justify-between text-base pt-4 lg:pt-0">
{#each links as { title, href, icon }}
<li>
<a class="btn btn-ghost" target="_blank" rel="noreferrer" {href}>
{#if icon}
<i class={icon} />
{/if}
{title}</a>
</li>
{/each}
</ul>
</div>
<div
id="menu"
class="hidden flex-nowrap items-center justify-between gap-3 overflow-hidden md:flex">
<DropdownNavMenu icon={GithubIcon} links={githubLinks} />
<Separator orientation="vertical" />
{@render children()}
</div>
{@render mobileToggle?.()}
</nav>
</div>
<style>
#menu-toggle:checked + #menu {
display: block;
}
.navbar {
z-index: 10000;
}
</style>
-34
View File
@@ -1,34 +0,0 @@
<script lang="ts">
import FloatingToolbar from '$/components/FloatingToolbar.svelte';
import { Button } from '$/components/ui/button';
import { Separator } from '$/components/ui/separator';
import type { PanZoomState } from '$/util/panZoom';
import { urlsStore } from '$/util/state';
import ExpandIcon from '~icons/material-symbols/open-in-full-rounded';
import ArrowsToCircleIcon from '~icons/material-symbols/screenshot-frame-2';
import MagnifyingGlassPlusIcon from '~icons/material-symbols/zoom-in';
import MagnifyingGlassMinusIcon from '~icons/material-symbols/zoom-out';
let { panZoomState }: { panZoomState: PanZoomState } = $props();
</script>
<FloatingToolbar>
<Button variant="ghost" size="icon" title="Reset view" onclick={() => panZoomState.reset()}>
<ArrowsToCircleIcon />
</Button>
<Separator orientation="vertical" />
<Button
variant="ghost"
size="icon"
class="hidden sm:block"
onclick={() => panZoomState.zoomOut()}>
<MagnifyingGlassMinusIcon />
</Button>
<Button variant="ghost" size="icon" class="hidden sm:block" onclick={() => panZoomState.zoomIn()}>
<MagnifyingGlassPlusIcon />
</Button>
<Separator orientation="vertical" class="hidden sm:block" />
<Button variant="ghost" size="icon" title="Full Screen" href={$urlsStore.view} target="_blank">
<ExpandIcon />
</Button>
</FloatingToolbar>
+120 -49
View File
@@ -1,73 +1,144 @@
<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 Card from '$lib/components/Card/Card.svelte';
import { logEvent } from '$lib/util/stats';
import ShapesIcon from '~icons/material-symbols/account-tree-outline-rounded';
const extras = {
ZenUML: `zenuml
title Order Service
@Actor Client #FFEBE6
@Boundary OrderController #0747A6
@EC2 <<BFF>> OrderService #E3FCEF
group BusinessService {
@Lambda PurchaseService
@AzureFunction InvoiceService
const samples = {
Flow: `graph 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]`,
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!`,
Class: `classDiagram
Animal <|-- Duck
Animal <|-- Fish
Animal <|-- Zebra
Animal : +int age
Animal : +String gender
Animal: +isMammal()
Animal: +mate()
class Duck{
+String beakColor
+swim()
+quack()
}
@Starter(Client)
// \`POST /orders\`
OrderController.post(payload) {
OrderService.create(payload) {
order = new Order(payload)
if(order != null) {
par {
PurchaseService.createPO(order)
InvoiceService.createInvoice(order)
}
}
}
class Fish{
-int sizeInFeet
-canEat()
}
`
class Zebra{
+bool is_wild
+run()
}`,
State: `stateDiagram-v2
[*] --> Still
Still --> [*]
Still --> Moving
Moving --> Still
Moving --> Crash
Crash --> [*]`,
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`,
Pie: `pie title Pets adopted by volunteers
"Dogs" : 386
"Cats" : 85
"Rats" : 15`,
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"`,
'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`,
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 effectivness<br/>and features
On Automatic creation
Uses
Creative techniques
Strategic planning
Argument mapping
Tools
Pen and paper
Mermaid`
};
const samples = { ...getSampleDiagrams(), ...extras } as const;
const loadSampleDiagram = (diagramType: string): void => {
updateCode(samples[diagramType], {
resetPanZoom: true,
updateDiagram: true
updateDiagram: true,
resetPanZoom: true
});
logEvent('loadSampleDiagram', { diagramType });
};
const mainDiagrams = [
'Flowchart',
'Class',
// Adding in this array will add an icon to the preset menu
const newDiagrams: Array<keyof typeof samples> = ['Mindmap'];
const diagramOrder: Array<keyof typeof samples> = [
'Sequence',
'Entity Relationship',
'Flow',
'Class',
'State',
'ER',
'Gantt',
'User Journey',
'Git',
'Pie',
'Mindmap'
];
const diagramOrder = [
...mainDiagrams,
...Object.keys(samples)
.filter((key) => !mainDiagrams.includes(key))
.sort()
];
</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)}
<Button
size="sm"
class="w-fit min-w-20 flex-grow normal-case"
onclick={() => loadSampleDiagram(sample)}>
<Card title="Sample Diagrams" isOpen={false}>
<div class="flex flex-wrap p-2 gap-2">
{#each diagramOrder as sample}
<button
class="btn btn-sm btn-primary w-28 normal-case flex-grow"
on:click={() => loadSampleDiagram(sample)}>
{sample}
</Button>
{#if newDiagrams.includes(sample)}
<span class="ml-2 fa fa-heart" />
{/if}
</button>
{/each}
</div>
</Card>
-66
View File
@@ -1,66 +0,0 @@
<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">
<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>
{#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}
-49
View File
@@ -1,49 +0,0 @@
<script>
import { buttonVariants } from '$/components/ui/button';
import * as Dialog from '$/components/ui/dialog';
import { Separator } from '$/components/ui/separator';
import { env } from '$/util/env';
import { 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';
</script>
<Dialog.Root>
<Dialog.Trigger class={buttonVariants({ size: 'sm' })}>Share</Dialog.Trigger>
<Dialog.Content>
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2 text-xl">
<ShareIcon class="size-5" /> Shareable links
</Dialog.Title>
<Dialog.Description>Share your diagrams with others.</Dialog.Description>
</Dialog.Header>
<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" />
Mermaid Live Editor
</h2>
<CopyInput value={window.location.href} />
<Dialog.Description>
The content of the diagrams you create never leaves your browser.
</Dialog.Description>
</div>
{#if env.isEnabledMermaidChartLinks}
<Separator />
<div class="flex flex-col gap-2">
<h2 class="flex items-center gap-2">
<MermaidChartIcon class="size-5" />
Mermaid Chart Playground
</h2>
<CopyInput value={$urlsStore.mermaidChart({ medium: 'share' }).playground} />
<Dialog.Description>
Opens the Mermaid Chart Playground with Mermaid AI, Visual Editor, and more.
</Dialog.Description>
</div>
{/if}
</div>
</Dialog.Content>
</Dialog.Root>
@@ -1,21 +0,0 @@
<script lang="ts">
import FloatingToolbar from '$/components/FloatingToolbar.svelte';
import { Toggle } from '$/components/ui/toggle';
import { defaultState, inputStateStore } from '$/util/state';
import RoughIcon from '~icons/material-symbols/draw-outline-rounded';
import BackgroundIcon from '~icons/material-symbols/grid-4x4-rounded';
if ($inputStateStore.grid === undefined) {
// Handle cases where old states were saved without grid option
$inputStateStore.grid = defaultState.grid;
}
</script>
<FloatingToolbar>
<Toggle bind:pressed={$inputStateStore.rough} size="sm" title="Hand-Drawn">
<RoughIcon />
</Toggle>
<Toggle bind:pressed={$inputStateStore.grid} size="sm" title="Background Grid">
<BackgroundIcon />
</Toggle>
</FloatingToolbar>
+64
View File
@@ -0,0 +1,64 @@
<script>
import { setTheme, themeStore } from '$lib/util/theme';
const themes = [
'🌝 light',
'🌚 dark',
'🧁 cupcake',
'🐝 bumblebee',
'✳️ emerald',
'🏢 corporate',
'🌃 synthwave',
'👴 retro',
'🤖 cyberpunk',
'🌸 valentine',
'🎃 halloween',
'🌷 garden',
'🌲 forest',
'🐟 aqua',
'👓 lofi',
'🖍 pastel',
'🧚‍♀️ fantasy',
'📝 wireframe',
'🏴 black',
'💎 luxury',
'🧛‍♂️ dracula'
];
</script>
<div class="hidden lg:block dropdown">
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<div tabindex="0" class="btn btn-ghost ">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
class="inline-block w-6 h-6 stroke-current md:mr-2"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" /></svg>
<span class="hidden md:inline"> Change Theme </span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 1792 1792"
class="inline-block w-4 h-4 ml-1 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>
</div>
<div
class="mt-14 overflow-y-auto shadow-2xl top-px dropdown-content h-96 w-56 bg-base-200 text-base-content">
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<ul tabindex="0" class="p-4 menu compact">
{#each themes as theme}
<li class={theme.includes($themeStore.theme) ? 'bordered' : ''}>
<span
class="btn btn-ghost justify-start"
on:click={() => setTheme(theme)}
on:keypress={() => setTheme(theme)}>{theme}</span>
</li>
{/each}
</ul>
</div>
</div>
-39
View File
@@ -1,39 +0,0 @@
<script lang="ts">
import { mode } from 'mode-watcher';
import { cubicInOut } from 'svelte/easing';
import { type TransitionConfig } from 'svelte/transition';
import MoonIcon from '~icons/material-symbols/dark-mode-outline-rounded';
import SunIcon from '~icons/material-symbols/light-mode-outline-rounded';
const spin = (
node: Element,
{ duration = 400, easing = cubicInOut, clockWise = true } = {}
): TransitionConfig => {
const style = getComputedStyle(node);
const opacity = +style.opacity;
const transform = style.transform === 'none' ? '' : style.transform;
return {
duration,
easing,
css: (t, u) => `
transform: ${transform} rotate(${u * 90 * (clockWise ? 1 : -1)}deg);
opacity: ${opacity * t}
`
};
};
</script>
<div class="inline-grid">
{#key $mode}
<div
in:spin={{ clockWise: true }}
out:spin={{ clockWise: false }}
class="col-start-1 row-start-1">
{#if $mode === 'dark'}
<MoonIcon />
{:else}
<SunIcon />
{/if}
</div>
{/key}
</div>
@@ -1,31 +0,0 @@
<script lang="ts">
import FloatingToolbar from '$/components/FloatingToolbar.svelte';
import Privacy from '$/components/Privacy.svelte';
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';
</script>
<FloatingToolbar>
<span class="text-sm font-semibold opacity-60">v{version}</span>
{#if !env.hidePrivacyPolicy}
<Button variant="ghost" size="icon" title="Privacy & Security">
<Privacy />
</Button>
<Separator orientation="vertical" />
{/if}
<Button
variant="ghost"
size="icon"
data-testid={TID.themeToggleButton}
title="Switch to {$mode === 'dark' ? 'light' : 'dark'} theme"
class="[&_svg]:size-5"
onclick={() => setMode($mode === 'dark' ? 'light' : 'dark')}>
<ThemeIcon />
</Button>
</FloatingToolbar>
+108 -121
View File
@@ -1,170 +1,157 @@
<script lang="ts">
import type { State, ValidatedState } from '$/types';
import { recordRenderTime, shouldRefreshView } from '$/util/autoSync';
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 FontAwesome, { mayContainFontAwesome } from '$lib/components/FontAwesome.svelte';
import uniqueID from 'lodash-es/uniqueId';
import type { MermaidConfig } from 'mermaid';
import { mode } from 'mode-watcher';
import { inputStateStore, stateStore, updateCodeStore } from '$lib/util/state';
import { onMount } from 'svelte';
import { Svg2Roughjs } from 'svg2roughjs';
import panzoom from 'svg-pan-zoom';
import type { State, ValidatedState } from '$lib/types';
import { logEvent } from '$lib/util/stats';
import { AsyncQueue, cmdKey } from '$lib/util/util';
import { render as renderDiagram } from '$lib/util/mermaid';
let {
panZoomState = new PanZoomState(),
shouldShowGrid = true
}: { panZoomState?: PanZoomState; shouldShowGrid?: boolean } = $props();
let code = '';
let config = '';
let container: HTMLDivElement | undefined = $state();
let rough: boolean;
let view: HTMLDivElement | undefined = $state();
let error = $state(false);
let panZoom = true;
let container: HTMLDivElement;
let view: HTMLDivElement;
let error = false;
let outOfSync = false;
let hide = false;
let manualUpdate = true;
let waitForFontAwesomeToLoad: FontAwesome['waitForFontAwesomeToLoad'] | undefined = $state();
let panZoomEnabled = $stateStore.panZoom;
let pzoom: SvgPanZoom.Instance;
// Set up panZoom state observer to update the store when pan/zoom changes
const setupPanZoomObserver = () => {
panZoomState.onPanZoomChange = (pan, zoom) => {
updateCodeStore({ pan, zoom });
};
const handlePanZoomChange = () => {
const pan = pzoom.getPan();
const zoom = pzoom.getZoom();
updateCodeStore({ pan, zoom });
logEvent('panZoom');
};
const handlePanZoom = (state: State, graphDiv: SVGSVGElement) => {
try {
panZoomState.updateElement(graphDiv, state);
} catch (error) {
console.error('PanZoom error:', error);
const handlePanZoom = (state: State) => {
if (!state.panZoom) {
return;
}
hide = true;
pzoom?.destroy();
pzoom = undefined;
Promise.resolve().then(() => {
const graphDiv = document.getElementById('graph-div');
pzoom = panzoom(graphDiv, {
onPan: handlePanZoomChange,
onZoom: handlePanZoomChange,
controlIconsEnabled: true,
fit: true,
center: true
});
const { pan, zoom } = state;
if (pan !== undefined && zoom !== undefined && Number.isFinite(zoom)) {
pzoom.zoom(zoom);
pzoom.pan(pan);
}
hide = false;
});
};
const handleStateChange = async (state: ValidatedState) => {
const startTime = Date.now();
if (state.error !== undefined) {
error = true;
return;
}
error = false;
let diagramType: string | undefined;
try {
if (container) {
if (container && state && (state.updateDiagram || state.autoSync)) {
if (!state.autoSync) {
$inputStateStore.updateDiagram = false;
}
outOfSync = false;
manualUpdate = true;
// Do not render if there is no change in Code/Config/PanZoom
if (
code === state.code &&
config === state.mermaid &&
rough === state.rough &&
panZoom === state.panZoom
) {
if (code === state.code && config === state.mermaid && panZoomEnabled === state.panZoom) {
return;
}
if (!shouldRefreshView()) {
return;
}
code = state.code;
config = state.mermaid;
rough = state.rough;
panZoom = state.panZoom ?? true;
if (mayContainFontAwesome(code)) {
await waitForFontAwesomeToLoad?.();
}
const scroll = view?.parentElement?.scrollTop;
panZoomEnabled = state.panZoom;
const scroll = view.parentElement.scrollTop;
delete container.dataset.processed;
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) {
// eslint-disable-next-line svelte/no-dom-manipulating
container.innerHTML = svg;
let graphDiv = document.querySelector<SVGSVGElement>(`#${viewID}`);
if (!graphDiv) {
throw new Error('graph-div not found');
}
if (state.rough) {
const svg2roughjs = new Svg2Roughjs('#container');
svg2roughjs.svg = graphDiv;
await svg2roughjs.sketch();
graphDiv.remove();
const sketch = document.querySelector<SVGSVGElement>('#container > svg');
if (!sketch) {
throw new Error('sketch not found');
}
const height = sketch.getAttribute('height');
const width = sketch.getAttribute('width');
sketch.setAttribute('id', 'graph-div');
sketch.setAttribute('height', '100%');
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%';
if (bindFunctions) {
bindFunctions(graphDiv);
await renderDiagram(
Object.assign({}, JSON.parse(state.mermaid)),
code,
'graph-div',
(svgCode, bindFunctions) => {
if (svgCode.length > 0) {
handlePanZoom(state);
container.innerHTML = svgCode;
// console.log(container.innerHTML);
const graphDiv = document.getElementById('graph-div');
graphDiv.setAttribute('height', '100%');
graphDiv.style.maxWidth = '100%';
if (bindFunctions) {
bindFunctions(graphDiv);
}
}
}
if (state.panZoom) {
handlePanZoom(state, graphDiv);
}
}
if (view?.parentElement && scroll) {
view.parentElement.scrollTop = scroll;
}
);
view.parentElement.scrollTop = scroll;
error = false;
} else if (manualUpdate) {
manualUpdate = false;
} else if (code !== state.code || config !== state.mermaid) {
outOfSync = true;
}
} catch (error_) {
console.error('view fail', error_);
} catch (e) {
console.error('view fail', e);
error = true;
}
const renderTime = Date.now() - startTime;
saveStatistics({ code, diagramType, isRough: state.rough, renderTime });
recordRenderTime(renderTime, () => {
$inputStateStore.updateDiagram = true;
});
};
const q = new AsyncQueue(handleStateChange);
onMount(() => {
setupPanZoomObserver();
// 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(() => {}));
stateStore.subscribe(async (state) => {
await q.process(state);
});
window.addEventListener('resize', () => {
if ($stateStore.panZoom && pzoom) {
pzoom.resize();
}
});
console.log('View mounted');
});
</script>
<FontAwesome bind:waitForFontAwesomeToLoad />
{#if (error && $stateStore.error instanceof Error) || outOfSync}
<div
class="absolute w-full p-2 z-10 {error
? 'text-red-600'
: 'text-yellow-600'} bg-base-100 bg-opacity-80 text-center"
id="errorContainer">
{#if error}
{$stateStore.error}
{:else}
Diagram out of sync. <br />
Press <i class="fas fa-sync" /> (Sync button) or <kbd>{cmdKey} + Enter</kbd> to sync.
{/if}
</div>
{/if}
<div
id="view"
bind:this={view}
class={['h-full w-full', shouldShowGrid && `grid-bg-${$mode}`, error && 'opacity-50']}>
<div id="container" bind:this={container} class="h-full overflow-auto"></div>
<div id="view" bind:this={view} class="p-2 h-full" class:error class:outOfSync>
<div id="container" bind:this={container} class="h-full overflow-auto" class:hide />
</div>
<style>
.grid-bg-light {
background-size: 30px 30px;
background-image: radial-gradient(circle, #e4e4e48c 2px, #0000 2px);
#view {
flex: 1;
}
.grid-bg-dark {
background-size: 30px 30px;
background-image: radial-gradient(circle, #46464646 2px, #0000 2px);
#container {
transition: visibility 0.3s;
}
.error,
.outOfSync {
opacity: 0.5;
}
.hide {
visibility: hidden;
}
</style>
+260
View File
@@ -0,0 +1,260 @@
<script lang="ts">
import { browser } from '$app/environment';
import Card from '$lib/components/Card/Card.svelte';
import { env } from '$lib/util/env';
import { pakoSerde } from '$lib/util/serde';
import { stateStore } from '$lib/util/state';
import { logEvent } from '$lib/util/stats';
import { toBase64 } from 'js-base64';
import moment from 'moment';
const { krokiRendererUrl, rendererUrl } = env;
type Exporter = (context: CanvasRenderingContext2D, image: HTMLImageElement) => () => void;
const getFileName = (ext: string) =>
`mermaid-diagram-${moment().format('YYYY-MM-DD-HHmmss')}.${ext}`;
const getBase64SVG = (svg?: HTMLElement, width?: number, height?: number): string => {
svg?.setAttribute('height', `${height}px`);
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 = getSvgEl();
}
const svgString = svg.outerHTML
.replaceAll('<br>', '<br/>')
.replaceAll(/<img([^>]*)>/g, (m, g: string) => `<img ${g} />`);
return toBase64(svgString);
};
const exportImage = (event: Event, exporter: Exporter) => {
const canvas: HTMLCanvasElement = document.createElement('canvas');
const svg: HTMLElement = document.querySelector('#container svg');
const box: DOMRect = svg.getBoundingClientRect();
canvas.width = box.width;
canvas.height = box.height;
if (imagemodeselected === 'width') {
const ratio = box.height / box.width;
canvas.width = userimagesize;
canvas.height = userimagesize * ratio;
} else if (imagemodeselected === 'height') {
const ratio = box.width / box.height;
canvas.width = userimagesize * ratio;
canvas.height = userimagesize;
}
const context = canvas.getContext('2d');
context.fillStyle = 'white';
context.fillRect(0, 0, canvas.width, canvas.height);
const image = new Image();
image.onload = exporter(context, image);
image.src = `data:image/svg+xml;base64,${getBase64SVG(svg, canvas.width, canvas.height)}`;
event.stopPropagation();
event.preventDefault();
};
const getSvgEl = () => {
const svgEl: HTMLElement = document
.querySelector('#container svg')
.cloneNode(true) as HTMLElement;
svgEl.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
const fontAwesomeCdnUrl = Array.from(document.head.getElementsByTagName('link'))
.map((l) => l.href)
.find((h) => h && h.includes('font-awesome'));
if (fontAwesomeCdnUrl == null) {
return svgEl;
}
const styleEl = document.createElement('style');
styleEl.innerText = `@import url("${fontAwesomeCdnUrl}");'`;
svgEl.prepend(styleEl);
return svgEl;
};
const simulateDownload = (download: string, href: string): void => {
const a = document.createElement('a');
a.download = download;
a.href = href;
a.click();
a.remove();
};
const downloadImage: Exporter = (context, image) => {
return () => {
const { canvas } = context;
context.drawImage(image, 0, 0, canvas.width, canvas.height);
simulateDownload(
getFileName('png'),
canvas.toDataURL('image/png').replace('image/png', 'image/octet-stream')
);
};
};
const isClipboardAvailable = (): boolean => {
return Object.prototype.hasOwnProperty.call(window, 'ClipboardItem') as boolean;
};
const clipboardCopy: Exporter = (context, image) => {
return () => {
const { canvas } = context;
context.drawImage(image, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
try {
// @ts-ignore: https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/1004/files
void navigator.clipboard.write([
/* eslint-disable no-undef */
// @ts-ignore: https://github.com/microsoft/TypeScript/issues/43821
new ClipboardItem({
[blob.type]: blob
})
]);
} catch (error) {
console.error(error);
}
});
};
};
const onCopyClipboard = (event: Event) => {
exportImage(event, clipboardCopy);
logEvent('copyClipboard');
};
const onDownloadPNG = (event: Event) => {
exportImage(event, downloadImage);
logEvent('download', {
type: 'png'
});
};
const onDownloadSVG = () => {
simulateDownload(getFileName('svg'), `data:image/svg+xml;base64,${getBase64SVG()}`);
logEvent('download', {
type: 'svg'
});
};
const onCopyMarkdown = () => {
(document.getElementById('markdown') as HTMLInputElement).select();
document.execCommand('Copy');
logEvent('copyMarkdown');
};
let gistURL = '';
stateStore.subscribe(({ loader }) => {
if (loader?.type === 'gist') {
// @ts-ignore Gist will have url
gistURL = loader.config.url;
}
});
const loadGist = () => {
if (!gistURL) {
alert('Please enter a Gist URL first');
}
window.location.href = `${window.location.pathname}?gist=${gistURL}`;
logEvent('loadGist');
};
let iUrl: string;
let svgUrl: string;
let krokiUrl: string;
let mdCode: string;
let imagemodeselected = 'auto';
let userimagesize = 1080;
let isNetlify = false;
if (browser && ['mermaid.live', 'netlify'].some((path) => window.location.host.includes(path))) {
isNetlify = true;
}
stateStore.subscribe(async (state) => {
const { code, serialized } = await state;
iUrl = `${rendererUrl}/img/${serialized}?type=png`;
svgUrl = `${rendererUrl}/svg/${serialized}`;
krokiUrl = `${krokiRendererUrl}/mermaid/svg/${pakoSerde.serialize(code)}`;
mdCode = `[![](${iUrl})](${window.location.protocol}//${window.location.host}${window.location.pathname}#${serialized})`;
});
</script>
<Card title="Actions" isOpen={true}>
<div class="flex flex-wrap gap-2 m-2">
{#if isClipboardAvailable()}
<button class="action-btn w-full" on:click={onCopyClipboard}
><i class="far fa-copy mr-2" /> 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>
<button id="downloadSVG" class="action-btn flex-grow" on:click={onDownloadSVG}>
<i class="fas fa-download mr-2" /> 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>
<div class="flex gap-2 items-center">
PNG size
<label for="autosize">
<input type="radio" value="auto" id="autosize" bind:group={imagemodeselected} /> Auto
</label>
<label for="width">
<input type="radio" value="width" id="width" bind:group={imagemodeselected} /> Width
</label>
<label for="height">
<input type="radio" value="height" id="height" bind:group={imagemodeselected} /> Height
</label>
{#if imagemodeselected !== 'auto'}
<input
id="height"
class="input"
type="number"
min="3"
max="10000"
bind:value={userimagesize} />
{/if}
</div>
<div class="w-full flex gap-2 items-center">
<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>
<div class="w-full flex gap-2 items-center">
<input
class="input"
id="gist"
type="text"
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>
</label>
</div>
{#if isNetlify}
<div class="w-full flex items-center justify-center">
<a class="link underline text-gray-500 text-sm" href="https://netlify.com">
This site is powered by Netlify
</a>
</div>
{/if}
</div>
</Card>
@@ -0,0 +1,3 @@
// Vitest Snapshot v1
exports[`card.svelte > mounts 1`] = `"<div><div class=\\"card rounded overflow-hidden m-2 flex-grow flex flex-col shadow-2xl\\"><div class=\\"bg-primary p-2 pb-0 flex-none cursor-pointer\\"><div class=\\"flex justify-between\\"><div class=\\"flex cursor-default s-_wx1E_JHsCoF\\"><span class=\\"mr-2 font-semibold s-_wx1E_JHsCoF\\"><i class=\\"fas fa-chevron-right icon s-_wx1E_JHsCoF isOpen\\"></i> TabTest</span> <ul class=\\"tabs s-_wx1E_JHsCoF\\"><div class=\\"tab tab-lifted tab-active s-_wx1E_JHsCoF\\"><i class=\\"mr-1 undefined s-_wx1E_JHsCoF\\"></i> title1 </div><div class=\\"tab tab-lifted text-primary-content s-_wx1E_JHsCoF\\"><i class=\\"mr-1 undefined s-_wx1E_JHsCoF\\"></i> title2 </div></ul></div><!--<Tabs>--> <div class=\\"flex gap-x-4 items-center -mt-2\\"></div></div></div> <div class=\\"card-body p-0 flex-grow overflow-auto text-base-content\\"></div></div><!--<Card>--></div>"`;
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts">
import type { Tab } from '$lib/types';
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: string = '';
export let title: string;
$: isOpen = isCloseable ? isOpen : true;
$: isTabsShown = isOpen && tabs.length > 0;
</script>
<div class="card rounded overflow-hidden m-2 flex-grow flex flex-col shadow-2xl">
<div
class="bg-primary p-2 {isTabsShown ? 'pb-0' : ''} flex-none cursor-pointer"
on:click={() => (isOpen = !isOpen)}
on:keypress={() => (isOpen = !isOpen)}>
<div class="flex justify-between">
<Tabs on:select {tabs} bind:isOpen {title} {isCloseable} {activeTabID} />
<div class="flex gap-x-4 items-center {isTabsShown ? '-mt-2' : ''}">
<slot name="actions" />
</div>
</div>
</div>
{#if isOpen}
<div class="card-body p-0 flex-grow overflow-auto text-base-content" transition:slide>
<slot />
</div>
{/if}
</div>
+24
View File
@@ -0,0 +1,24 @@
import { cleanup, render } from '@testing-library/svelte';
import { describe, expect, it, afterEach } from 'vitest';
import Card from './Card.svelte';
describe('card.svelte', () => {
// TODO: @testing-library/svelte claims to add this automatically but it doesn't work without explicit afterEach
afterEach(() => cleanup());
it('mounts', () => {
const { container } = render(Card, {
title: 'TabTest',
tabs: [
{ id: 't1', title: 'title1' },
{ id: 't2', title: 'title2' }
]
});
expect(container).toBeTruthy();
expect(container).toHaveTextContent('TabTest');
expect(container).toHaveTextContent('title1');
expect(container).toHaveTextContent('title2');
expect(container).not.toHaveTextContent('title3');
expect(container.innerHTML).toMatchSnapshot();
});
});
+52
View File
@@ -0,0 +1,52 @@
<script lang="ts">
import type { Tab, TabEvents } from '$lib/types';
import { createEventDispatcher } from 'svelte';
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;
if (!activeTabID && tabs.length > 0) {
activeTabID = tabs[0].id;
}
const dispatch = createEventDispatcher<TabEvents>();
const toggleTabs = (tab: Tab) => {
activeTabID = tab.id;
dispatch('select', tab);
};
</script>
<div class="flex cursor-default">
<span
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 />
{/if}
{title}</span>
{#if isOpen && tabs}
<ul class="tabs" transition:fade>
{#each tabs as tab}
<div
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}" />
{tab.title}
</div>
{/each}
</ul>
{/if}
</div>
<style>
.icon {
transition-duration: 0.5s;
}
.isOpen {
transform: rotate(90deg);
}
</style>
+192
View File
@@ -0,0 +1,192 @@
<script lang="ts">
import Card from '$lib/components/Card/Card.svelte';
import { inputStateStore, getStateString } from '$lib/util/state';
import {
addHistoryEntry,
historyModeStore,
clearHistoryData,
getPreviousState,
historyStore,
loaderHistoryStore,
restoreHistory
} from './history';
import { notify, prompt } from '$lib/util/notify';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import moment from 'moment';
import type { HistoryType, State, Tab } from '$lib/types';
import { logEvent } from '$lib/util/stats';
const HISTORY_SAVE_INTERVAL = 60000;
const tabSelectHandler = (message: CustomEvent<Tab>) => {
historyModeStore.set(message.detail.id as HistoryType);
};
let tabs: Tab[] = [
{
id: 'manual',
title: 'Saved',
icon: 'far fa-bookmark'
},
{
id: 'auto',
title: 'Timeline',
icon: 'fas fa-history'
}
];
const downloadHistory = () => {
const data = get(historyStore);
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `mermaid-history-${moment().format('YYYY-MM-DD-HHmmss')}.json`;
a.click();
URL.revokeObjectURL(url);
logEvent('history', {
action: 'download'
});
};
const uploadHistory = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'application/json';
input.addEventListener('change', ({ target }: Event) => {
const file = (<HTMLInputElement>target).files[0];
if (!file) {
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const data = JSON.parse(e.target.result as string);
restoreHistory(data);
};
reader.readAsText(file);
});
input.click();
};
const saveHistory = (auto = false) => {
const currentState: string = getStateString();
const previousState: string = getPreviousState(auto);
if (previousState !== currentState) {
addHistoryEntry({
state: $inputStateStore,
time: Date.now(),
type: auto ? 'auto' : 'manual'
});
} else if (!auto) {
notify('State already saved.');
}
};
const clearHistory = (id?: string): void => {
if (!id && !prompt('Clear all saved items?')) {
return;
}
clearHistoryData(id);
};
const restoreHistoryItem = (state: State): void => {
inputStateStore.set({ ...state, updateDiagram: true });
};
const relativeTime = (time: number) => {
const t = new Date(time);
return `${new Date(t).toLocaleString()} (${moment(t).fromNow()})`;
};
onMount(() => {
historyModeStore.set('manual');
setInterval(() => {
saveHistory(true);
}, HISTORY_SAVE_INTERVAL);
});
loaderHistoryStore.subscribe((entries) => {
if (entries.length > 0 && tabs.length === 2) {
tabs = [
{
id: 'loader',
title: 'Revisions',
icon: 'fab fa-git-alt'
},
...tabs
];
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-xs btn-secondary w-12"
on:click|stopPropagation={() => uploadHistory()}
title="Upload history"><i class="fa fa-upload" /></button>
{#if $historyStore.length > 0}
<button
id="downloadHistory"
class="btn btn-xs btn-secondary w-12"
on:click|stopPropagation={() => downloadHistory()}
title="Download history"><i class="fa fa-download" /></button>
{/if}
|
<button
id="saveHistory"
class="btn btn-xs btn-success w-12"
on:click|stopPropagation={() => saveHistory()}
title="Save current state"><i class="far fa-save" /></button>
{#if $historyModeStore !== 'loader'}
<button
id="clearHistory"
class="btn btn-xs btn-error w-12"
on:click|stopPropagation={() => clearHistory()}
title="Delete all saved states"><i class="fas fa-trash-alt" /></button>
{/if}
</div>
<ul class="p-2 space-y-2 overflow-auto h-56" id="historyList">
{#if $historyStore.length > 0}
{#each $historyStore as { id, state, time, name, url, type }}
<li class="rounded p-2 shadow flex-col">
<div class="flex">
<div class="flex-1">
<div class="flex flex-col text-base-content">
{#if url}
<a
href={url}
target="_blank"
rel="noreferrer"
title="Open revision in new tab"
class="hover:underline text-blue-500">{name}</a>
{:else}
<span>{name}</span>
{/if}
<span class="text-gray-400 text-sm">{relativeTime(time)}</span>
</div>
</div>
<div class="flex gap-2 content-center">
<button class="btn btn-success" on:click={() => restoreHistoryItem(state)}
><i class="fas fa-undo mr-1" />Restore</button>
{#if type !== 'loader'}
<button class="btn btn-error" on:click={() => clearHistory(id)}
><i class="fas fa-trash-alt mr-1" />Delete</button>
{/if}
</div>
</div>
</li>
{/each}
{:else}
<div class="m-2">
No items in History<br />
Click the Save button to save current state and restore it later.<br />
Timeline will automatically be saved every minute.
</div>
{/if}
</ul>
</Card>
+120
View File
@@ -0,0 +1,120 @@
import type { HistoryEntry } from '$lib/types';
import { describe, it, expect } from 'vitest';
import {
addHistoryEntry,
injectHistoryIDs,
clearHistoryData,
historyModeStore,
historyStore
} from './history';
import { defaultState } from '$lib/util/state';
import { get } from 'svelte/store';
describe('history', () => {
it('should handle saving individual history entry', () => {
expect(window.localStorage.getItem('manualHistoryStore')).toBe('[]');
expect(window.localStorage.getItem('autoHistoryStore')).toBe('[]');
addHistoryEntry({
state: defaultState,
time: 12345,
type: 'manual'
});
const [manualEntry]: HistoryEntry[] = JSON.parse(
window.localStorage.getItem('manualHistoryStore')
);
expect(manualEntry.time).toBe(12345);
expect(manualEntry.type).toBe('manual');
expect(manualEntry.name).not.toBeNull();
expect(manualEntry.state).not.toBeNull();
addHistoryEntry({
state: defaultState,
time: 54321,
type: 'auto'
});
const [autoEntry]: HistoryEntry[] = JSON.parse(window.localStorage.getItem('autoHistoryStore'));
expect(autoEntry.time).toBe(54321);
expect(autoEntry.type).toBe('auto');
expect(autoEntry.name).not.toBeNull();
expect(autoEntry.state).not.toBeNull();
historyModeStore.set('manual');
clearHistoryData();
historyModeStore.set('auto');
clearHistoryData();
expect(window.localStorage.getItem('manualHistoryStore')).toBe('[]');
expect(window.localStorage.getItem('autoHistoryStore')).toBe('[]');
});
it('should clear history entries', () => {
addHistoryEntry({
state: defaultState,
time: 12345,
type: 'manual'
});
addHistoryEntry({
state: { ...defaultState, code: 'graph TD\\n A[Christmas] -->|Get money| B(Go shopping)' },
time: 123456,
type: 'manual'
});
historyModeStore.set('manual');
const store: HistoryEntry[] = get(historyStore);
expect(store.length).toBe(2);
clearHistoryData(store[1].id);
expect(get(historyStore).length).toBe(1);
clearHistoryData();
expect(get(historyStore).length).toBe(0);
historyModeStore.set('auto');
addHistoryEntry({
state: defaultState,
time: 54321,
type: 'auto'
});
addHistoryEntry({
state: { ...defaultState, code: 'graph TD\\n A[Christmas] -->|Get money| B(Go shopping)' },
time: 654321,
type: 'auto'
});
expect(get(historyStore).length).toBe(2);
clearHistoryData();
expect(get(historyStore).length).toBe(0);
// Test calling when history is empty
clearHistoryData();
expect(get(historyStore).length).toBe(0);
});
});
describe('history migration', () => {
it('should inject history IDs as migration', () => {
window.localStorage.setItem(
'manualHistoryStore',
'[{"state":{"code":"graph TD\\n A[Halloween] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":false},"time":0,"type":"manual","name":"hollow-art"},{"state":{"code":"graph TD\\n A[Christmas] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":true},"time":0,"type":"manual","name":"helpful-ocean"}]'
);
window.localStorage.setItem(
'autoHistoryStore',
'[{"state":{"code":"graph TD\\n A[New Year] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":false},"time":0,"type":"auto","name":"barking-dog"},{"state":{"code":"graph TD\\n A[Christmas] -->|Get money| B(Go shopping)","mermaid":"{\\n \\"theme\\": \\"dark\\"\\n}","autoSync":true,"updateDiagram":true},"time":0,"type":"manual","name":"needy-mosquito"}]'
);
let manualHistoryStore: HistoryEntry[] = JSON.parse(
window.localStorage.getItem('manualHistoryStore')
);
let autoHistoryStore: HistoryEntry[] = JSON.parse(
window.localStorage.getItem('autoHistoryStore')
);
expect(manualHistoryStore.every(({ id }) => id !== undefined)).toBe(false);
expect(autoHistoryStore.every(({ id }) => id !== undefined)).toBe(false);
injectHistoryIDs();
manualHistoryStore = JSON.parse(window.localStorage.getItem('manualHistoryStore'));
autoHistoryStore = JSON.parse(window.localStorage.getItem('autoHistoryStore'));
expect(manualHistoryStore.every(({ id }) => id !== undefined)).toBe(true);
expect(autoHistoryStore.every(({ id }) => id !== undefined)).toBe(true);
});
});
+141
View File
@@ -0,0 +1,141 @@
import { derived, writable, get } from 'svelte/store';
import type { Readable, Writable } from 'svelte/store';
import { persist, localStorage } from '$lib/util/persist';
import { generateSlug } from 'random-word-slugs';
import type { HistoryEntry, HistoryType, Optional } from '$lib/types';
import { v4 as uuidV4 } from 'uuid';
import { logEvent } from '$lib/util/stats';
const MAX_AUTO_HISTORY_LENGTH = 30;
export const historyModeStore: Writable<HistoryType> = persist(
writable('manual'),
localStorage(),
'autoHistoryMode'
);
const autoHistoryStore: Writable<HistoryEntry[]> = persist(
writable([]),
localStorage(),
'autoHistoryStore'
);
const manualHistoryStore: Writable<HistoryEntry[]> = persist(
writable([]),
localStorage(),
'manualHistoryStore'
);
export const loaderHistoryStore: Writable<HistoryEntry[]> = writable([] as HistoryEntry[]);
export const historyStore: Readable<HistoryEntry[]> = derived(
[historyModeStore, autoHistoryStore, manualHistoryStore, loaderHistoryStore],
([historyMode, autoHistories, manualHistories, loadedHistories], set) => {
if (historyMode === 'auto') {
set(autoHistories);
} else if (historyMode === 'manual') {
set(manualHistories);
} else if (historyMode === 'loader') {
set(loadedHistories);
} else {
set(autoHistories);
}
}
);
export const addHistoryEntry = (entryToAdd: Optional<HistoryEntry, 'id'>): void => {
const entry: HistoryEntry = {
...entryToAdd,
id: uuidV4()
};
if (entry.type === 'loader') {
loaderHistoryStore.update((entries) => [entry, ...entries]);
return;
}
if (!entry.name) {
entry.name = generateSlug(2);
}
if (entry.type === 'auto') {
autoHistoryStore.update((entries) => {
if (entries.length >= MAX_AUTO_HISTORY_LENGTH) {
entries = entries.slice(0, MAX_AUTO_HISTORY_LENGTH - 1);
}
return [entry, ...entries];
});
} else if (entry.type === 'manual') {
manualHistoryStore.update((entries) => [entry, ...entries]);
logEvent('history', { action: 'save' });
}
};
export const clearHistoryData = (idToClear?: string): void => {
(get(historyModeStore) === 'auto' ? autoHistoryStore : manualHistoryStore).update((entries) => {
if (get(historyModeStore) !== 'loader') {
entries = entries.filter(({ id }) => idToClear && id != idToClear);
logEvent('history', { action: 'clear', type: idToClear ? 'single' : 'all' });
}
return entries;
});
};
export const getPreviousState = (auto: boolean): string => {
const entries = get(auto ? autoHistoryStore : manualHistoryStore);
if (entries.length > 0) {
return JSON.stringify(entries[0].state);
}
return '';
};
export const restoreHistory = (data: HistoryEntry[]) => {
const entries = data.filter(validateEntry);
const invalidEntryCount = data.length - entries.length;
if (invalidEntryCount > 0) {
console.error(`${invalidEntryCount} invalid history entries were removed.`);
console.error(data);
}
if (entries.length > 0) {
let entryCount = 0;
(entries[0].type === 'auto' ? autoHistoryStore : manualHistoryStore).update((existing) => {
const existingIDs = new Set(existing.map(({ id }) => id));
const newEntries = entries.filter(({ id }) => !existingIDs.has(id));
entryCount = newEntries.length;
const combined = [...existing, ...newEntries];
combined.sort((a, b) => b.time - a.time);
return combined;
});
alert(
`${entryCount} entries restored. ${invalidEntryCount} invalid, ${
entries.length - entryCount
} duplicates.`
);
logEvent('history', {
action: 'restore',
success: entryCount,
invalid: invalidEntryCount,
duplicates: entries.length - entryCount
});
} else {
alert('No valid entries found.');
}
};
export const injectHistoryIDs = (): void => {
const setIDs = (entries: HistoryEntry[]) => {
for (const entry of entries) {
if (!entry.id) {
entry.id = uuidV4();
}
}
return entries;
};
autoHistoryStore.update(setIDs);
manualHistoryStore.update(setIDs);
};
const validateEntry = (entry: HistoryEntry): boolean => {
return entry.type && entry.state && entry.time && true;
};
@@ -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>

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