Compare commits

..
Author SHA1 Message Date
Steph ff88a247f2 update tests 2023-11-07 06:58:57 -08:00
Steph 269c4e4deb fix docs paths 2023-10-29 21:35:29 -07:00
150 changed files with 8979 additions and 12624 deletions
+1 -10
View File
@@ -2,13 +2,4 @@
**/.git **/.git
**/.svelte-kit **/.svelte-kit
**/dist **/dist
**/docs **/docs
**/.github
**/.husky
**/.vscode
Dockerfile
.dockerignore
docker-compose.yml
README.md
-7
View File
@@ -1,7 +0,0 @@
MERMAID_DOMAIN=''
MERMAID_ANALYTICS_URL=''
MERMAID_RENDERER_URL='https://mermaid.ink'
MERMAID_KROKI_RENDERER_URL='https://kroki.io'
MERMAID_IS_ENABLED_MERMAID_CHART_LINKS=''
# cp .env .env.local to make local changes
+9 -30
View File
@@ -7,17 +7,15 @@ module.exports = {
// 'plugin:@typescript-eslint/recommended-requiring-type-checking', // 'plugin:@typescript-eslint/recommended-requiring-type-checking',
'plugin:@typescript-eslint/strict', 'plugin:@typescript-eslint/strict',
'plugin:unicorn/recommended', 'plugin:unicorn/recommended',
'plugin:svelte/recommended',
'plugin:svelte/prettier',
'prettier' 'prettier'
], ],
plugins: [ plugins: [
'svelte3',
'tailwindcss', 'tailwindcss',
'@typescript-eslint', '@typescript-eslint',
'es', 'es',
'vitest', 'vitest',
'no-only-tests', 'no-only-tests',
'sort-keys',
'unicorn' 'unicorn'
], ],
ignorePatterns: [ ignorePatterns: [
@@ -32,13 +30,7 @@ module.exports = {
'tsconfig.json' 'tsconfig.json'
], ],
overrides: [ overrides: [
{ { files: ['*.svelte'], processor: 'svelte3/svelte3' },
files: ['*.svelte'],
parser: 'svelte-eslint-parser',
parserOptions: {
parser: '@typescript-eslint/parser'
}
},
{ {
files: ['*.ts'], files: ['*.ts'],
extends: [ extends: [
@@ -48,30 +40,24 @@ module.exports = {
'plugin:@typescript-eslint/strict', 'plugin:@typescript-eslint/strict',
'prettier' 'prettier'
] ]
},
{
files: ['**/components/ui/**'],
rules: {
'unicorn/prefer-export-from': 'off',
'unicorn/prevent-abbreviations': 'off',
'unicorn/explicit-length-check': 'off',
'sort-keys/sort-keys-fix': 'off'
}
} }
], ],
settings: {
'svelte3/typescript': () => require('typescript')
},
parserOptions: { parserOptions: {
sourceType: 'module', sourceType: 'module',
ecmaVersion: 2020, ecmaVersion: 2020,
tsconfigRootDir: __dirname, tsconfigRootDir: __dirname,
project: './tsconfig.json', project: ['./tsconfig.json'],
extraFileExtensions: ['.svelte'] extraFileExtensions: ['.svelte'],
allowAutomaticSingleRunInference: true
}, },
env: { env: {
browser: true, browser: true,
es2020: true es2020: true
}, },
rules: { rules: {
'sort-keys/sort-keys-fix': ['error', 'asc', { minKeys: 5 }],
'@typescript-eslint/ban-ts-comment': [ '@typescript-eslint/ban-ts-comment': [
'error', 'error',
{ {
@@ -90,28 +76,21 @@ module.exports = {
case: 'camelCase' case: 'camelCase'
} }
], ],
'unicorn/filename-case': 'off',
'unicorn/prevent-abbreviations': [ 'unicorn/prevent-abbreviations': [
'error', 'error',
{ {
allowList: { allowList: {
args: true,
ctx: true, ctx: true,
db: true, db: true,
doc: true, doc: true,
env: true, env: true,
fn: true, fn: true,
i: true, i: true,
j: true,
k: true,
param: true, param: true,
Params: true,
params: true,
Props: true,
props: true,
req: true, req: true,
res: true, res: true,
str: true, str: true,
searchParams: true,
temp: true, temp: true,
ImportMetaEnv: true ImportMetaEnv: true
} }
+1 -1
View File
@@ -12,6 +12,6 @@ Describe the way your implementation works or what design decisions you made if
Make sure you 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) - [ ] :computer: have added unit/e2e tests (if appropriate)
- [ ] :bookmark: targeted `develop` branch - [ ] :bookmark: targeted `develop` branch
+14 -38
View File
@@ -14,49 +14,25 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Install pnpm - uses: actions/setup-node@v3
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: with:
node-version-file: '.node-version' node-version: 18
cache: pnpm cache: yarn
- name: Setup Pages
uses: actions/configure-pages@v5
with:
static_site_generator: sveltekit
- name: Build & Deploy - 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: | run: |
export DEPLOY=true export DEPLOY=true
[ "$GITHUB_EVENT_NAME" != "pull_request" ] && rm -rf docs/_app/ [ "$GITHUB_EVENT_NAME" != "pull_request" ] && rm -rf docs/_app/
pnpm install yarn install
pnpm build version=$(yarn version --patch --no-git-tag-version | grep "New version" | cut -d':' -f 2)
yarn build
yarn run lint
cd ..
- name: Upload artifact - name: Deploy
uses: actions/upload-pages-artifact@v3 uses: peaceiris/actions-gh-pages@v3
if: ${{ github.ref == 'refs/heads/master' }}
with: with:
path: ./docs github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs
deploy: keep_files: true
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
+52 -31
View File
@@ -2,11 +2,13 @@ name: Docker
on: on:
push: push:
# Publish `master` as Docker `latest` image.
branches: branches:
# Publish `master` as Docker `latest` image.
- master - 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. # Run tests for all PRs to master and develop.
pull_request: pull_request:
@@ -14,35 +16,54 @@ on:
- master - master
- develop - develop
env:
IMAGE_NAME: mermaid-live-editor
jobs: jobs:
docker: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3 - name: Run tests
- name: Login to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Get release version
run: | run: |
RELEASE_VERSION=$([ "${{ github.ref_name }}" = "master" ] && echo "latest" || echo "nightly") docker build . --file Dockerfile
echo "RELEASE_VERSION=${RELEASE_VERSION}" >> "${GITHUB_ENV}"
- uses: docker/metadata-action@v5 push:
id: meta # Ensure test job passes before pushing image.
with: needs: test
images: ghcr.io/${{ github.repository }}
tags: | runs-on: ubuntu-latest
type=raw,value=${{ env.RELEASE_VERSION }} if: github.event_name == 'push'
- uses: docker/build-push-action@v5
with: steps:
context: . - uses: actions/checkout@v3
target: mermaid
push: ${{ github.event_name == 'push' }} - name: Build image
pull: true run: docker build . --file Dockerfile --tag $IMAGE_NAME
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} - 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
+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@v8
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 -30
View File
@@ -7,48 +7,45 @@ on:
- develop - develop
jobs: jobs:
playwright: cypress-run:
name: 'Playwright Tests'
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: strategy:
image: mcr.microsoft.com/playwright:v1.52.0-jammy fail-fast: false
matrix:
# run 3 copies of the current job in parallel
containers: [1, 2, 3]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v3
- uses: actions/cache@v4 - uses: actions/cache@v3
id: pnpm-and-build-cache id: yarn-and-build-cache
with: with:
path: | path: |
~/.cache/Cypress
build build
node_modules node_modules
key: ${{ runner.os }}-node_modules-build-${{ hashFiles('**/pnpm-lock.yaml') }} key: ${{ runner.os }}-node_modules-build-${{ hashFiles('**/yarn.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-node_modules-build- ${{ runner.os }}-node_modules-build-
- name: Install pnpm - uses: actions/setup-node@v3
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: with:
node-version-file: '.node-version' node-version: 18
cache: 'pnpm' cache: 'yarn'
- name: Install dependencies # Install NPM dependencies, cache them correctly
run: pnpm install # and run all Cypress tests
- name: Cypress run
- name: Build uses: cypress-io/github-action@v3
run: pnpm build with:
build: yarn build
- name: Run Playwright tests start: yarn preview
run: pnpm test:e2e wait-on: 'http://localhost:3000'
record: true
headless: true
parallel: true
env: env:
CI: true CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
CYPRESS_CI: true
- uses: actions/upload-artifact@v4
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30
+10 -16
View File
@@ -15,28 +15,22 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
- uses: actions/cache@v3 - uses: actions/cache@v3
id: pnpm-and-build-cache id: yarn-and-build-cache
with: with:
path: | path: |
build build
node_modules node_modules
key: ${{ runner.os }}-node_modules-build-${{ hashFiles('**/pnpm-lock.yaml') }} key: ${{ runner.os }}-node_modules-build-${{ hashFiles('**/yarn.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-node_modules-build- ${{ runner.os }}-node_modules-build-
- name: Install pnpm - uses: actions/setup-node@v3
uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with: with:
node-version-file: '.node-version' node-version: 18
cache: 'pnpm' cache: 'yarn'
- name: Install dependencies - name: Lint & Test
run: pnpm install run: |
yarn install
- name: Lint yarn lint
run: pnpm lint yarn test:unit
- name: Run unit tests
run: pnpm test:unit
+8 -11
View File
@@ -1,17 +1,14 @@
.DS_Store node_modules/
coverage/
.cache/ .cache/
.env.local build/
yarn-error.log
.npmrc .npmrc
.DS_Store
/.svelte-kit /.svelte-kit
/build /build
/coverage
/docs
/functions /functions
/node_modules
/snapshots.js /snapshots.js
/cypress/downloads
# Playwright /cypress/videos
/test-results/ /cypress/screenshots
/playwright-report/
/blob-report/
/playwright/.cache/
+1 -1
View File
@@ -1,4 +1,4 @@
#!/bin/sh #!/bin/sh
. "$(dirname "$0")/_/husky.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 engine-strict=true
auto-install-peers=true
+1 -2
View File
@@ -5,5 +5,4 @@ build/**
node_modules/** node_modules/**
coverage/** coverage/**
__snapshots__/** __snapshots__/**
snapshots.js snapshots.js
pnpm-lock.yaml
+1 -3
View File
@@ -3,7 +3,5 @@
"svelteSortOrder": "options-scripts-markup-styles", "svelteSortOrder": "options-scripts-markup-styles",
"bracketSameLine": true, "bracketSameLine": true,
"trailingComma": "none", "trailingComma": "none",
"printWidth": 100, "printWidth": 100
"tailwindConfig": "./tailwind.config.js",
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"]
} }
+4 -27
View File
@@ -2,49 +2,26 @@
"editor.formatOnSave": true, "editor.formatOnSave": true,
"cSpell.blockCheckingWhenLineLengthGreaterThan": 150, "cSpell.blockCheckingWhenLineLengthGreaterThan": 150,
"cSpell.words": [ "cSpell.words": [
"appinstalled",
"asyncable", "asyncable",
"Browserslist", "Browserslist",
"ckppp", "ckppp",
"corg", "corg",
"cssnano", "cssnano",
"daisyui",
"esserializer", "esserializer",
"fontawesome",
"fsegurai",
"gantt",
"gitgraph",
"KROKI", "KROKI",
"localstorage", "localstorage",
"mermaidchart", "mermaidchart",
"mindmap", "mindmap",
"Pageview",
"pako", "pako",
"panmove",
"panstart",
"panzoom",
"pinchmove",
"pinchstart",
"pzoom",
"roughjs",
"Serde", "Serde",
"serdes", "serdes",
"Stackable",
"tailwindcss", "tailwindcss",
"uparrow", "uparrow"
"webfonts",
"zenuml"
], ],
"vitest.commandLine": "pnpm test:unit", "vitest.commandLine": "yarn test:unit",
"vitest.enable": true, "vitest.enable": true,
"testing.autoRun.mode": "rerun", "testing.autoRun.mode": "rerun",
"svelte.enable-ts-plugin": true, "svelte.enable-ts-plugin": true,
"githubPullRequests.ignoredPullRequestBranches": ["develop"], "githubPullRequests.ignoredPullRequestBranches": ["develop"]
"[svelte]": {
"editor.defaultFormatter": "svelte.svelte-vscode"
},
"tailwindCSS.classAttributes": ["class", "className", ".*Classes"],
"tailwindCSS.experimental.classRegex": [
["([\"'`][^\"'`]*.*?[\"'`])", "[\"'`]([^\"'`]*).*?[\"'`]"]
],
"tailwindCSS.emmetCompletions": true
} }
-1
View File
@@ -1 +0,0 @@
mermaid.live
+15 -32
View File
@@ -1,34 +1,17 @@
FROM docker.io/library/node:22.15.0-alpine3.21 AS mermaid-live-editor-dependencies # Two-stage docker container for mermaid-js/mermaid-live-editor
# Build : docker build -t mermaid-js/mermaid-live-editor .
RUN apk --no-cache add build-base git python3 && \ # Run : docker run --name mermaid-live-editor --publish 8080:8080 mermaid-js/mermaid-live-editor
rm -rf /var/cache/apk/* # Start : docker start mermaid-live-editor
# Use webbrowser : http://localhost:8080
RUN corepack enable pnpm # Stop : press ctrl + c
# or
WORKDIR /app # docker stop mermaid-live-editor
FROM node:18.17.1 as mermaid-live-editor-builder
COPY ./package.json . COPY --chown=node:node . /home
COPY ./pnpm-lock.yaml . WORKDIR /home
RUN yarn install
RUN pnpm install RUN yarn build
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
COPY . ./
RUN pnpm build
FROM mermaid-live-editor-builder AS mermaid-dev
ENTRYPOINT ["pnpm", "dev"]
FROM nginx:1.28-alpine3.21 AS mermaid
FROM nginxinc/nginx-unprivileged:alpine as mermaid-live-editor-runner
COPY ./nginx.conf /etc/nginx/conf.d/default.conf 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.17.1
WORKDIR /app
COPY package.json .
COPY yarn.lock .
RUN npm install
COPY . .
RUN ls
CMD ["yarn", "dev"]
+15 -60
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) [![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)
[![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. Edit, preview and share mermaid charts/diagrams.
@@ -14,11 +17,7 @@ Edit, preview and share mermaid charts/diagrams.
## Live demo ## Live demo
You can try out a [live version](https://mermaid.live/). You can try out a live version [here](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.
## Docker ## Docker
@@ -30,36 +29,13 @@ docker run --platform linux/amd64 --publish 8000:8080 ghcr.io/mermaid-js/mermaid
### To configure renderer URL ### To configure renderer URL
When building set the MERMAID_RENDERER_URL build argument to the rendering When building, Set the Environment variable MERMAID_RENDERER_URL to the rendering service.
service. Default is `https://mermaid.ink`
Example:
Default is`https://mermaid.ink`.
Set to empty string to disable PNG and SVG links under Actions
### To configure Kroki Instance URL ### To configure Kroki Instance URL
When building set the MERMAID_KROKI_RENDERER_URL build argument to your Kroki When building, Set the Environment variable MERMAID_KROKI_RENDERER_URL to your Kroki instance.
instance.
Default is `https://kroki.io` 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 ### Development
@@ -69,28 +45,6 @@ docker compose up --build
Then open http://localhost:3000 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
```
## Setup ## Setup
Below link will help you making a copy of the repository in your local system. Below link will help you making a copy of the repository in your local system.
@@ -99,14 +53,15 @@ https://docs.github.com/en/get-started/quickstart/fork-a-repo
## Requirements ## Requirements
- [Node.js](https://nodejs.org/en/) current LTS version - [volta](https://volta.sh/) to manage node versions.
- [pnpm](https://pnpm.io/) package manager. Install with `corepack enable pnpm` - [Node.js](https://nodejs.org/en/). `volta install node`
- [yarn](https://yarnpkg.com/) package manager. `volta install yarn`
## Development ## Development
```sh ```sh
pnpm install yarn install
pnpm dev -- --open yarn dev -- --open
``` ```
This app is created with Svelte Kit. This app is created with Svelte Kit.
+1 -1
View File
@@ -6,7 +6,7 @@
# git clone https://github.com/mermaid-js/docs.git # git clone https://github.com/mermaid-js/docs.git
set -e set -e
rm -rf docs rm -rf docs
pnpm release yarn release
pushd . pushd .
if [ ! -d ../docs ]; then if [ ! -d ../docs ]; then
echo "Clone https://github.com/mermaid-js/docs to parent folder before continuing." echo "Clone https://github.com/mermaid-js/docs to parent folder before continuing."
-17
View File
@@ -1,17 +0,0 @@
{
"$schema": "https://next.shadcn-svelte.com/schema.json",
"style": "new-york",
"tailwind": {
"config": "tailwind.config.js",
"css": "src/app.postcss",
"baseColor": "slate"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "$lib/components/ui",
"hooks": "$lib/hooks"
},
"typescript": true,
"registry": "https://next.shadcn-svelte.com/registry"
}
+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: 5000,
requestTimeout: 5000,
retries: {
runMode: 2,
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
}
}
+49
View File
@@ -0,0 +1,49 @@
import { typeInEditor, verifyFileSizeGreaterThan } from './util';
describe('Check actions', () => {
beforeEach(() => {
cy.clearLocalStorage();
cy.visit('/edit');
cy.contains('Actions').click();
});
it('should update markdown code', () => {
cy.get('#markdown')
.invoke('val')
.then((oldText) => {
typeInEditor('C --> HistoryTest', { bottom: true, newline: true });
cy.get('#markdown')
.invoke('val')
.then((newText) => {
expect(oldText).to.not.eq(newText);
});
});
});
it.skip('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();
verifyFileSizeGreaterThan('diagram', 'png', 34_000);
cy.get(`#downloadSVG`).click();
verifyFileSizeGreaterThan('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();
verifyFileSizeGreaterThan('diagram', 'png', 40_000);
cy.get(`#downloadSVG`).click();
verifyFileSizeGreaterThan('diagram', 'svg', 11_000);
cy.clock().invoke('restore');
});
});
+97
View File
@@ -0,0 +1,97 @@
import { typeInEditor, cmd } from './util';
describe('Auto sync tests', () => {
beforeEach(() => {
cy.clearLocalStorage();
cy.visit('/');
cy.url().should('contain', '/edit#pako');
cy.window().should('have.property', 'editorLoaded', true);
});
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');
typeInEditor(' C --> Test', { bottom: true });
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');
typeInEditor(' C --> Test');
cy.get('#view').should('have.class', 'outOfSync');
cy.get('#errorContainer').should('contain.text', 'Diagram out of sync.');
typeInEditor(`${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');
typeInEditor(' 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();
typeInEditor('ing');
cy.get('#view').should('not.have.class', 'outOfSync');
cy.getLocalStorage('codeStore').snapshot();
});
it('supports commenting code out/in', () => {
cy.get('#editor').contains('Car').click();
cy.get('#editor').get('textarea').type(`${cmd}/`, { force: true });
cy.get('#view').contains('Car').should('not.exist');
typeInEditor(`{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');
typeInEditor(`branch test`, { bottom: true, newline: true });
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');
});
it('should update diagram after entire text is removed', () => {
// https://github.com/mermaid-js/mermaid-live-editor/issues/1102
typeInEditor(`${cmd} a {backspace}`);
typeInEditor('graph LR');
typeInEditor(' {enter} A-->Car');
cy.get('#view').contains('Car').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$="/syntax/flowchart"]`).should('exist');
cy.contains('Config').click();
cy.get(`[data-cy=docs][href$="/syntax/flowchart?id=configuration"]`).should('exist');
cy.contains('Sequence').click();
cy.get(`[data-cy=docs][href$="/syntax/sequenceDiagram?id=configuration"]`).should('exist');
cy.contains('Code').click();
cy.get(`[data-cy=docs][href$="/syntax/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$="/syntax/stateDiagram"]`).should('exist');
cy.contains('Config').click();
cy.get(`[data-cy=docs][href$="/syntax/stateDiagram"]`).should('exist');
});
});
+103
View File
@@ -0,0 +1,103 @@
import { typeInEditor, verifyFileSnapshot } from './util';
describe('Save History', () => {
beforeEach(() => {
cy.clock(new Date(2022, 0, 1).getTime());
cy.clearLocalStorage();
cy.visit('/edit');
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('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);
typeInEditor(' 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();
typeInEditor(' 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();
typeInEditor(' 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', () => {
typeInEditor(' C --> HistoryTest');
cy.tick(70_000);
cy.contains('Timeline').click();
cy.get('#historyList').find('li').should('have.length', 1);
cy.get('#editor').type('ing');
cy.tick(70_000);
cy.get('#historyList').find('li').should('have.length', 2);
for (let i = 0; i < 31; i++) {
cy.get('#editor').type('.');
cy.tick(70_000);
}
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)');
});
});
+136
View File
@@ -0,0 +1,136 @@
import { toBase64 } from 'js-base64';
describe('Site Loads', () => {
beforeEach(() => {
cy.clearLocalStorage();
cy.visit('/');
cy.url().should('include', '/edit#pako');
});
it('Check Home page load', () => {
cy.url().should('include', '/edit');
cy.contains('History').click();
cy.getLocalStorage('codeStore').snapshot();
});
it('should keep code after reload', () => {
cy.get('#editor').contains('Car');
cy.reload();
cy.get('#editor').contains('Car');
});
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.');
});
});
describe('Verify types of URLs', () => {
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');
});
describe('Uncompressed URLs', () => {
it('should load URL without specifier', () => {
cy.visit(
'/edit/#eyJjb2RlIjoiZ3JhcGhcbiAgICBUZXN0TGFiZWwiLCJtZXJtYWlkIjoie1xuICBcInRoZW1lXCI6IFwiZGVmYXVsdFwiXG59IiwidXBkYXRlRWRpdG9yIjpmYWxzZSwiYXV0b1N5bmMiOnRydWUsInVwZGF0ZURpYWdyYW0iOmZhbHNlfQ'
);
cy.contains('TestLabel');
cy.visit(
'/edit#eyJjb2RlIjoiY2xhc3NEaWFncmFtXG4gICAgQW5pbWFsIDx8LS0gRHVja1xuICAgIEFuaW1hbCA8fC0tIEZpc2hcbiAgICBBbmltYWwgPHwtLSBaZWJyYVxuICAgIEFuaW1hbCA6ICtpbnQgYWdlXG4gICAgQW5pbWFsIDogK1N0cmluZyBnZW5kZXJcbiAgICBBbmltYWw6ICtpc01hbW1hbCgpXG4gICAgQW5pbWFsOiArbWF0ZSgpXG4gICAgY2xhc3MgRHVja3tcbiAgICAgICtTdHJpbmcgYmVha0NvbG9yXG4gICAgICArc3dpbSgpXG4gICAgICArcXVhY2soKVxuICAgIH1cbiAgICBjbGFzcyBGaXNoe1xuICAgICAgLWludCBzaXplSW5GZWV0XG4gICAgICAtY2FuRWF0KClcbiAgICB9XG4gICAgY2xhc3MgWmVicmF7XG4gICAgICArYm9vbCBpc193aWxkXG4gICAgICArcnVuKClcbiAgICB9XG4gICAgICAgICAgICAiLCJtZXJtYWlkIjoie1xuICBcInRoZW1lXCI6IFwiZGFya1wiXG59IiwidXBkYXRlRWRpdG9yIjpmYWxzZSwiYXV0b1N5bmMiOnRydWUsInVwZGF0ZURpYWdyYW0iOmZhbHNlfQ'
);
cy.contains('Animal');
});
it('should load URL with "base64" specifier', () => {
cy.visit(
'/edit/#base64:eyJjb2RlIjoiZ3JhcGhcbiAgICBUZXN0TGFiZWwiLCJtZXJtYWlkIjoie1xuICBcInRoZW1lXCI6IFwiZGVmYXVsdFwiXG59IiwidXBkYXRlRWRpdG9yIjpmYWxzZSwiYXV0b1N5bmMiOnRydWUsInVwZGF0ZURpYWdyYW0iOmZhbHNlfQ'
);
cy.contains('TestLabel');
});
});
});
+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('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('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();
});
});
});
+60
View File
@@ -0,0 +1,60 @@
export const cmd = `{${Cypress.platform === 'darwin' ? 'meta' : 'ctrl'}}`;
interface EditorOptions {
bottom?: boolean;
newline?: boolean;
}
export const typeInEditor = (
text: string,
{ bottom = true, newline = false }: EditorOptions = {}
) => {
cy.window().should('have.property', 'editorLoaded', true);
cy.get('#editor').click();
cy.get('#editor').within(($editor) => {
if (bottom) {
cy.get('textarea').type('{pageDown}', { force: true });
}
if (newline) {
cy.get('textarea').type('{enter}', { force: true });
}
cy.get('textarea').type(text, { force: true });
});
};
const downloadsFolder = Cypress.config('downloadsFolder');
export const verifyFileSizeGreaterThan = (
fileType: 'history' | 'diagram',
extension: string,
size: number
) => {
const fileName = `mermaid-${fileType}-2022-01-01-000000.${extension}`;
const filePath = `${downloadsFolder}/${fileName}`;
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
cy.verifyDownload(fileName);
cy.readFile(filePath, null, {
log: false
}).then((buffer: ArrayBuffer) => {
expect(buffer.byteLength).to.be.gt(size);
expect(buffer.byteLength).to.be.lt(size * 1.3);
});
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}`;
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
cy.verifyDownload(fileName);
cy.readFile(filePath, null, {
log: false
}).then((buffer: ArrayBuffer) =>
expect(new TextDecoder('utf8').decode(buffer)).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\":\"flowchart TD\\n A[Christmas] -->|Get money| B(Go shopping)\\n B --> C{Let me think}\\n C -->|One| D[Laptop]\\n C -->|Two| E[iPhone]\\n C -->|Three| F[fa:fa-car Car]\\n \",\"mermaid\":\"{\\n \\\"theme\\\": \\\"default\\\"\\n}\",\"autoSync\":true,\"updateDiagram\":true}"
},
"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": "12.17.4",
"Auto sync tests": {
"should dim diagram when code is edited": {
"1": "{\"code\":\"flowchart TD\\n A[Christmas] -->|Get money| B(Go shopping)\\n B --> C{Let me think}\\n C -->|One| D[Laptop]\\n C -->|Two| E[iPhone]\\n C -->|Three| F[fa:fa-car Car]\\n C --> Test\",\"mermaid\":\"{\\n \\\"theme\\\": \\\"default\\\"\\n}\",\"autoSync\":false,\"updateDiagram\":false}"
},
"should not dim diagram when code is in sync": {
"1": "{\"code\":\"flowchart TD\\n A[Christmas] -->|Get money| B(Go shopping)\\n B --> C{Let me think}\\n C -->|One| D[Laptop]\\n C -->|Two| E[iPhone]\\n C -->|Three| F[fa:fa-car Car]\\n C --> Testing\",\"mermaid\":\"{\\n \\\"theme\\\": \\\"default\\\"\\n}\",\"autoSync\":true,\"updateDiagram\":false}"
}
},
"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", "node"]
},
"include": ["**/*.ts"]
}
+1 -1
View File
@@ -3,7 +3,7 @@ services:
mermaid: mermaid:
build: build:
context: . context: .
target: mermaid-dev dockerfile: Dockerfile.dev
volumes: volumes:
- ./src:/app/src - ./src:/app/src
ports: ports:
-12
View File
@@ -1,12 +0,0 @@
[build.environment]
MERMAID_ANALYTICS_URL = 'https://p.mermaid.live'
MERMAID_DOMAIN = 'mermaid.live'
MERMAID_RENDERER_URL = 'https://mermaid.ink'
MERMAID_KROKI_RENDERER_URL = 'https://kroki.io'
MERMAID_IS_ENABLED_MERMAID_CHART_LINKS ='true'
[[redirects]]
from = "/index.html"
to = "/edit"
status = 301
force = true
+65 -100
View File
@@ -5,107 +5,79 @@
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"dev:force": "MERMAID_LOCAL=true pnpm dev --force", "dev:force": "MERMAID_LOCAL=true yarn dev --force",
"dev:test": "pnpm dev", "dev:test": "yarn dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "prettier --check --cache . && eslint --ignore-path .gitignore .", "lint": "prettier --check --cache --plugin-search-dir=. .;eslint --ignore-path .gitignore .",
"lint:fix": "prettier --write --cache . && eslint --fix --ignore-path .gitignore .", "lint:fix": "prettier --write --cache --plugin-search-dir=. .;eslint --fix --ignore-path .gitignore .",
"format": "prettier --write --cache .", "format": "prettier --write --cache --plugin-search-dir=. .",
"pre-commit": "lint-staged", "pre-commit": "lint-staged",
"postinstall": "husky install && svelte-kit sync && (git config blame.ignoreRevsFile .git-blame-ignore-revs || true)", "postinstall": "husky install && svelte-kit sync && (git config blame.ignoreRevsFile .git-blame-ignore-revs || true)",
"test:unit": "vitest", "test:unit": "vitest",
"test:unit:ui": "vitest --ui", "test:ui": "vitest --ui",
"test:unit:coverage": "vitest run --coverage", "test:coverage": "vitest run --coverage",
"test": "pnpm test:unit && pnpm test:e2e", "test:browser": "cypress run",
"test:e2e": "playwright test", "test": "test:unit && test:browser",
"test:e2e:ui": "playwright test --ui", "cy": "cypress open"
"test:e2e:debug": "playwright test --debug"
}, },
"devDependencies": { "devDependencies": {
"@fortawesome/fontawesome-free": "^6.7.2", "@cypress/snapshot": "2.1.7",
"@iconify-json/material-symbols": "^1.2.20", "@sveltejs/adapter-static": "2.0.3",
"@iconify-json/mdi": "^1.2.3", "@sveltejs/kit": "1.25.0",
"@playwright/test": "^1.52.0", "@testing-library/jest-dom": "5.17.0",
"@sveltejs/adapter-static": "3.0.8", "@testing-library/svelte": "3.2.2",
"@sveltejs/kit": "2.20.8", "@types/pako": "2.0.0",
"@sveltejs/vite-plugin-svelte": "^4.0.4", "@types/uuid": "9.0.4",
"@types/hammerjs": "^2.0.46", "@typescript-eslint/eslint-plugin": "5.62.0",
"@types/lodash-es": "^4.17.12", "@typescript-eslint/parser": "5.62.0",
"@types/node": "^22.15.10", "@vitest/ui": "^0.34.0",
"@types/pako": "2.0.3", "autoprefixer": "^10.4.14",
"@types/uuid": "9.0.8",
"@typescript-eslint/eslint-plugin": "6.21.0",
"@typescript-eslint/parser": "6.21.0",
"@vitest/coverage-v8": "2.1.9",
"@vitest/ui": "^2.1.9",
"autoprefixer": "^10.4.21",
"bits-ui": "^1.4.6",
"c8": "7.14.0", "c8": "7.14.0",
"chai": "^4.5.0", "chai": "^4.3.7",
"clsx": "^2.1.1", "cssnano": "^6.0.0",
"cssnano": "^6.1.2", "cy-verify-downloads": "0.2.0",
"eslint": "8.57.1", "cypress": "12.17.4",
"eslint-config-prettier": "9.1.0", "cypress-localstorage-commands": "2.2.4",
"eslint-plugin-es": "^4.1.0", "eslint": "8.49.0",
"eslint-plugin-no-only-tests": "^3.3.0", "eslint-config-prettier": "8.10.0",
"eslint-plugin-postcss-modules": "^2.0.0", "eslint-plugin-cypress": "2.14.0",
"eslint-plugin-sort-keys": "^2.3.5", "eslint-plugin-es": "4.1.0",
"eslint-plugin-svelte": "^2.46.1", "eslint-plugin-no-only-tests": "^3.1.0",
"eslint-plugin-tailwindcss": "^3.18.0", "eslint-plugin-postcss-modules": "2.0.0",
"eslint-plugin-unicorn": "^50.0.1", "eslint-plugin-svelte3": "4.0.0",
"eslint-plugin-vitest": "^0.5.4", "eslint-plugin-tailwindcss": "3.13.0",
"esserializer": "^1.3.11", "eslint-plugin-unicorn": "^46.0.0",
"eslint-plugin-vitest": "^0.3.0",
"esserializer": "1.3.11",
"font-awesome": "^4.7.0",
"husky": "^8.0.3", "husky": "^8.0.3",
"jsdom": "^25.0.1", "jsdom": "21.1.2",
"lint-staged": "^15.5.1", "lint-staged": "13.3.0",
"lucide-svelte": "^0.507.0", "node-html-parser": "^6.1.5",
"node-html-parser": "^6.1.13", "postcss": "^8.4.21",
"paneforge": "^1.0.0-next.5", "postcss-load-config": "4.0.1",
"postcss": "^8.5.3", "prettier": "2.8.8",
"postcss-load-config": "5.1.0", "prettier-plugin-svelte": "^2.10.0",
"prettier": "^3.5.3", "svelte": "3.59.2",
"prettier-plugin-svelte": "^3.3.3", "svelte-preprocess": "5.0.4",
"prettier-plugin-tailwindcss": "^0.6.11", "tailwindcss": "^3.3.1",
"svelte": "^5.28.2", "tslib": "^2.5.0",
"svelte-preprocess": "^6.0.3", "typescript": "5.2.2",
"svelte-sonner": "^0.3.28", "vite": "^4.3.9",
"tailwind-merge": "^3.2.0", "vitest": "^0.34.0"
"tailwind-variants": "^0.3.1",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"tslib": "^2.8.1",
"typescript": "^5.8.3",
"unplugin-icons": "^22.1.0",
"vite": "^5.4.19",
"vitest": "^2.1.9",
"vitest-dom": "^0.1.1"
}, },
"dependencies": { "dependencies": {
"@codemirror/lang-json": "^6.0.1", "analytics": "0.8.9",
"@codemirror/lang-markdown": "^6.3.2", "analytics-plugin-plausible": "0.0.6",
"@codemirror/lang-yaml": "^6.1.2", "daisyui": "2.52.0",
"@codemirror/language": "^6.11.0", "dayjs": "^1.11.7",
"@codemirror/state": "^6.5.2", "js-base64": "3.7.5",
"@codemirror/view": "^6.36.7", "mermaid": "10.5.0",
"@fontsource-variable/recursive": "^5.2.5", "monaco-editor": "0.43.0",
"@fsegurai/codemirror-theme-vscode-dark": "^6.1.4",
"@fsegurai/codemirror-theme-vscode-light": "^6.1.4",
"@mermaid-js/layout-elk": "^0.1.7",
"@mermaid-js/mermaid-zenuml": "^0.2.0",
"codemirror": "^6.0.1",
"dayjs": "^1.11.13",
"hammerjs": "^2.0.8",
"js-base64": "3.7.7",
"lodash-es": "^4.17.21",
"mermaid": "^11.6.0",
"mode-watcher": "^0.5.1",
"monaco-editor": "0.52.2",
"pako": "2.1.0", "pako": "2.1.0",
"plausible-tracker": "^0.3.9",
"random-word-slugs": "0.1.7", "random-word-slugs": "0.1.7",
"svg-pan-zoom": "3.6.2", "svg-pan-zoom": "3.6.1",
"svg2roughjs": "^3.2.1",
"uuid": "9.0.1" "uuid": "9.0.1"
}, },
"lint-staged": { "lint-staged": {
@@ -114,18 +86,11 @@
"eslint --ignore-path .gitignore " "eslint --ignore-path .gitignore "
] ]
}, },
"engines": { "volta": {
"node": ">=20.19.0" "node": "18.17.1",
"yarn": "1.22.19"
}, },
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39", "engines": {
"pnpm": { "node": ">=16.7"
"onlyBuiltDependencies": [
"deasync",
"esbuild",
"svelte-preprocess"
],
"ignoredBuiltDependencies": [
"vue-demi"
]
} }
} }
-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
});
-7647
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -24,6 +24,5 @@
"major": { "major": {
"dependencyDashboardApproval": true "dependencyDashboardApproval": true
}, },
"dependencyDashboardAutoclose": true, "dependencyDashboardAutoclose": true
"rangeStrategy": "bump"
} }
+11 -3
View File
@@ -1,18 +1,26 @@
<!doctype html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Online FlowChart &amp; Diagrams Editor - Mermaid Live Editor</title> <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" /> <link rel="canonical" href="https://mermaid.live" />
<meta <meta
name="description" name="description"
content="Simplify documentation and avoid heavy tools. Open source Visio Alternative. Commonly used for explaining your code! Mermaid is a simple markdown-like script language for generating charts from text via javascript." /> content="Simplify documentation and avoid heavy tools. Open source Visio Alternative. Commonly used for explaining your code! Mermaid is a simple markdown-like script language for generating charts from text via javascript." />
<link rel="icon" type="image/png" href="%sveltekit.assets%/favicon.svg" /> <link rel="icon" type="image/png" href="%sveltekit.assets%/favicon.svg" />
<link rel="mask-icon" href="%sveltekit.assets%/favicon.svg" color="#000000" /> <link rel="mask-icon" href="%sveltekit.assets%/favicon.svg" color="#000000" />
<meta name="theme-color" content="#ff3670" /> <meta name="theme-color" content="#6366F1" />
<link rel="manifest" href="%sveltekit.assets%/manifest.json" /> <link rel="manifest" href="%sveltekit.assets%/manifest.json" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css"
integrity="sha512-z3gLpd7yknf1YoNbCzqRKc4qyor8gaKU1qmn+CShxbuBusANI9QpRohGBreCFkKxLhei6S9CQXFEbbKuqLg0DA=="
crossorigin="anonymous"
referrerpolicy="no-referrer" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body> <body>
+4 -62
View File
@@ -1,68 +1,10 @@
@import '@fontsource-variable/recursive/crsv.css';
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base { .input {
:root { @apply flex-1 border-primary border-solid border-2 rounded;
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 240 10% 91%;
--primary-foreground: 255 20% 15%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 228 24% 96%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 340 100% 44%;
--accent-foreground: 210 40% 98%;
--destructive: 0 72.22% 50.59%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.75rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 30%;
--primary-foreground: 222.2 47.4% 90%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 340 100% 44%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
} }
.action-btn {
@layer base { @apply btn btn-primary;
* {
@apply border-border;
}
body {
@apply h-screen w-screen overflow-hidden bg-background text-foreground;
}
}
body {
font-family: 'Recursive Variable', sans-serif;
}
.d {
@apply border border-red-500;
} }
+3 -3
View File
@@ -3,9 +3,9 @@
interface ImportMetaEnv { interface ImportMetaEnv {
readonly MERMAID_RENDERER_URL?: string; readonly MERMAID_RENDERER_URL?: string;
readonly MERMAID_KROKI_RENDERER_URL?: string; readonly MERMAID_KROKI_RENDERER_URL?: string;
readonly MERMAID_ANALYTICS_URL?: string; readonly MERMAID_CDN_URL?: string;
readonly MERMAID_DOMAIN?: string; readonly MERMAID_BASE_URL?: string;
readonly MERMAID_IS_ENABLED_MERMAID_CHART_LINKS?: string; readonly MERMAID_LOCAL?: boolean;
// more env variables... // more env variables...
} }
+15
View File
@@ -1 +1,16 @@
/* eslint-disable @typescript-eslint/no-empty-interface */
/* eslint-disable @typescript-eslint/ban-types */
/* eslint-disable @typescript-eslint/no-unused-vars */
/// <reference types="@sveltejs/kit" /> /// <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-expect-error
// eslint-disable-next-line no-undef
extends TestingLibraryMatchers<typeof expect.stringContaining, R> {}
}
}
+166 -242
View File
@@ -1,141 +1,80 @@
<script lang="ts"> <script lang="ts">
import Card from '$/components/Card/Card.svelte';
import CopyButton from '$/components/CopyButton.svelte';
import CopyInput from '$/components/CopyInput.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 { env } from '$/util/env';
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { waitForRender } from '$lib/util/autoSync'; import Card from '$lib/components/Card/Card.svelte';
import { inputStateStore, stateStore, urlsStore } from '$lib/util/state'; 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 { 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 { toBase64 } from 'js-base64';
import DownloadIcon from '~icons/material-symbols/download'; import dayjs from 'dayjs';
import ExternalLinkIcon from '~icons/material-symbols/open-in-new-rounded'; const { krokiRendererUrl, rendererUrl } = env;
import WidthIcon from '~icons/material-symbols/width-rounded';
const fontAwesomeURLs = (() => {
const baseUrl = `https://cdnjs.cloudflare.com/ajax/libs/font-awesome/${FAVersion}`;
return {
css: `${baseUrl}/css/all.min.css`,
woff2: `${baseUrl}/webfonts/fa-solid-900.woff2`
};
})();
type Exporter = (context: CanvasRenderingContext2D, image: HTMLImageElement) => () => void; type Exporter = (context: CanvasRenderingContext2D, image: HTMLImageElement) => () => void;
const getFileName = (extension: string) => const getFileName = (ext: string) =>
`mermaid-diagram-${dayjs().format('YYYY-MM-DD-HHmmss')}.${extension}`; `mermaid-diagram-${dayjs().format('YYYY-MM-DD-HHmmss')}.${ext}`;
const getSvgElement = async () => { const getBase64SVG = (svg?: HTMLElement, width?: number, height?: number): string => {
try { height && svg?.setAttribute('height', `${height}px`);
$inputStateStore.panZoom = false; width && svg?.setAttribute('width', `${width}px`); // Workaround https://stackoverflow.com/questions/28690643/firefox-error-rendering-an-svg-image-to-html5-canvas-with-drawimage
await new Promise((resolve) => setTimeout(resolve, 1000)); if (!svg) {
await waitForRender(); svg = getSvgEl();
const svgElement = document.querySelector('#container svg');
if (!svgElement) {
throw new Error('svg not found');
}
svgElement.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
return {
svg: svgElement.cloneNode(true) as HTMLElement,
box: svgElement.querySelector('g')!.getBoundingClientRect()
};
} finally {
setTimeout(() => {
$inputStateStore.panZoom = true;
}, 10000);
} }
};
const injectFontAwesome = async ({
svgString,
fontAwesomeEmbedMode
}: {
svgString: string;
fontAwesomeEmbedMode: 'raw' | 'url';
}) => {
if (fontAwesomeEmbedMode === 'url') {
return `<?xml version="1.0" encoding="UTF-8"?>
<?xml-stylesheet href="${fontAwesomeURLs.css}" type="text/css"?>
${svgString}`;
}
const [fontAwesomeCSS, fontBlob] = await Promise.all([
fetch(fontAwesomeURLs.css).then((response) => response.text()),
fetch(fontAwesomeURLs.woff2).then((res) => res.blob())
]);
const reader = new FileReader();
const fontBase64 = await new Promise<string>((resolve) => {
reader.onloadend = () => {
const base64 = reader.result as string;
// Remove the data URL prefix (data:application/octet-stream;base64,)
resolve(base64.split(',')[1]);
};
reader.readAsDataURL(fontBlob);
});
const styleString = `
@font-face {
font-family: 'Font Awesome 6 Free';
font-style: normal;
font-weight: 900;
src: url(data:font/woff2;base64,${fontBase64}) format('woff2');
}
.fa {
font-family: 'Font Awesome 6 Free';
font-weight: 900;
// TODO: Make this dynamic from config
font-size: 16px;
width: 16px;
fill: black;
}
${fontAwesomeCSS}
`;
return svgString.replace('<style>', `<style>${styleString}`);
};
const getBase64SVG = async ({
svg,
box,
width,
height,
embedFontAwesome
}: {
svg?: HTMLElement;
box?: DOMRect;
width?: number;
height?: number;
embedFontAwesome?: boolean;
} = {}): Promise<string> => {
if (!svg || !box) {
({ svg, box } = await getSvgElement());
}
console.log(box);
// Prevents the SVG size of the interface from being changed
// svg = svg.cloneNode(true) as HTMLElement;
height && svg.setAttribute('height', `${height}px`);
width && svg.setAttribute('width', `${width}px`); // Workaround https://stackoverflow.com/questions/28690643/firefox-error-rendering-an-svg-image-to-html5-canvas-with-drawimage
svg.setAttribute('viewBox', `0 0 ${box.width} ${box.height}`);
const svgString = svg.outerHTML const svgString = svg.outerHTML
.replaceAll('<br>', '<br/>') .replaceAll('<br>', '<br/>')
.replaceAll(/<img([^>]*)>/g, (m, g: string) => `<img ${g} />`); .replaceAll(/<img([^>]*)>/g, (m, g: string) => `<img ${g} />`);
return toBase64(svgString);
};
const svgStringWithFontAwesome = await injectFontAwesome({ const exportImage = (event: Event, exporter: Exporter) => {
svgString, const canvas: HTMLCanvasElement = document.createElement('canvas');
fontAwesomeEmbedMode: embedFontAwesome ? 'raw' : 'url' const svg: HTMLElement | null = document.querySelector('#container svg');
}); if (!svg) {
throw new Error('svg not found');
}
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;
}
console.log(svgStringWithFontAwesome); const context = canvas.getContext('2d');
return toBase64(svgStringWithFontAwesome); if (!context) {
throw new Error('context not found');
}
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.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 simulateDownload = (download: string, href: string): void => {
@@ -145,48 +84,6 @@ ${fontAwesomeCSS}
a.click(); a.click();
a.remove(); a.remove();
}; };
const exportImage = async (event: Event, exporter: Exporter) => {
event.stopPropagation();
event.preventDefault();
const canvas = document.createElement('canvas');
const { svg, box } = await getSvgElement();
if (imageSizeMode === 'width') {
const ratio = box.height / box.width;
canvas.width = imageSize;
canvas.height = imageSize * ratio;
} else if (imageSizeMode === 'height') {
const ratio = box.width / box.height;
canvas.width = imageSize * ratio;
canvas.height = imageSize;
} else {
const multiplier = 2;
canvas.width = box.width * multiplier;
canvas.height = box.height * multiplier;
}
const context = canvas.getContext('2d');
if (!context) {
throw new Error('context not found');
}
context.fillStyle = `hsl(${window.getComputedStyle(document.body).getPropertyValue('--background')})`;
context.fillRect(0, 0, canvas.width, canvas.height);
const image = new Image();
image.addEventListener('load', () => {
exporter(context, image)();
});
image.src = `data:image/svg+xml;base64,${await getBase64SVG({ svg, box, height: canvas.height, width: canvas.width, embedFontAwesome: true })}`;
// Fallback to set panZoom to true after 2 seconds
// This is a workaround for the case when the image is not loaded
// setTimeout(() => {
// if (!$inputStateStore.panZoom) {
// }
// }, 2000);
};
const downloadImage: Exporter = (context, image) => { const downloadImage: Exporter = (context, image) => {
return () => { return () => {
const { canvas } = context; const { canvas } = context;
@@ -199,7 +96,7 @@ ${fontAwesomeCSS}
}; };
const isClipboardAvailable = (): boolean => { const isClipboardAvailable = (): boolean => {
return Object.prototype.hasOwnProperty.call(window, 'ClipboardItem'); return Object.prototype.hasOwnProperty.call(window, 'ClipboardItem') as boolean;
}; };
const clipboardCopy: Exporter = (context, image) => { const clipboardCopy: Exporter = (context, image) => {
@@ -223,116 +120,143 @@ ${fontAwesomeCSS}
}; };
}; };
const onCopyClipboard = async (event: Event) => { const onCopyClipboard = (event: Event) => {
await exportImage(event, clipboardCopy); exportImage(event, clipboardCopy);
logEvent('copyClipboard'); logEvent('copyClipboard');
}; };
const onDownloadPNG = async (event: Event) => { const onDownloadPNG = (event: Event) => {
await exportImage(event, downloadImage); exportImage(event, downloadImage);
logEvent('download', { logEvent('download', {
type: 'png' type: 'png'
}); });
}; };
const onDownloadSVG = async () => { const onDownloadSVG = () => {
simulateDownload(getFileName('svg'), `data:image/svg+xml;base64,${await getBase64SVG()}`); simulateDownload(getFileName('svg'), `data:image/svg+xml;base64,${getBase64SVG()}`);
logEvent('download', { logEvent('download', {
type: 'svg' type: 'svg'
}); });
}; };
let gistURL = $state(''); const onCopyMarkdown = () => {
(document.getElementById('markdown') as HTMLInputElement).select();
document.execCommand('Copy');
logEvent('copyMarkdown');
};
let gistURL = '';
stateStore.subscribe(({ loader }) => { stateStore.subscribe(({ loader }) => {
if (loader?.type === 'gist') { if (loader?.type === 'gist') {
// @ts-expect-error Gist will have url
gistURL = loader.config.url; gistURL = loader.config.url;
} }
}); });
const loadGist = () => { const loadGist = () => {
if (!gistURL) { 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}`; window.location.href = `${window.location.pathname}?gist=${gistURL}`;
logEvent('loadGist'); 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(() => { let isNetlify = false;
if (!imageSizeMode) { if (browser && ['mermaid.live', 'netlify'].some((path) => window.location.host.includes(path))) {
imageSizeMode = 'auto'; isNetlify = true;
} }
stateStore.subscribe(({ code, serialized }) => {
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> </script>
{#snippet dualActionButton(text: string, download: (event: Event) => unknown, url?: string)} <Card title="Actions" isOpen={false}>
<div class="flex flex-grow gap-0.5"> <div class="flex flex-wrap gap-2 m-2">
<Button
class={['flex-grow', url && 'rounded-r-none']}
onclick={download}
data-testid="download-{text}">
<DownloadIcon />
{text}
</Button>
{#if url}
<Button class="rounded-l-none" href={url} target="_blank" rel="noreferrer noopener">
<ExternalLinkIcon />
</Button>
{/if}
</div>
{/snippet}
<Card title="Actions" isStackable icon={{ component: DownloadIcon, class: 'rotate-180' }}>
<div class="flex min-w-fit flex-col gap-2 p-2">
<div class="flex w-full items-center gap-2 whitespace-nowrap py-2">
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)}
{#if env.krokiRendererUrl}
<a target="_blank" rel="noreferrer" class="flex-grow" href={$urlsStore.kroki}>
<Button class="action-btn flex w-full items-center gap-2">
<ExternalLinkIcon /> Kroki
</Button>
</a>
{/if}
</div>
<Separator />
{#if isClipboardAvailable()} {#if isClipboardAvailable()}
<CopyButton onclick={onCopyClipboard} label="Copy Image" /> <button class="action-btn w-full" on:click={onCopyClipboard}
{/if} ><i class="far fa-copy mr-2" /> Copy Image to clipboard
{#if $urlsStore.mdCode} </button>
<CopyInput value={$urlsStore.mdCode} label="Copy Markdown" testID={TID.copyMarkdown} />
{/if} {/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 w-full items-center gap-2"> <div class="flex gap-2 items-center">
<Input type="url" bind:value={gistURL} placeholder="Enter Gist URL" /> PNG size
<Button onclick={loadGist}>Load Gist</Button> <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> </div>
{#if isNetlify} {#if isNetlify}
<div class="flex w-full items-center justify-center"> <div class="w-full flex items-center justify-center">
<a class="link text-sm text-gray-500 underline" href="https://netlify.com"> <a class="link underline text-gray-500 text-sm" href="https://netlify.com">
This site is powered by Netlify This site is powered by Netlify
</a> </a>
</div> </div>
+20 -74
View File
@@ -1,85 +1,31 @@
<script lang="ts"> <script lang="ts">
import type { Tab } from '$/types'; import type { Tab } from '$lib/types';
import type { Component, Snippet } from 'svelte';
import { quintOut } from 'svelte/easing';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import CollapseAllIcon from '~icons/material-symbols/collapse-all-rounded';
import Tabs from './Tabs.svelte'; import Tabs from './Tabs.svelte';
export let isCloseable = true;
interface Props { export let isOpen = true;
isClosable?: boolean; export let tabs: Tab[] = [];
isOpen?: boolean; export let activeTabID = '';
isStackable?: boolean; export let title: string;
tabs?: Tab[]; $: isOpen = isCloseable ? isOpen : true;
activeTabID?: string; $: isTabsShown = isOpen && tabs.length > 0;
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);
</script> </script>
<div <div class="card rounded overflow-hidden m-2 flex-grow flex flex-col shadow-2xl">
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 <div
role="toolbar" class="bg-primary p-2 {isTabsShown ? 'pb-0' : ''} flex-none cursor-pointer"
tabindex="0" on:click={() => (isOpen = !isOpen)}
class={[ on:keypress={() => (isOpen = !isOpen)}>
'flex h-11 flex-none cursor-pointer items-center justify-between whitespace-nowrap bg-muted p-2', <div class="flex justify-between">
isTabsShown && 'pb-1' <Tabs on:select {tabs} bind:isOpen {title} {isCloseable} {activeTabID} />
]} <div class="flex gap-x-4 items-center {isTabsShown ? '-mt-2' : ''}">
onclick={toggleCardOpen} <slot name="actions" />
onkeypress={toggleCardOpen}> </div>
{#if icon || title} </div>
<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}
</div> </div>
{#if isOpen} {#if isOpen}
<div class="flex-grow overflow-x-auto" transition:slide={{ easing: quintOut }}> <div class="card-body p-0 flex-grow overflow-auto text-base-content" transition:slide>
{@render children()} <slot />
</div> </div>
{/if} {/if}
</div> </div>
+40 -40
View File
@@ -1,52 +1,52 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$/components/ui/button'; import type { Tab, TabEvents } from '$lib/types';
import { Separator } from '$/components/ui/separator'; import { createEventDispatcher } from 'svelte';
import type { Tab } from '$lib/types';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
export let isCloseable = true;
let { export let tabs: Tab[];
tabs, export let title: string;
activeTabID, export let isOpen = false;
onselect export let activeTabID: string;
}: {
tabs: Tab[];
activeTabID: string;
onselect?: (tab: Tab) => void;
} = $props();
if (!activeTabID && tabs.length > 0) { if (!activeTabID && tabs.length > 0) {
activeTabID = tabs[0].id; activeTabID = tabs[0].id;
} }
const dispatch = createEventDispatcher<TabEvents>();
const toggleTabs = (tab: Tab) => { const toggleTabs = (tab: Tab) => {
return (event: Event) => { activeTabID = tab.id;
event.stopPropagation(); dispatch('select', tab);
onselect?.(tab);
};
}; };
</script> </script>
<div class="flex w-fit cursor-default items-center gap-2"> <div class="flex cursor-default">
<ul class="flex gap-2 align-middle" transition:fade> <span
{#each tabs as tab, index} class="mr-2 font-semibold"
<Button on:click|stopPropagation={() => (isOpen = !isOpen)}
role="tab" on:keypress|stopPropagation={() => (isOpen = !isOpen)}>
variant="ghost" {#if isCloseable}
class={[ <i class="fas fa-chevron-right icon" class:isOpen />
'px-2', {/if}
activeTabID === tab.id && 'rounded-b-none border-b-2 border-b-primary-foreground/50' {title}</span>
]} {#if isOpen && tabs}
onclick={toggleTabs(tab)} <ul class="tabs" transition:fade>
onkeypress={toggleTabs(tab)}> {#each tabs as tab}
<tab.icon /> <div
{tab.title} class="tab tab-lifted {activeTabID === tab.id ? 'tab-active' : 'text-primary-content'}"
</Button> on:click|stopPropagation={() => toggleTabs(tab)}
on:keypress|stopPropagation={() => toggleTabs(tab)}>
{#if index < tabs.length - 1} <i class="mr-1 {tab.icon}" />
<div class="my-2"> {tab.title}
<Separator orientation="vertical" class="w-0.5 bg-slate-300" />
</div> </div>
{/if} {/each}
{/each} </ul>
</ul> {/if}
</div> </div>
<style>
.icon {
transition-duration: 0.5s;
}
.isOpen {
transform: rotate(90deg);
}
</style>
+23
View File
@@ -0,0 +1,23 @@
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', icon: 'fab fa-git-alt' },
{ id: 't2', title: 'title2', icon: 'far fa-bookmark' }
]
});
expect(container).toBeTruthy();
expect(container).toHaveTextContent('TabTest');
expect(container).toHaveTextContent('title1');
expect(container).toHaveTextContent('title2');
expect(container).not.toHaveTextContent('title3');
});
});
-41
View File
@@ -1,41 +0,0 @@
<script lang="ts">
import { Button } from '$/components/ui/button';
import type { InputType } from '$/types';
import { notify } from '$/util/notify';
import { scale } from 'svelte/transition';
import CheckIcon from '~icons/material-symbols/check-rounded';
import CopyIcon from '~icons/material-symbols/content-copy-outline-rounded';
let {
onclick,
label = 'Copy'
}: { onclick: (event?: Event) => Promise<unknown>; label?: string; type?: InputType } = $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>
-118
View File
@@ -1,118 +0,0 @@
<script lang="ts">
import type { EditorProps } from '$/types';
import { stateStore } from '$/util/state';
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';
const { onUpdate }: EditorProps = $props();
let divElement: HTMLDivElement | undefined = $state();
let editor: monaco.editor.IStandaloneCodeEditor | undefined;
let editorOptions = {
minimap: {
enabled: false
},
overviewRulerLanes: 0
} satisfies monaco.editor.IStandaloneEditorConstructionOptions;
let currentText = '';
const jsonModel = monaco.editor.createModel(
'',
'json',
monaco.Uri.parse('internal://config.json')
);
const mermaidModel = monaco.editor.createModel(
'',
'mermaid',
monaco.Uri.parse('internal://mermaid.mmd')
);
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: 'https://mermaid.js.org/schemas/config.schema.json'
}
]
});
initEditor(monaco);
errorDebug();
editor = monaco.editor.create(divElement, editorOptions);
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);
}
// Update editor text if it's different
const newText = editorMode === 'code' ? code : mermaid;
if (newText !== currentText) {
editor.setScrollTop(0);
editor.setValue(newText);
currentText = newText;
}
// Display/clear errors
monaco.editor.setModelMarkers(model, 'mermaid', errorMarkers);
});
const unsubscribeMode = mode.subscribe((mode) => {
editor && monaco.editor.setTheme(`mermaid${mode === 'dark' ? '-dark' : ''}`);
});
const resizeObserver = new ResizeObserver((entries) => {
editor?.layout({
height: entries[0].contentRect.height,
width: entries[0].contentRect.width
});
});
if (divElement.parentElement) {
resizeObserver.observe(divElement);
}
return () => {
unsubscribeState();
unsubscribeMode();
resizeObserver.disconnect();
editor?.dispose();
};
});
</script>
<div bind:this={divElement} id="editor" class="h-full flex-grow overflow-hidden"></div>
@@ -1,109 +0,0 @@
<script lang="ts">
import { Button } from '$/components/ui/button';
import { TID } from '$/constants';
import type { DocumentationConfig } from '$/types';
import { standardizeDiagramType } from '$/util/mermaid';
import { stateStore } from '$/util/state';
import BookIcon from '~icons/material-symbols/book-2-outline-rounded';
const docURLBase = 'https://mermaid.js.org';
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'
},
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 }}
<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>
+119 -46
View File
@@ -1,55 +1,128 @@
<script lang="ts"> <script lang="ts">
import DesktopEditor from '$/components/DesktopEditor.svelte'; import type { EditorMode } from '$lib/types';
import McWrapper from '$/components/McWrapper.svelte'; import { stateStore, updateCode, updateConfig } from '$lib/util/state';
import MermaidChartIcon from '$/components/MermaidChartIcon.svelte'; import { themeStore } from '$lib/util/theme';
import MobileEditor from '$/components/MobileEditor.svelte'; import { errorDebug, syncDiagram } from '$lib/util/util';
import { Button } from '$/components/ui/button'; import * as monaco from 'monaco-editor';
import { TID } from '$/constants'; import monacoJsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
import { env } from '$/util/env'; import monacoEditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import { stateStore, updateCode, updateConfig, urlsStore } from '$lib/util/state'; import { onMount } from 'svelte';
import ExclamationCircleIcon from '~icons/material-symbols/error-outline-rounded'; import { initEditor } from '$lib/util/monacoExtra';
import { logEvent } from '$lib/util/stats';
let { isMobile }: { isMobile: boolean } = $props(); let divEl: HTMLDivElement | undefined = undefined;
const onUpdate = (text: string) => { let editor: monaco.editor.IStandaloneCodeEditor | undefined;
if ($stateStore.editorMode === 'code') { 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.setScrollTop(0);
editor.setValue(newText);
text = newText;
}
// Update editor mode if it's different
const language = editorMode === 'code' ? 'mermaid' : 'json';
const model = editor.getModel();
if (!model) {
console.error("editor model doesn't exist");
return;
}
if (model.getLanguageId() !== language) {
monaco.editor.setModelLanguage(model, language);
}
// Display/clear errors
monaco.editor.setModelMarkers(model, '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); updateCode(text);
} else { } else {
updateConfig(text); updateConfig(text);
} }
}; };
onMount(async () => {
self.MonacoEnvironment = {
getWorker(_, label) {
if (label === 'json') {
return new monacoJsonWorker();
}
return new monacoEditorWorker();
}
};
if (!divEl) {
throw new Error('divEl is undefined');
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
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 (!newText || 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: 'keyboardShortcut'
});
}
});
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
});
});
if (divEl.parentElement) {
resizeObserver.observe(divEl.parentElement);
}
// @ts-ignore
if (window.Cypress) {
// @ts-ignore
window.editorLoaded = true;
}
return () => {
// console.log(`editor disposed`);
editor?.dispose();
};
});
</script> </script>
<div class="flex h-full flex-col"> <div bind:this={divEl} id="editor" class="overflow-hidden" />
{#if isMobile}
<MobileEditor {onUpdate} />
{:else}
<DesktopEditor {onUpdate} />
{/if}
{#if $stateStore.error instanceof Error}
<div class="flex flex-col text-sm" data-testid={TID.errorContainer}>
<div class="flex items-center justify-between gap-2 bg-slate-900 p-2 text-white">
<div class="flex w-fit items-center gap-2">
<ExclamationCircleIcon class="size-6 text-destructive" aria-hidden="true" />
<div class="flex flex-col">
<p>Syntax error</p>
{#if env.isEnabledMermaidChartLinks}
<p class="text-xs text-white/60">Create a free account to repair with AI</p>
{/if}
</div>
</div>
<McWrapper>
<Button
variant="accent"
size="sm"
href={$urlsStore.mermaidChart({ medium: 'ai_repair' }).save}>
<MermaidChartIcon />
AI Repair
</Button>
</McWrapper>
</div>
<output class="max-h-32 overflow-auto bg-muted p-2" name="mermaid-error" for="editor">
<pre>{$stateStore.error?.toString()}</pre>
</output>
</div>
{/if}
</div>
-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>
-54
View File
@@ -1,54 +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');
// eslint-disable-next-line unicorn/prefer-top-level-await
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>
+83 -101
View File
@@ -1,53 +1,42 @@
<script lang="ts"> <script lang="ts">
import Card from '$lib/components/Card/Card.svelte'; import Card from '$lib/components/Card/Card.svelte';
import type { HistoryEntry, HistoryType, State, Tab } from '$lib/types'; import { inputStateStore, getStateString } from '$lib/util/state';
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 { import {
addHistoryEntry, addHistoryEntry,
historyModeStore,
clearHistoryData, clearHistoryData,
getPreviousState, getPreviousState,
historyModeStore,
historyStore, historyStore,
loaderHistoryStore, loaderHistoryStore,
restoreHistory restoreHistory
} from './history'; } from './history';
import { notify, prompt } from '$lib/util/notify';
import { onMount } from 'svelte';
import { get } from 'svelte/store';
import dayjs from 'dayjs';
import dayjsRelativeTime from 'dayjs/plugin/relativeTime';
import type { HistoryEntry, HistoryType, State, Tab } from '$lib/types';
import { logEvent } from '$lib/util/stats';
dayjs.extend(dayjsRelativeTime); dayjs.extend(dayjsRelativeTime);
const HISTORY_SAVE_INTERVAL = 60_000; const HISTORY_SAVE_INTERVAL = 60000;
const tabSelectHandler = (tab: Tab) => { const tabSelectHandler = (message: CustomEvent<Tab>) => {
historyModeStore.set(tab.id as HistoryType); historyModeStore.set(message.detail.id as HistoryType);
}; };
let tabs: Tab[] = [
let tabs: Tab[] = $state([
{ {
id: 'manual', id: 'manual',
title: 'Saved', title: 'Saved',
icon: BookmarkIcon icon: 'far fa-bookmark'
}, },
{ {
id: 'auto', id: 'auto',
title: 'Timeline', title: 'Timeline',
icon: HistoryIcon icon: 'fas fa-history'
} }
]); ];
const downloadHistory = () => { const downloadHistory = () => {
const data = get(historyStore); const data = get(historyStore);
@@ -67,13 +56,17 @@
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'file'; input.type = 'file';
input.accept = 'application/json'; input.accept = 'application/json';
input.addEventListener('change', async ({ target }: Event) => { input.addEventListener('change', ({ target }: Event) => {
const file = (target as HTMLInputElement)?.files?.[0]; const file = (<HTMLInputElement>target).files[0];
if (!file) { if (!file) {
return; return;
} }
const data: HistoryEntry[] = JSON.parse(await file.text()); const reader = new FileReader();
restoreHistory(data); reader.onload = (e) => {
const data: HistoryEntry[] = JSON.parse(e.target.result as string);
restoreHistory(data);
};
reader.readAsText(file);
}); });
input.click(); input.click();
}; };
@@ -103,6 +96,11 @@
inputStateStore.set({ ...state, updateDiagram: true }); inputStateStore.set({ ...state, updateDiagram: true });
}; };
const relativeTime = (time: number) => {
const t = new Date(time);
return `${new Date(t).toLocaleString()} (${dayjs(t).fromNow()})`;
};
onMount(() => { onMount(() => {
historyModeStore.set('manual'); historyModeStore.set('manual');
setInterval(() => { setInterval(() => {
@@ -116,93 +114,77 @@
{ {
id: 'loader', id: 'loader',
title: 'Revisions', title: 'Revisions',
icon: GitAltIcon icon: 'fab fa-git-alt'
}, },
...tabs ...tabs
]; ];
historyModeStore.set('loader'); historyModeStore.set('loader');
} }
}); });
let isOpen = false;
</script> </script>
<Card onselect={tabSelectHandler} isOpen isClosable={false} {tabs}> <Card on:select={tabSelectHandler} bind:isOpen {tabs} title="History">
{#snippet actions()} <div slot="actions">
<div class="flex items-center gap-2"> <button
<Button id="uploadHistory"
size="icon" class="btn btn-xs btn-secondary w-12"
variant="ghost" on:click|stopPropagation={() => uploadHistory()}
id="uploadHistory" title="Upload history"><i class="fa fa-upload" /></button>
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">
{#if $historyStore.length > 0} {#if $historyStore.length > 0}
{#each $historyStore as { id, state, time, name, url, type } (id)} <button
<li class="flex flex-col gap-2"> id="downloadHistory"
<div class="flex items-center justify-between"> class="btn btn-xs btn-secondary w-12"
<div class="flex flex-col"> on:click|stopPropagation={() => downloadHistory()}
{#if url} title="Download history"><i class="fa fa-download" /></button>
<a {/if}
href={url} |
target="_blank" <button
title="Open revision in new tab" id="saveHistory"
class="text-blue-500 hover:underline">{name}</a> class="btn btn-xs btn-success w-12"
{:else} on:click|stopPropagation={() => saveHistory()}
<span class="whitespace-nowrap">{name}</span> title="Save current state"><i class="far fa-save" /></button>
{/if} {#if $historyModeStore !== 'loader'}
<span class="whitespace-nowrap text-xs text-primary-foreground/30"> <button
{new Date(time).toLocaleString()} id="clearHistory"
</span> 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"
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>
<div class="flex gap-2 content-center">
<div class="flex items-center gap-2"> <button class="btn btn-success" on:click={() => restoreHistoryItem(state)}
<span class="whitespace-nowrap text-sm text-primary-foreground/50"> ><i class="fas fa-undo mr-1" />Restore</button>
{dayjs(time).fromNow()}
</span>
<Button size="icon" variant="ghost" onclick={() => restoreHistoryItem(state)}>
<UndoIcon />
</Button>
{#if type !== 'loader'} {#if type !== 'loader'}
<Button <button class="btn btn-error" on:click={() => clearHistory(id)}
size="icon" ><i class="fas fa-trash-alt mr-1" />Delete</button>
variant="ghost"
class="hover:text-destructive"
onclick={() => clearHistory(id)}>
<TrashAltIcon />
</Button>
{/if} {/if}
</div> </div>
</div> </div>
<Separator />
</li> </li>
{/each} {/each}
{:else} {:else}
<div class="m-2 text-center"> <div class="m-2">
No items in History<br /> No items in History<br />
Click the Save button to save current state and restore it later.<br /> Click the Save button to save current state and restore it later.<br />
Timeline will automatically be saved every minute. Timeline will automatically be saved every minute.
+5 -5
View File
@@ -1,10 +1,10 @@
import type { HistoryEntry, HistoryType, Optional } from '$lib/types'; import { derived, writable, get } from 'svelte/store';
import { localStorage, persist } from '$lib/util/persist';
import { logEvent } from '$lib/util/stats';
import { generateSlug } from 'random-word-slugs';
import type { Readable, Writable } from 'svelte/store'; import 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 { v4 as uuidV4 } from 'uuid';
import { logEvent } from '$lib/util/stats';
const MAX_AUTO_HISTORY_LENGTH = 30; const MAX_AUTO_HISTORY_LENGTH = 30;
-87
View File
@@ -1,87 +0,0 @@
<script lang="ts">
import McWrapper from '$/components/McWrapper.svelte';
import ThemeIcon from '$/components/ThemeIcon.svelte';
import * as Popover from '$/components/ui/popover';
import { Switch } from '$/components/ui/switch';
import { urlsStore } from '$/util/state';
import { cn } from '$/utils';
import { mode, setMode } from 'mode-watcher';
import type { Component } from 'svelte';
import AddIcon from '~icons/material-symbols/add-2-rounded';
import BookIcon from '~icons/material-symbols/book-2-outline-rounded';
import PluginIcon from '~icons/material-symbols/extension-outline';
import HomeIcon from '~icons/material-symbols/house-outline-rounded';
import CommunityIcon from '~icons/material-symbols/person-play-outline-rounded';
import PlaygroundIcon from '~icons/material-symbols/shape-line-outline';
import MermaidChartIcon from './MermaidChartIcon.svelte';
const menuItems = $derived([
{ label: 'New Diagram', icon: AddIcon, href: $urlsStore.new },
{ label: 'Home', icon: HomeIcon, href: 'https://mermaid.js.org/' },
{ label: 'Documentation', icon: BookIcon, href: 'https://mermaid.js.org/intro/' },
{ label: 'Community', icon: CommunityIcon, href: 'https://discord.gg/sKeNQX4Wtj' }
]);
const mermaidChartMenuItems = $derived([
{
label: 'Edit in Playground',
icon: PlaygroundIcon,
href: $urlsStore.mermaidChart({ medium: 'main_menu' }).playground
},
{
label: 'Plugins',
icon: PluginIcon,
href: $urlsStore.mermaidChart({ medium: 'main_menu' }).plugins,
checkDiagramType: false
},
{
label: 'MermaidChart',
icon: MermaidChartIcon,
href: $urlsStore.mermaidChart({ medium: 'main_menu' }).home,
checkDiagramType: false
}
]);
</script>
{#snippet menuItem(options: { label: string; icon: Component; href: string; class?: string })}
<a
href={options.href}
target="_blank"
class={cn(
'flex items-center justify-start gap-2 border-b bg-muted p-2 px-3 hover:bg-background',
options.class
)}>
<options.icon class="size-5" />
{options.label}
</a>
{/snippet}
<Popover.Root>
<Popover.Trigger class="shrink-0">
<img class="size-6" src="/favicon.svg" alt="Mermaid Live Editor" />
</Popover.Trigger>
<Popover.Content align="start" class="flex flex-col overflow-hidden p-0" sideOffset={16}>
{#each menuItems as item}
{@render menuItem(item)}
{/each}
<div class="flex items-center justify-between border-b bg-muted px-3 py-2 hover:bg-background">
<span class="flex items-center gap-2">
<ThemeIcon />
Dark Mode
</span>
<Switch
checked={$mode === 'dark'}
onCheckedChange={(dark) => setMode(dark ? 'dark' : 'light')} />
</div>
{#each mermaidChartMenuItems as item}
<McWrapper side="right" shouldCheckDiagramType={item.checkDiagramType}>
{@render menuItem({
...item,
class: 'text-accent bg-background hover:bg-muted'
})}
</McWrapper>
{/each}
</Popover.Content>
</Popover.Root>
-50
View File
@@ -1,50 +0,0 @@
<script lang="ts">
import { env } from '$/util/env';
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,
shouldCheckDiagramType = true,
side = 'bottom',
labelPrefix = 'Opens a new tab in'
}: {
children: Snippet;
shouldCheckDiagramType?: boolean;
side?: ComponentProps<typeof Tooltip.Content>['side'];
labelPrefix?: string;
} = $props();
let shouldDisableComponent = $derived(
shouldCheckDiagramType && $stateStore.diagramType === 'zenuml'
);
</script>
{#if env.isEnabledMermaidChartLinks}
<Tooltip.Provider>
<Tooltip.Root delayDuration={100}>
<Tooltip.Trigger>
<div class={[shouldDisableComponent && 'pointer-events-none cursor-not-allowed grayscale']}>
{@render children()}
</div>
</Tooltip.Trigger>
<Tooltip.Content {side}>
<div class="flex items-center gap-2">
{#if shouldDisableComponent}
<div class="text-muted-foreground">
This diagram type is not supported in MermaidChart.com
</div>
{:else}
<ExternalLinkIcon />
<span class="flex items-center gap-1">
{labelPrefix}
<div class="text-accent">MermaidChart.com</div>
</span>
{/if}
</div>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
{/if}
@@ -1,7 +0,0 @@
<script lang="ts">
import type { ClassValue } from 'svelte/elements';
let { class: className }: { class?: ClassValue } = $props();
</script>
<img class={['size-4', className]} src="/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>
+76 -117
View File
@@ -1,137 +1,96 @@
<script lang="ts" module> <script context="module" lang="ts">
import { logEvent, plausible } from '$lib/util/stats';
import { version } from 'mermaid/package.json'; import { version } from 'mermaid/package.json';
import { analytics } from '$lib/util/stats';
void logEvent('version', { void analytics?.track('version', {
mermaidVersion: version mermaidVersion: version
}); });
</script> </script>
<script lang="ts"> <script lang="ts">
import MainMenu from '$/components/MainMenu.svelte'; import Theme from './Theme.svelte';
import McWrapper from '$/components/McWrapper.svelte';
import { Button } from '$/components/ui/button';
import { Separator } from '$/components/ui/separator';
import { Switch } from '$/components/ui/switch';
import { dismissPromotion, getActivePromotion } from '$lib/util/promos/promo';
import { urlsStore } from '$lib/util/state';
import { MCBaseURL } from '$lib/util/util';
import type { ComponentProps, Snippet } from 'svelte';
import CloseIcon from '~icons/material-symbols/close-rounded';
import GithubIcon from '~icons/mdi/github';
import DropdownNavMenu from './DropdownNavMenu.svelte';
interface Props { interface Link {
mobileToggle?: Snippet; href: string;
children: Snippet; title?: string;
icon?: string;
img?: string;
} }
const links: Link[] = [
let { children, mobileToggle }: Props = $props();
const isReferral = document.referrer.includes(MCBaseURL);
type Links = ComponentProps<typeof DropdownNavMenu>['links'];
const githubLinks: Links = [
{ title: 'Mermaid JS', href: 'https://github.com/mermaid-js/mermaid' },
{ {
title: 'Mermaid Live Editor', title: 'Documentation',
href: 'https://github.com/mermaid-js/mermaid-live-editor' href: 'https://mermaid.js.org/intro/n00b-gettingStarted.html'
}, },
{ {
title: 'Mermaid CLI', title: 'Tutorial',
href: 'https://mermaid.js.org/config/Tutorials.html'
},
{
title: 'Mermaid',
href: 'https://github.com/mermaid-js/mermaid'
},
{
title: 'CLI',
href: 'https://github.com/mermaid-js/mermaid-cli' href: 'https://github.com/mermaid-js/mermaid-cli'
},
{
href: 'https://github.com/mermaid-js/mermaid-live-editor',
icon: 'fab fa-github fa-lg'
},
{
href: 'https://mermaidchart.com',
img: '/mermaidchart-logo.svg'
} }
]; ];
let activePromotion = $state(getActivePromotion());
const trackBannerClick = () => {
if (!plausible || !activePromotion) {
return;
}
logEvent('bannerClick', {
promotion: activePromotion.id
});
};
</script> </script>
{#if activePromotion} <div class="navbar shadow-lg bg-primary p-0">
<div class="top-bar z-10 flex h-fit w-full bg-primary"> <div class="flex-1 px-2 mx-2">
<div <span class="text-lg font-bold">
class="flex flex-grow" <a href="/">Mermaid<span class="text-xs font-thin">v{version}</span> Live Editor</a>
role="button" </span>
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> </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"> <Theme />
<div class="flex flex-1 items-center gap-4"> <div class="hidden lg:flex lg:items-center lg:w-auto w-full" id="menu">
<MainMenu /> <ul class="lg:flex items-center justify-between text-base pt-4 lg:pt-0">
<div {#each links as { title, href, icon, img }}
id="switcher" <li>
class="flex items-center justify-center gap-4 font-medium" <a class="btn btn-ghost" target="_blank" {href}>
class:flex-row-reverse={isReferral}> {#if icon}
<a href="/" class="whitespace-nowrap text-accent"> <i class={icon} />
{#if !isReferral && !mobileToggle} {:else if img}
Mermaid <img src={img} alt={title} />
{/if} {/if}
Live Editor {#if title}
</a> {title}
{/if}
<McWrapper labelPrefix="Opens the current diagram in">
<div class="hidden items-center justify-center gap-4 md:flex">
<Separator orientation="vertical" />
<Switch
id="editorMode"
class="data-[state=checked]:bg-secondary"
checked={isReferral}
onclick={() => {
logEvent('playgroundToggle', { isReferred: isReferral });
// Wait for the event to be logged
setTimeout(() => {
window.open(
$urlsStore.mermaidChart({ medium: 'toggle' }).playground,
'_self',
// Do not send referrer header, if the user already came from playground
isReferral ? 'noreferrer' : ''
);
}, 100);
}} />
<a
href={$urlsStore.mermaidChart({ medium: 'toggle' }).playground}
class="whitespace-nowrap">
Playground <span class="hidden text-sm opacity-50 lg:inline"
>- more features, no account required</span>
</a> </a>
</div> </li>
</McWrapper> {/each}
</div> </ul>
</div> </div>
<div </div>
id="menu"
class="hidden flex-nowrap items-center justify-between gap-3 overflow-hidden md:flex"> <style>
<DropdownNavMenu icon={GithubIcon} links={githubLinks} /> #menu-toggle:checked + #menu {
<Separator orientation="vertical" /> display: block;
{@render children()} }
</div> .navbar {
{@render mobileToggle?.()} z-index: 10000;
</nav> }
img {
width: 1.5rem;
height: 1.5rem;
}
</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>
+58 -132
View File
@@ -1,30 +1,20 @@
<script lang="ts"> <script lang="ts">
import Card from '$/components/Card/Card.svelte';
import { Button } from '$/components/ui/button';
import { updateCode } from '$lib/util/state'; import { updateCode } from '$lib/util/state';
import Card from '$lib/components/Card/Card.svelte';
import { logEvent } from '$lib/util/stats'; import { logEvent } from '$lib/util/stats';
import ShapesIcon from '~icons/material-symbols/account-tree-outline-rounded';
const samples = { const samples = {
Block: `block-beta Flow: `flowchart TD
columns 3 A[Christmas] -->|Get money| B(Go shopping)
doc>"Document"]:3 B --> C{Let me think}
space down1<[" "]>(down) space C -->|One| D[Laptop]
C -->|Two| E[iPhone]
block:e:3 C -->|Three| F[fa:fa-car Car]`,
l["left"] Sequence: `sequenceDiagram
m("A wide one in the middle") Alice->>+John: Hello John, how are you?
r["right"] Alice->>+John: John, can you hear me?
end John-->>-Alice: Hi Alice, I can hear you!
space down2<[" "]>(down) space John-->>-Alice: I feel great!`,
db[("DB")]:3
space:3
D space C
db --> D
C --> db
D --> C
style m fill:#d6d,stroke:#333,stroke-width:4px
`,
Class: `classDiagram Class: `classDiagram
Animal <|-- Duck Animal <|-- Duck
Animal <|-- Fish Animal <|-- Fish
@@ -46,21 +36,13 @@
+bool is_wild +bool is_wild
+run() +run()
}`, }`,
ER: `erDiagram State: `stateDiagram-v2
CUSTOMER }|..|{ DELIVERY-ADDRESS : has [*] --> Still
CUSTOMER ||--o{ ORDER : places Still --> [*]
CUSTOMER ||--o{ INVOICE : "liable for" Still --> Moving
DELIVERY-ADDRESS ||--o{ ORDER : receives Moving --> Still
INVOICE ||--|{ ORDER : covers Moving --> Crash
ORDER ||--|{ ORDER-ITEM : includes Crash --> [*]`,
PRODUCT-CATEGORY ||--|{ PRODUCT : contains
PRODUCT ||--o{ ORDER-ITEM : "ordered in"`,
Flow: `flowchart TD
A[Christmas] -->|Get money| B(Go shopping)
B --> C{Let me think}
C -->|One| D[Laptop]
C -->|Two| E[iPhone]
C -->|Three| F[fa:fa-car Car]`,
Gantt: `gantt Gantt: `gantt
title A Gantt Diagram title A Gantt Diagram
dateFormat YYYY-MM-DD dateFormat YYYY-MM-DD
@@ -70,6 +52,28 @@
section Another section Another
Task in sec :2014-01-12 , 12d Task in sec :2014-01-12 , 12d
another task : 24d`, 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 Git: `gitGraph
commit commit
commit commit
@@ -89,7 +93,7 @@
Popularisation Popularisation
British popular psychology author Tony Buzan British popular psychology author Tony Buzan
Research Research
On effectiveness<br/>and features On effectivness<br/>and features
On Automatic creation On Automatic creation
Uses Uses
Creative techniques Creative techniques
@@ -98,32 +102,6 @@
Tools Tools
Pen and paper Pen and paper
Mermaid`, Mermaid`,
Packet: `---
title: "TCP Packet"
---
packet-beta
0-15: "Source Port"
16-31: "Destination Port"
32-63: "Sequence Number"
64-95: "Acknowledgment Number"
96-99: "Data Offset"
100-105: "Reserved"
106: "URG"
107: "ACK"
108: "PSH"
109: "RST"
110: "SYN"
111: "FIN"
112-127: "Window"
128-143: "Checksum"
144-159: "Urgent Pointer"
160-191: "(Options and Padding)"
192-255: "Data (variable length)"
`,
Pie: `pie title Pets adopted by volunteers
"Dogs" : 386
"Cats" : 85
"Rats" : 15`,
QuadrantChart: `quadrantChart QuadrantChart: `quadrantChart
title Reach and engagement of campaigns title Reach and engagement of campaigns
x-axis Low Reach --> High Reach x-axis Low Reach --> High Reach
@@ -137,73 +115,23 @@ packet-beta
Campaign C: [0.57, 0.69] Campaign C: [0.57, 0.69]
Campaign D: [0.78, 0.34] Campaign D: [0.78, 0.34]
Campaign E: [0.40, 0.34] Campaign E: [0.40, 0.34]
Campaign F: [0.35, 0.78]`, Campaign F: [0.35, 0.78]`
Sequence: `sequenceDiagram
Alice->>+John: Hello John, how are you?
Alice->>+John: John, can you hear me?
John-->>-Alice: Hi Alice, I can hear you!
John-->>-Alice: I feel great!`,
State: `stateDiagram-v2
[*] --> Still
Still --> [*]
Still --> Moving
Moving --> Still
Moving --> Crash
Crash --> [*]`,
'User Journey': `journey
title My working day
section Go to work
Make tea: 5: Me
Go upstairs: 3: Me
Do work: 1: Me, Cat
section Go home
Go downstairs: 5: Me
Sit down: 3: Me`,
XYChart: `
xychart-beta
title "Sales Revenue"
x-axis [jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec]
y-axis "Revenue (in $)" 4000 --> 11000
bar [5000, 6000, 7500, 8200, 9500, 10500, 11000, 10200, 9200, 8500, 7000, 6000]
line [5000, 6000, 7500, 8200, 9500, 10500, 11000, 10200, 9200, 8500, 7000, 6000]`,
ZenUML: `zenuml
title Order Service
@Actor Client #FFEBE6
@Boundary OrderController #0747A6
@EC2 <<BFF>> OrderService #E3FCEF
group BusinessService {
@Lambda PurchaseService
@AzureFunction InvoiceService
}
@Starter(Client)
// \`POST /orders\`
OrderController.post(payload) {
OrderService.create(payload) {
order = new Order(payload)
if(order != null) {
par {
PurchaseService.createPO(order)
InvoiceService.createInvoice(order)
}
}
}
}
`
}; };
type SampleTypes = keyof typeof samples; type SampleTypes = keyof typeof samples;
const loadSampleDiagram = (diagramType: SampleTypes): void => { const loadSampleDiagram = (diagramType: SampleTypes): void => {
updateCode(samples[diagramType], { updateCode(samples[diagramType], {
resetPanZoom: true, updateDiagram: true,
updateDiagram: true resetPanZoom: true
}); });
logEvent('loadSampleDiagram', { diagramType }); logEvent('loadSampleDiagram', { diagramType });
}; };
// Adding in this array will add an icon to the preset menu
const newDiagrams: SampleTypes[] = ['Mindmap', 'QuadrantChart'];
const diagramOrder: SampleTypes[] = [ const diagramOrder: SampleTypes[] = [
'Flow',
'Sequence', 'Sequence',
'Flow',
'Class', 'Class',
'State', 'State',
'ER', 'ER',
@@ -212,23 +140,21 @@ packet-beta
'Git', 'Git',
'Pie', 'Pie',
'Mindmap', 'Mindmap',
'ZenUML', 'QuadrantChart'
'QuadrantChart',
'XYChart',
'Block',
'Packet'
]; ];
</script> </script>
<Card title="Sample Diagrams" isOpen isStackable icon={{ component: ShapesIcon }}> <Card title="Sample Diagrams" isOpen={false}>
<div class="flex h-fit max-h-52 flex-wrap gap-2 overflow-y-auto p-2"> <div class="flex flex-wrap p-2 gap-2">
{#each diagramOrder as sample} {#each diagramOrder as sample}
<Button <button
size="sm" class="btn btn-sm btn-primary w-28 normal-case flex-grow"
class="w-fit min-w-20 flex-grow normal-case" on:click={() => loadSampleDiagram(sample)}>
onclick={() => loadSampleDiagram(sample)}>
{sample} {sample}
</Button> {#if newDiagrams.includes(sample)}
<span class="ml-2 fa fa-heart" />
{/if}
</button>
{/each} {/each}
</div> </div>
</Card> </Card>
-53
View File
@@ -1,53 +0,0 @@
<script>
import * as Dialog from '$/components/ui/dialog';
import ShieldIcon from '~icons/material-symbols/shield-lock-outline-rounded';
</script>
<Dialog.Root>
<Dialog.Trigger>
<ShieldIcon />
</Dialog.Trigger>
<Dialog.Content class="max-h-full overflow-y-auto">
<Dialog.Header>
<Dialog.Title class="flex items-center gap-2 text-xl">
<ShieldIcon class="size-8 text-green-700" />
Data security
</Dialog.Title>
</Dialog.Header>
<p class="text-xl font-semibold">
The content of the diagrams you create never leaves your browser.
</p>
<p>It's only stored in the URL, and your browser's local storage.</p>
<p>
Mermaid Live Editor is a fully open source, client side application, deployed transparently on <a
href="https://github.com/mermaid-js/mermaid-live-editor/deployments"
class="underline"
target="_blank">
GitHub Pages
</a>.
</p>
<p>
It will also work as a fully offline
<a href="https://web.dev/explore/progressive-web-apps" target="_blank">
Progressive Web App.
</a>
</p>
<p>
The only server we have is a self hosted version of the open source and privacy friendly
Plausible Analytics. We only collect anonymous data related to actions performed, like the
type of diagram rendered, number of times a feature was used, etc.
</p>
<p>
All the data we collect is anonymized and
<a href="https://p.mermaid.live/mermaid.live" class="underline" target="_blank">
available publicly.
</a>
</p>
<p>
Additional services like the external PNG/SVG/Kroki links and "Save to Mermaid Chart" feature
will share your diagram with the respective 3rd party service.
</p>
</Dialog.Content>
</Dialog.Root>
-48
View File
@@ -1,48 +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 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="/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 lang="ts">
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">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:bordered={$themeStore.theme !== undefined && theme.includes($themeStore.theme)}>
<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,28 +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 { 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>
<Button variant="ghost" size="icon" title="Privacy & Security">
<Privacy />
</Button>
<Separator orientation="vertical" />
<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>
+110 -111
View File
@@ -1,165 +1,164 @@
<script lang="ts"> <script lang="ts">
import type { State, ValidatedState } from '$/types'; import { inputStateStore, stateStore, updateCodeStore } from '$lib/util/state';
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 { logEvent, 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 { onMount } from 'svelte'; 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 { cmdKey } from '$lib/util/util';
import { render as renderDiagram } from '$lib/util/mermaid';
import type { MermaidConfig } from 'mermaid';
let {
panZoomState = new PanZoomState(),
shouldShowGrid = true
}: { panZoomState?: PanZoomState; shouldShowGrid?: boolean } = $props();
let code = ''; let code = '';
let config = ''; let config = '';
let container: HTMLDivElement | undefined = $state(); let container: HTMLDivElement;
let rough: boolean; let view: HTMLDivElement;
let view: HTMLDivElement | undefined = $state(); let error = false;
let error = $state(false); let outOfSync = false;
let panZoom = true; let hide = false;
let manualUpdate = true; let manualUpdate = true;
let waitForFontAwesomeToLoad: FontAwesome['waitForFontAwesomeToLoad'] | undefined = $state(); let panZoomEnabled = $stateStore.panZoom;
let pzoom: typeof panzoom | undefined;
// Set up panZoom state observer to update the store when pan/zoom changes const handlePanZoomChange = () => {
const setupPanZoomObserver = () => { if (!pzoom) {
panZoomState.onPanZoomChange = (pan, zoom) => { return;
updateCodeStore({ pan, zoom }); }
logEvent('panZoom'); const pan = pzoom.getPan();
}; const zoom = pzoom.getZoom();
updateCodeStore({ pan, zoom });
logEvent('panZoom');
}; };
const handlePanZoom = (state: State, graphDiv: SVGSVGElement) => { const handlePanZoom = (state: State) => {
panZoomState.updateElement(graphDiv, state); if (!state.panZoom) {
return;
}
hide = true;
pzoom?.destroy();
pzoom = undefined;
void Promise.resolve().then(() => {
const graphDiv = document.getElementById('graph-div');
if (!graphDiv) {
return;
}
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 handleStateChange = async (state: ValidatedState) => {
const startTime = Date.now();
if (state.error !== undefined) { if (state.error !== undefined) {
error = true; error = true;
return; return;
} }
error = false; error = false;
let diagramType: string | undefined;
try { try {
if (container) { if (container && state && (state.updateDiagram || state.autoSync)) {
if (!state.autoSync) {
$inputStateStore.updateDiagram = false;
}
outOfSync = false;
manualUpdate = true; manualUpdate = true;
// Do not render if there is no change in Code/Config/PanZoom // Do not render if there is no change in Code/Config/PanZoom
if ( if (code === state.code && config === state.mermaid && panZoomEnabled === state.panZoom) {
code === state.code &&
config === state.mermaid &&
rough === state.rough &&
panZoom === state.panZoom
) {
return; return;
} }
if (!shouldRefreshView()) {
return;
}
code = state.code; code = state.code;
config = state.mermaid; config = state.mermaid;
rough = state.rough; panZoomEnabled = state.panZoom;
panZoom = state.panZoom ?? true; const scroll = view.parentElement!.scrollTop;
if (mayContainFontAwesome(code)) {
await waitForFontAwesomeToLoad?.();
}
const scroll = view?.parentElement?.scrollTop;
delete container.dataset.processed; delete container.dataset.processed;
const viewID = uniqueID('graph-'); const { svg, bindFunctions } = await renderDiagram(
const { Object.assign({}, JSON.parse(state.mermaid)) as MermaidConfig,
svg, code,
bindFunctions, 'graph-div'
diagramType: detectedDiagramType );
} = await renderDiagram(JSON.parse(state.mermaid) as MermaidConfig, code, viewID);
diagramType = detectedDiagramType;
if (svg.length > 0) { if (svg.length > 0) {
handlePanZoom(state);
container.innerHTML = svg; container.innerHTML = svg;
let graphDiv = document.querySelector<SVGSVGElement>(`#${viewID}`); console.log({ svg });
const graphDiv = document.getElementById('graph-div');
if (!graphDiv) { if (!graphDiv) {
throw new Error('graph-div not found'); throw new Error('graph-div not found');
} }
if (state.rough) { graphDiv.setAttribute('height', '100%');
const svg2roughjs = new Svg2Roughjs('#container'); graphDiv.style.maxWidth = '100%';
svg2roughjs.svg = graphDiv; if (bindFunctions) {
await svg2roughjs.sketch(); bindFunctions(graphDiv);
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);
}
}
if (state.panZoom) {
handlePanZoom(state, graphDiv);
} }
} }
if (view?.parentElement && scroll) {
view.parentElement.scrollTop = scroll; view.parentElement!.scrollTop = scroll;
}
error = false; error = false;
} else if (manualUpdate) { } else if (manualUpdate) {
manualUpdate = false; manualUpdate = false;
} else if (code !== state.code || config !== state.mermaid) {
outOfSync = true;
} }
} catch (error_) { } catch (e) {
console.error('view fail', error_); console.error('view fail', e);
error = true; error = true;
} }
const renderTime = Date.now() - startTime;
saveStatistics({ code, diagramType, isRough: state.rough, renderTime });
recordRenderTime(renderTime, () => {
$inputStateStore.updateDiagram = true;
});
}; };
onMount(() => { onMount(() => {
setupPanZoomObserver();
// Queue state changes to avoid race condition
let pendingStateChange = Promise.resolve();
stateStore.subscribe((state) => { stateStore.subscribe((state) => {
pendingStateChange = pendingStateChange.then(() => handleStateChange(state).catch(() => {})); void handleStateChange(state);
});
window.addEventListener('resize', () => {
if ($stateStore.panZoom && pzoom) {
pzoom.resize();
}
}); });
}); });
</script> </script>
<FontAwesome bind:waitForFontAwesomeToLoad /> {#if (error && $stateStore.error instanceof Error) || outOfSync}
<div
class="absolute w-full p-2 z-10 font-mono {error
? 'text-red-600'
: 'text-yellow-600'} bg-base-100 bg-opacity-80 text-left"
id="errorContainer">
{#if error}
{@html $stateStore.error?.toString().replace(/\n/g, '<br />')}
{: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 <div id="view" bind:this={view} class="p-2 h-full" class:error class:outOfSync>
id="view" <div id="container" bind:this={container} class="h-full overflow-auto" class:hide />
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> </div>
<style> <style>
.grid-bg-light { #view {
background-size: 30px 30px; flex: 1;
background-image: radial-gradient(circle, #e4e4e48c 2px, #0000 2px);
} }
.grid-bg-dark { #container {
background-size: 30px 30px; transition: visibility 0.3s;
background-image: radial-gradient(circle, #46464646 2px, #0000 2px); }
.error,
.outOfSync {
opacity: 0.5;
}
.hide {
visibility: hidden;
} }
</style> </style>
@@ -1,69 +0,0 @@
<script lang="ts" module>
import type { WithElementRef } from 'bits-ui';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { type VariantProps, tv } from 'tailwind-variants';
export const buttonVariants = tv({
base: 'focus-visible:ring-ring inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-6 [&_svg]:shrink-0',
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/80',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90 shadow-sm',
outline:
'border-input bg-background hover:bg-accent hover:text-accent-foreground border shadow-sm',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-sm',
accent: 'bg-accent text-accent-foreground hover:bg-accent/80 shadow-sm',
ghost: 'hover:bg-primary hover:text-primary-foreground',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'size-8'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
});
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
import { cn } from '$lib/utils.js';
let {
class: className,
variant = 'default',
size = 'default',
ref = $bindable(null),
href,
type = 'button',
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a bind:this={ref} class={cn(buttonVariants({ variant, size }), className)} {href} {...restProps}>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
class={cn(buttonVariants({ variant, size }), className)}
{type}
{...restProps}>
{@render children?.()}
</button>
{/if}
-9
View File
@@ -1,9 +0,0 @@
export {
default as Button,
buttonVariants,
default as Root,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
type ButtonProps as Props
} from './button.svelte';
@@ -1,36 +0,0 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Dialog as DialogPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
import X from 'lucide-svelte/icons/x';
import type { Snippet } from 'svelte';
import * as Dialog from './index.js';
let {
ref = $bindable(null),
class: className,
portalProps,
children,
...restProps
}: WithoutChildrenOrChild<DialogPrimitive.ContentProps> & {
portalProps?: DialogPrimitive.PortalProps;
children: Snippet;
} = $props();
</script>
<Dialog.Portal {...portalProps}>
<Dialog.Overlay />
<DialogPrimitive.Content
bind:ref
class={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...restProps}>
{@render children?.()}
<DialogPrimitive.Close
class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:text-muted-foreground">
<X class="size-4" />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</Dialog.Portal>
@@ -1,15 +0,0 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.DescriptionProps = $props();
</script>
<DialogPrimitive.Description
bind:ref
class={cn('text-sm text-muted-foreground', className)}
{...restProps} />
@@ -1,19 +0,0 @@
<script lang="ts">
import type { WithElementRef } from 'bits-ui';
import type { HTMLAttributes } from 'svelte/elements';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
class={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...restProps}>
{@render children?.()}
</div>
@@ -1,19 +0,0 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import type { WithElementRef } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
</script>
<div
bind:this={ref}
class={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
{...restProps}>
{@render children?.()}
</div>
@@ -1,18 +0,0 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.OverlayProps = $props();
</script>
<DialogPrimitive.Overlay
bind:ref
class={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...restProps} />
@@ -1,15 +0,0 @@
<script lang="ts">
import { Dialog as DialogPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
...restProps
}: DialogPrimitive.TitleProps = $props();
</script>
<DialogPrimitive.Title
bind:ref
class={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...restProps} />
-36
View File
@@ -1,36 +0,0 @@
import { Dialog as DialogPrimitive } from 'bits-ui';
import Content from './dialog-content.svelte';
import Description from './dialog-description.svelte';
import Footer from './dialog-footer.svelte';
import Header from './dialog-header.svelte';
import Overlay from './dialog-overlay.svelte';
import Title from './dialog-title.svelte';
const Root: typeof DialogPrimitive.Root = DialogPrimitive.Root;
const Trigger: typeof DialogPrimitive.Trigger = DialogPrimitive.Trigger;
const Close: typeof DialogPrimitive.Close = DialogPrimitive.Close;
const Portal: typeof DialogPrimitive.Portal = DialogPrimitive.Portal;
export {
Close,
Content,
Description,
//
Root as Dialog,
Close as DialogClose,
Content as DialogContent,
Description as DialogDescription,
Footer as DialogFooter,
Header as DialogHeader,
Overlay as DialogOverlay,
Portal as DialogPortal,
Title as DialogTitle,
Trigger as DialogTrigger,
Footer,
Header,
Overlay,
Portal,
Root,
Title,
Trigger
};
-7
View File
@@ -1,7 +0,0 @@
import Root from './input.svelte';
export {
Root,
//
Root as Input
};
-43
View File
@@ -1,43 +0,0 @@
<script lang="ts">
import type { InputType } from '$/types';
import { cn } from '$lib/utils.js';
import type { WithElementRef } from 'bits-ui';
import type { HTMLInputAttributes } from 'svelte/elements';
type Props = WithElementRef<
Omit<HTMLInputAttributes, 'type'> &
({ type: 'file'; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
...restProps
}: Props = $props();
</script>
{#if type === 'file'}
<input
bind:this={ref}
class={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
type="file"
bind:files
bind:value
{...restProps} />
{:else}
<input
bind:this={ref}
class={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
className
)}
{type}
bind:value
{...restProps} />
{/if}
-17
View File
@@ -1,17 +0,0 @@
import { Popover as PopoverPrimitive } from 'bits-ui';
import Content from './popover-content.svelte';
const Root = PopoverPrimitive.Root;
const Trigger = PopoverPrimitive.Trigger;
const Close = PopoverPrimitive.Close;
export {
Root,
Content,
Trigger,
Close,
//
Root as Popover,
Content as PopoverContent,
Trigger as PopoverTrigger,
Close as PopoverClose
};
@@ -1,27 +0,0 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Popover as PopoverPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
align = 'center',
sideOffset = 4,
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
portalProps?: PopoverPrimitive.PortalProps;
} = $props();
</script>
<PopoverPrimitive.Portal {...portalProps}>
<PopoverPrimitive.Content
bind:ref
{align}
{sideOffset}
class={cn(
'z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className
)}
{...restProps} />
</PopoverPrimitive.Portal>
-13
View File
@@ -1,13 +0,0 @@
import { Pane } from 'paneforge';
import Handle from './resizable-handle.svelte';
import PaneGroup from './resizable-pane-group.svelte';
export {
PaneGroup,
Pane,
Handle,
//
PaneGroup as ResizablePaneGroup,
Pane as ResizablePane,
Handle as ResizableHandle
};
@@ -1,29 +0,0 @@
<script lang="ts">
import GripVertical from 'lucide-svelte/icons/grip-vertical';
import * as ResizablePrimitive from 'paneforge';
import type { WithoutChildrenOrChild } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
withHandle = false,
...restProps
}: WithoutChildrenOrChild<ResizablePrimitive.PaneResizerProps> & {
withHandle?: boolean;
} = $props();
</script>
<ResizablePrimitive.PaneResizer
bind:ref
class={cn(
'relative flex w-px items-center justify-center bg-border after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 data-[direction=vertical]:h-px data-[direction=vertical]:w-full data-[direction=vertical]:after:left-0 data-[direction=vertical]:after:h-1 data-[direction=vertical]:after:w-full data-[direction=vertical]:after:-translate-y-1/2 data-[direction=vertical]:after:translate-x-0 [&[data-direction=vertical]>div]:rotate-90',
className
)}
{...restProps}>
{#if withHandle}
<div class="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
<GripVertical class="size-2.5" />
</div>
{/if}
</ResizablePrimitive.PaneResizer>
@@ -1,21 +0,0 @@
<script lang="ts">
import * as ResizablePrimitive from 'paneforge';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
class: className,
direction,
this: paneGroup = $bindable(),
...restProps
}: ResizablePrimitive.PaneGroupProps & {
this?: ResizablePrimitive.PaneGroup;
} = $props();
</script>
<ResizablePrimitive.PaneGroup
bind:ref
bind:this={paneGroup}
{direction}
class={cn('flex h-full w-full data-[direction=vertical]:flex-col', className)}
{...restProps} />
-7
View File
@@ -1,7 +0,0 @@
import Root from './separator.svelte';
export {
Root,
//
Root as Separator
};
@@ -1,21 +0,0 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Separator as SeparatorPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
orientation = 'horizontal',
...restProps
}: SeparatorPrimitive.RootProps = $props();
</script>
<SeparatorPrimitive.Root
bind:ref
class={cn(
'shrink-0 rounded border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'min-h-6 w-[0px]',
className
)}
{orientation}
{...restProps} />
-1
View File
@@ -1 +0,0 @@
export { default as Toaster } from './sonner.svelte';
@@ -1,20 +0,0 @@
<script lang="ts">
import { Toaster as Sonner, type ToasterProps as SonnerProps } from 'svelte-sonner';
import { mode } from 'mode-watcher';
let restProps: SonnerProps = $props();
</script>
<Sonner
theme={$mode}
class="toaster group"
toastOptions={{
classes: {
toast:
'group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg',
description: 'group-[.toast]:text-muted-foreground',
actionButton: 'group-[.toast]:bg-primary group-[.toast]:text-primary-foreground',
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground'
}
}}
{...restProps} />
-7
View File
@@ -1,7 +0,0 @@
import Root from './switch.svelte';
export {
Root,
//
Root as Switch
};
@@ -1,25 +0,0 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import { Switch as SwitchPrimitive, type WithoutChildrenOrChild } from 'bits-ui';
let {
ref = $bindable(null),
checked = $bindable(false),
class: className,
...restProps
}: WithoutChildrenOrChild<SwitchPrimitive.RootProps> = $props();
</script>
<SwitchPrimitive.Root
bind:ref
bind:checked
class={cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-accent data-[state=unchecked]:bg-slate-700',
className
)}
{...restProps}>
<SwitchPrimitive.Thumb
class={cn(
'pointer-events-none block size-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0'
)} />
</SwitchPrimitive.Root>
@@ -1,10 +0,0 @@
import Root from './toggle-group.svelte';
import Item from './toggle-group-item.svelte';
export {
Root,
Item,
//
Root as ToggleGroup,
Item as ToggleGroupItem
};
@@ -1,29 +0,0 @@
<script lang="ts">
import { ToggleGroup as ToggleGroupPrimitive } from 'bits-ui';
import { getToggleGroupCtx } from './toggle-group.svelte';
import { cn } from '$lib/utils.js';
import { type ToggleVariants, toggleVariants } from '$lib/components/ui/toggle/index.js';
let {
ref = $bindable(null),
value = $bindable(),
class: className,
size,
variant,
...restProps
}: ToggleGroupPrimitive.ItemProps & ToggleVariants = $props();
const ctx = getToggleGroupCtx();
</script>
<ToggleGroupPrimitive.Item
bind:ref
class={cn(
toggleVariants({
variant: ctx.variant || variant,
size: ctx.size || size
}),
className
)}
{value}
{...restProps} />
@@ -1,40 +0,0 @@
<script lang="ts" module>
import { getContext, setContext } from 'svelte';
import type { ToggleVariants } from '$lib/components/ui/toggle/index.js';
export function setToggleGroupCtx(props: ToggleVariants) {
setContext('toggleGroup', props);
}
export function getToggleGroupCtx() {
return getContext<ToggleVariants>('toggleGroup');
}
</script>
<script lang="ts">
import { ToggleGroup as ToggleGroupPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
value = $bindable(),
class: className,
size = 'default',
variant = 'default',
...restProps
}: ToggleGroupPrimitive.RootProps & ToggleVariants = $props();
setToggleGroupCtx({
variant,
size
});
</script>
<!--
Discriminated Unions + Destructing (required for bindable) do not
get along, so we shut typescript up by casting `value` to `never`.
-->
<ToggleGroupPrimitive.Root
bind:value={value as never}
bind:ref
class={cn('flex items-center justify-center gap-1', className)}
{...restProps} />

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