Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a59fc4fe5 | ||
|
|
82e17f41aa | ||
|
|
84c6a85545 | ||
|
|
3bd5a640ec | ||
|
|
1cb42e0a88 | ||
|
|
da53dab766 | ||
|
|
9396ed376b | ||
|
|
4bc257bace | ||
|
|
395ea5b7f1 |
@@ -1,14 +0,0 @@
|
||||
**/node_modules
|
||||
**/.git
|
||||
**/.svelte-kit
|
||||
**/dist
|
||||
**/docs
|
||||
|
||||
**/.github
|
||||
**/.husky
|
||||
**/.vscode
|
||||
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
README.md
|
||||
@@ -1,9 +0,0 @@
|
||||
MERMAID_DOMAIN=''
|
||||
MERMAID_BASE_PATH=''
|
||||
MERMAID_DOCS_URL='https://mermaid.js.org'
|
||||
MERMAID_ANALYTICS_URL=''
|
||||
MERMAID_RENDERER_URL='https://mermaid.ink'
|
||||
MERMAID_KROKI_RENDERER_URL='https://kroki.io'
|
||||
MERMAID_IS_ENABLED_MERMAID_CHART_LINKS='true'
|
||||
|
||||
# cp .env .env.local to make local changes
|
||||
@@ -1,5 +0,0 @@
|
||||
# .git-blame-ignore-revs
|
||||
# Prettier Pass
|
||||
6fde9e22a3ba0b80879a1909fb233367ef20b738
|
||||
# Tabs -> Spaces
|
||||
0ac606b8fbf6f94215cbda1d4511e2dcd30e4655
|
||||
@@ -1,49 +0,0 @@
|
||||
name: Docker Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: gitea.forkless.com
|
||||
username: ${{ secrets.GITEAUSER }}
|
||||
password: ${{ secrets.GITEATOKEN }}
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: gitea.forkless.com/forkless/mermaid-live-editor
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ github.ref_name == 'master' }}
|
||||
type=raw,value=nightly,enable=${{ github.ref_name == 'develop' }}
|
||||
type=sha,prefix={{branch}}-
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
target: mermaid
|
||||
push: ${{ github.event_name == 'push' }}
|
||||
pull: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
MERMAID_RENDERER_URL=${{ vars.MERMAID_RENDERER_URL || 'https://mermaid.ink' }}
|
||||
MERMAID_KROKI_RENDERER_URL=${{ vars.MERMAID_KROKI_RENDERER_URL || 'https://kroki.io' }}
|
||||
MERMAID_IS_ENABLED_MERMAID_CHART_LINKS=false
|
||||
@@ -1 +0,0 @@
|
||||
github: [sidharthv96, knsv]
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Link to Live Editor:
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
@@ -1,19 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -1,7 +0,0 @@
|
||||
name: 'Mermaid CodeQL config'
|
||||
|
||||
paths:
|
||||
- src
|
||||
paths-ignore:
|
||||
- node_modules
|
||||
- '**/*.spec.js'
|
||||
@@ -1,23 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: docker
|
||||
directory: /
|
||||
target-branch: develop
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: friday
|
||||
time: '07:00'
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
target-branch: develop
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: friday
|
||||
time: '07:00'
|
||||
- package-ecosystem: npm
|
||||
directory: /
|
||||
target-branch: develop
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: friday
|
||||
time: '07:00'
|
||||
@@ -0,0 +1,34 @@
|
||||
name: Beta Deploy
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- none
|
||||
# - citest
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- name: Checkout docs repo
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
repository: mermaid-js/docs
|
||||
path: docs
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
path: main
|
||||
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '12'
|
||||
|
||||
- run: |
|
||||
cd main
|
||||
npm i -g yarn
|
||||
yarn install
|
||||
yarn release-beta
|
||||
git config user.name github-actions
|
||||
git config user.email github-actions@github.com
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
name: Close broken link issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
close-broken-link-issues:
|
||||
if: github.event.issue.state == 'open'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close issues with only "Broken link" in the title
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const title = context.payload.issue.title.trim();
|
||||
const isBrokenLinkOnlyTitle = /^broken link\.?$/i.test(title);
|
||||
|
||||
if (!isBrokenLinkOnlyTitle) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const issueNumber = context.payload.issue.number;
|
||||
const newIssueUrl = `https://github.com/${owner}/${repo}/issues/new?assignees=&labels=bug&template=bug_report.md&title=Broken%20link`;
|
||||
|
||||
const mermaidIssuesUrl = 'https://github.com/mermaid-js/mermaid/issues/new';
|
||||
|
||||
const commentBody = [
|
||||
`@${context.payload.issue.user.login} This issue was automatically closed because the title only contains "Broken link" without additional details.`,
|
||||
'',
|
||||
'**Please only open issues here if something is broken in the live editor itself** (e.g. a link fails to load, the editor UI misbehaves, or a feature of mermaid.live does not work).',
|
||||
'',
|
||||
'**Do not open issues here for syntax errors or invalid Mermaid diagram code.** Those belong in the main Mermaid repository: [mermaid-js/mermaid](https://github.com/mermaid-js/mermaid/issues/new).',
|
||||
'',
|
||||
'If you are reporting a live editor bug, please include:',
|
||||
'- The full URL that failed to load',
|
||||
'- What you expected to happen',
|
||||
'- Any error messages you saw',
|
||||
'',
|
||||
`You can [open a new issue in this repo](${newIssueUrl}) with this information, or report diagram syntax issues in the [Mermaid repo](${mermaidIssuesUrl}).`
|
||||
].join('\n');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
body: commentBody
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned'
|
||||
});
|
||||
@@ -9,14 +9,14 @@
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: 'CodeQL'
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
branches: [ master ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [master]
|
||||
branches: [ master ]
|
||||
schedule:
|
||||
- cron: '36 1 * * 2'
|
||||
|
||||
@@ -28,41 +28,40 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ['javascript']
|
||||
language: [ 'javascript' ]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
|
||||
# Learn more:
|
||||
# https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
config-file: ./.github/codeql/config.yml
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v1
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
# - name: Autobuild
|
||||
# uses: github/codeql-action/autobuild@v1
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v1
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 https://git.io/JvXDl
|
||||
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
# ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
|
||||
# and modify them (or add more) to build your code if your project
|
||||
# uses a compiled language
|
||||
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
#- run: |
|
||||
# make bootstrap
|
||||
# make release
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Deploy Live Editor Beta
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- citest
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '12'
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Build & Deploy
|
||||
run: |
|
||||
npm i -g yarn
|
||||
yarn install
|
||||
yarn release-beta
|
||||
git config user.name github-actions
|
||||
git config user.email github-actions@github.com
|
||||
git add ./docs/*
|
||||
git commit -m "Release Beta"
|
||||
git push
|
||||
|
||||
@@ -2,61 +2,26 @@ name: Deploy Live Editor
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
- release
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: '12'
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
cache: pnpm
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
with:
|
||||
static_site_generator: sveltekit
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Build & Deploy
|
||||
env:
|
||||
MERMAID_DOMAIN: 'mermaid.live'
|
||||
MERMAID_ANALYTICS_URL: 'https://p.mermaid.live'
|
||||
MERMAID_RENDERER_URL: 'https://mermaid.ink'
|
||||
MERMAID_KROKI_RENDERER_URL: 'https://kroki.io'
|
||||
MERMAID_IS_ENABLED_MERMAID_CHART_LINKS: 'true'
|
||||
run: |
|
||||
export DEPLOY=true
|
||||
[ "$GITHUB_EVENT_NAME" != "pull_request" ] && rm -rf docs/_app/
|
||||
pnpm install
|
||||
pnpm build
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: ./docs
|
||||
|
||||
deploy:
|
||||
if: ${{ github.ref == 'refs/heads/master' }}
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
# Grant GITHUB_TOKEN the permissions required to make a Pages deployment
|
||||
permissions:
|
||||
pages: write # to deploy to Pages
|
||||
id-token: write # to verify the deployment originates from an appropriate source
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
npm i -g yarn
|
||||
yarn install
|
||||
yarn release
|
||||
git config user.name github-actions
|
||||
git config user.email github-actions@github.com
|
||||
git add ./docs/*
|
||||
git commit -m "Release"
|
||||
git push
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
# Publish `master` as Docker `latest` image.
|
||||
- master
|
||||
# Publish `develop` as Docker `nightly` image.
|
||||
- develop
|
||||
|
||||
# Run tests for all PRs to master and develop.
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: docker/metadata-action@v5
|
||||
id: meta
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable=${{ github.ref_name == 'master' }}
|
||||
type=raw,value=nightly,enable=${{ github.ref_name == 'develop' }}
|
||||
- uses: docker/build-push-action@v5
|
||||
id: build
|
||||
with:
|
||||
context: .
|
||||
target: mermaid
|
||||
push: ${{ github.event_name == 'push' }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
pull: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
- name: Generate Build Attestation
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-name: ghcr.io/${{ github.repository }}
|
||||
subject-digest: ${{ steps.build.outputs.digest }}
|
||||
push-to-registry: true
|
||||
if: github.event_name == 'push'
|
||||
@@ -1,21 +0,0 @@
|
||||
name: Create release pull request
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
jobs:
|
||||
productionPromotion:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
ref: master
|
||||
- name: Reset promotion branch
|
||||
run: |
|
||||
git fetch origin develop:develop
|
||||
git reset --hard develop
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v5
|
||||
with:
|
||||
branch: release-promotion
|
||||
title: Release live editor
|
||||
@@ -1,55 +0,0 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
merge_group:
|
||||
|
||||
jobs:
|
||||
playwright:
|
||||
name: 'Playwright Tests'
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.60.0-jammy
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/cache@v4
|
||||
id: pnpm-and-build-cache
|
||||
with:
|
||||
path: |
|
||||
build
|
||||
node_modules
|
||||
key: ${{ runner.os }}-node_modules-build-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node_modules-build-
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: pnpm test:e2e
|
||||
env:
|
||||
CI: true
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
name: test-results
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
@@ -1,46 +0,0 @@
|
||||
name: Unit Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- develop
|
||||
merge_group:
|
||||
|
||||
jobs:
|
||||
unit-tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- uses: actions/cache@v3
|
||||
id: pnpm-and-build-cache
|
||||
with:
|
||||
path: |
|
||||
build
|
||||
node_modules
|
||||
key: ${{ runner.os }}-node_modules-build-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node_modules-build-
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Type check
|
||||
run: pnpm check
|
||||
|
||||
- name: Run unit tests
|
||||
run: pnpm test:unit
|
||||
@@ -1,32 +0,0 @@
|
||||
name: Update Browserslist database
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 2 1,15 * *'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
update-browserslist-database:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config --global user.email "action@github.com"
|
||||
git config --global user.name "GitHub Action"
|
||||
- name: Update Browserslist database and create PR if applies
|
||||
uses: c2corg/browserslist-update-action@v2
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: browserslist-update
|
||||
base_branch: develop
|
||||
commit_message: 'build: update Browserslist db'
|
||||
title: 'build: update Browserslist db'
|
||||
body: Auto-generated by [browserslist-update-action](https://github.com/c2corg/browserslist-update-action/)
|
||||
labels: 'chores, github action'
|
||||
+4
-16
@@ -1,18 +1,6 @@
|
||||
.DS_Store
|
||||
node_modules/
|
||||
coverage/
|
||||
.cache/
|
||||
.env.local
|
||||
build/
|
||||
yarn-error.log
|
||||
.npmrc
|
||||
/.svelte-kit
|
||||
/build
|
||||
/coverage
|
||||
/docs
|
||||
/functions
|
||||
/node_modules
|
||||
/snapshots.js
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
_
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/sh
|
||||
. "$(dirname "$0")/_/husky.sh"
|
||||
|
||||
pnpm pre-commit
|
||||
@@ -1 +0,0 @@
|
||||
24.16.0
|
||||
Binary file not shown.
+1
-9
@@ -1,9 +1 @@
|
||||
docs/**
|
||||
.svelte-kit/**
|
||||
static/**
|
||||
build/**
|
||||
node_modules/**
|
||||
coverage/**
|
||||
__snapshots__/**
|
||||
snapshots.js
|
||||
pnpm-lock.yaml
|
||||
docs
|
||||
+2
-8
@@ -1,9 +1,3 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"svelteSortOrder": "options-scripts-markup-styles",
|
||||
"bracketSameLine": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||
"tailwindStylesheet": "./src/app.css"
|
||||
}
|
||||
"singleQuote": true
|
||||
}
|
||||
Vendored
-15
@@ -1,15 +0,0 @@
|
||||
{
|
||||
// Use IntelliSense to learn about possible attributes.
|
||||
// Hover to view descriptions of existing attributes.
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "pwa-chrome",
|
||||
"request": "launch",
|
||||
"name": "Launch Chrome against localhost",
|
||||
"url": "http://localhost:3000",
|
||||
"webRoot": "${workspaceFolder}"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
-10
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"servers": {
|
||||
"svelte": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "@sveltejs/mcp"]
|
||||
}
|
||||
},
|
||||
"inputs": []
|
||||
}
|
||||
Vendored
-59
@@ -1,59 +0,0 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"cSpell.blockCheckingWhenLineLengthGreaterThan": 150,
|
||||
"cSpell.words": [
|
||||
"appinstalled",
|
||||
"asyncable",
|
||||
"Browserslist",
|
||||
"ckppp",
|
||||
"corg",
|
||||
"cssnano",
|
||||
"esserializer",
|
||||
"fsegurai",
|
||||
"gantt",
|
||||
"gitgraph",
|
||||
"hugeicons",
|
||||
"KROKI",
|
||||
"localstorage",
|
||||
"mermaidchart",
|
||||
"mindmap",
|
||||
"NEWYEAR",
|
||||
"noopener",
|
||||
"noreferrer",
|
||||
"Pageview",
|
||||
"pako",
|
||||
"panmove",
|
||||
"panstart",
|
||||
"panzoom",
|
||||
"pinchmove",
|
||||
"pinchstart",
|
||||
"pzoom",
|
||||
"roughjs",
|
||||
"sankey",
|
||||
"Serde",
|
||||
"serdes",
|
||||
"Stackable",
|
||||
"tailwindcss",
|
||||
"treemap",
|
||||
"uparrow",
|
||||
"xychart",
|
||||
"zenuml"
|
||||
],
|
||||
"vitest.commandLine": "pnpm test:unit",
|
||||
"vitest.enable": true,
|
||||
"testing.autoRun.mode": "rerun",
|
||||
"svelte.enable-ts-plugin": true,
|
||||
"githubPullRequests.ignoredPullRequestBranches": ["develop"],
|
||||
"[svelte]": {
|
||||
"editor.defaultFormatter": "svelte.svelte-vscode"
|
||||
},
|
||||
"tailwindCSS.classAttributes": ["class", "className", ".*Classes"],
|
||||
"tailwindCSS.experimental.classRegex": [
|
||||
["([\"'`][^\"'`]*.*?[\"'`])", "[\"'`]([^\"'`]*).*?[\"'`]"]
|
||||
],
|
||||
"tailwindCSS.emmetCompletions": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": "explicit",
|
||||
"source.organizeImports": "explicit"
|
||||
}
|
||||
}
|
||||
+15
-34
@@ -1,37 +1,18 @@
|
||||
FROM docker.io/library/node:24.16.0-alpine3.22 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 : docker run --name mermaid-live-editor --publish 8080:80 mermaid-js/mermaid-live-editor
|
||||
# Start : docker start mermaid-live-editor
|
||||
# Use webbrowser : http://localhost:8080
|
||||
# Stop : press ctrl + c
|
||||
# or
|
||||
# docker stop mermaid-live-editor
|
||||
|
||||
RUN apk --no-cache add build-base git python3 && \
|
||||
rm -rf /var/cache/apk/*
|
||||
|
||||
RUN corepack enable pnpm
|
||||
FROM node:13.6.0-alpine as mermaid-live-editor-builder
|
||||
COPY --chown=node:node . /home
|
||||
WORKDIR /home
|
||||
RUN yarn install
|
||||
RUN yarn build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./package.json .
|
||||
COPY ./pnpm-lock.yaml .
|
||||
|
||||
RUN pnpm install
|
||||
|
||||
FROM mermaid-live-editor-dependencies AS mermaid-live-editor-builder
|
||||
|
||||
ARG MERMAID_RENDERER_URL
|
||||
ARG MERMAID_KROKI_RENDERER_URL
|
||||
ARG MERMAID_ANALYTICS_URL
|
||||
ARG MERMAID_DOMAIN
|
||||
ARG MERMAID_IS_ENABLED_MERMAID_CHART_LINKS
|
||||
ARG MERMAID_PRIVACY_POLICY_URL
|
||||
ARG MERMAID_HIDE_PRIVACY_POLICY
|
||||
ARG MERMAID_BASE_PATH
|
||||
|
||||
COPY . ./
|
||||
|
||||
RUN pnpm build
|
||||
|
||||
FROM mermaid-live-editor-builder AS mermaid-dev
|
||||
|
||||
ENTRYPOINT ["pnpm", "dev"]
|
||||
|
||||
FROM nginx:1.28-alpine3.21 AS mermaid
|
||||
|
||||
COPY ./nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=mermaid-live-editor-builder /app/docs /usr/share/nginx/html
|
||||
FROM nginx:alpine as mermaid-live-editor-runner
|
||||
COPY --from=mermaid-live-editor-builder --chown=nginx:nginx /home/docs /usr/share/nginx/html
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 - 2023 Knut Sveidqvist
|
||||
Copyright (c) 2020 - 2021 Knut Sveidqvist
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
[](https://discord.gg/sKeNQX4Wtj)
|
||||
[](https://app.netlify.com/sites/mermaidjs/deploys)
|
||||
[](https://join.slack.com/t/mermaid-talk/shared_invite/enQtNzc4NDIyNzk4OTAyLWVhYjQxOTI2OTg4YmE1ZmJkY2Y4MTU3ODliYmIwOTY3NDJlYjA0YjIyZTdkMDMyZTUwOGI0NjEzYmEwODcwOTE)
|
||||
|
||||
# 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.
|
||||
|
||||
|
||||
## Features
|
||||
|
||||
- Edit and preview flowcharts, sequence diagrams, gantt diagrams in real time.
|
||||
@@ -12,110 +16,46 @@ Edit, preview and share mermaid charts/diagrams.
|
||||
- Get a link to a viewer of the diagram so that you can share it with others.
|
||||
- Get a link to edit the diagram so that someone else can tweak it and send a new link back
|
||||
|
||||
|
||||
## Live demo
|
||||
|
||||
You can try out a [live version](https://mermaid.live/).
|
||||
|
||||
# Contributors are welcome!
|
||||
|
||||
If you want to speed up the progress for mermaid-live-editor, join the Discord channel and contact knsv.
|
||||
|
||||
## Docker
|
||||
|
||||
### Run published image
|
||||
|
||||
```bash
|
||||
docker run --platform linux/amd64 --publish 8000:8080 ghcr.io/mermaid-js/mermaid-live-editor
|
||||
```
|
||||
|
||||
The published docker image is built using our default environment variables. You cannot override them when running the image. If you need to customize them, you will need to build the image yourself.
|
||||
|
||||
### To configure renderer URL
|
||||
|
||||
When building set the MERMAID_RENDERER_URL build argument to the rendering
|
||||
service.
|
||||
Example:
|
||||
Default is`https://mermaid.ink`.
|
||||
Set to empty string to disable PNG and SVG links under Actions
|
||||
|
||||
### To configure Kroki Instance URL
|
||||
|
||||
When building set the MERMAID_KROKI_RENDERER_URL build argument to your Kroki
|
||||
instance.
|
||||
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
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Then open http://localhost:3000
|
||||
|
||||
### Building and running images locally
|
||||
|
||||
#### Build
|
||||
|
||||
```bash
|
||||
docker build -t mermaid-js/mermaid-live-editor .
|
||||
```
|
||||
|
||||
#### Run
|
||||
|
||||
```bash
|
||||
docker run --detach --name mermaid-live-editor --publish 8080:8080 mermaid-js/mermaid-live-editor
|
||||
```
|
||||
|
||||
Visit: <http://localhost:8080>
|
||||
|
||||
#### Stop
|
||||
|
||||
```bash
|
||||
docker stop mermaid-live-editor
|
||||
```
|
||||
You can try out a live version [here](https://mermaid-js.github.io/mermaid-live-editor/).
|
||||
|
||||
## Setup
|
||||
|
||||
Below link will help you making a copy of the repository in your local system.
|
||||
This project is set up using [Yarn](https://classic.yarnpkg.com/en/docs/getting-started):
|
||||
|
||||
https://docs.github.com/en/get-started/quickstart/fork-a-repo
|
||||
```
|
||||
yarn install
|
||||
```
|
||||
|
||||
## Requirements
|
||||
or in develop branch to use the beta version of mermaid use
|
||||
|
||||
```
|
||||
yarn install --update-checksums
|
||||
```
|
||||
|
||||
This together with a .npmrc file:
|
||||
|
||||
```
|
||||
registry=https://registry.npmjs.com/
|
||||
@mermaid-js:registry=https://npm.pkg.github.com
|
||||
//npm.pkg.github.com/:_authToken="XXXXXXXXX"
|
||||
```
|
||||
|
||||
- [Node.js](https://nodejs.org/en/) current LTS version
|
||||
- [pnpm](https://pnpm.io/) package manager. Install with `corepack enable pnpm`
|
||||
|
||||
## Development
|
||||
|
||||
```sh
|
||||
pnpm install
|
||||
pnpm dev -- --open
|
||||
```
|
||||
yarn dev
|
||||
open http://localhost:8080
|
||||
```
|
||||
|
||||
This app is created with Svelte Kit.
|
||||
This app is created with Svelte + svelte-spa-router.
|
||||
|
||||
|
||||
## Release
|
||||
|
||||
When a PR is created targeting master, it will be built and deployed by Netlify.
|
||||
The URL will be indicated in a Comment in the PR.
|
||||
|
||||
Once the PR is merged, it will automatically be released.
|
||||
```
|
||||
yarn release
|
||||
```
|
||||
|
||||
-13
@@ -1,13 +0,0 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
Live editor only has a single version, which will be maintained.
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| latest | :white_check_mark: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
To report a vulnerability, please e-mail security@mermaid.live with a description of the issue, the steps you took to create the issue, affected versions, and if known, mitigations for the issue.
|
||||
+3
-10
@@ -4,18 +4,11 @@
|
||||
|
||||
# git clone git@github.com:mermaid-js/docs.git
|
||||
# git clone https://github.com/mermaid-js/docs.git
|
||||
set -e
|
||||
rm -rf docs
|
||||
pnpm release
|
||||
|
||||
yarn release
|
||||
pushd .
|
||||
if [ ! -d ../docs ]; then
|
||||
echo "Clone https://github.com/mermaid-js/docs to parent folder before continuing."
|
||||
exit
|
||||
fi
|
||||
rm -rf ../docs/mermaid-live-editor-beta/*
|
||||
cp -r docs/* ../docs/mermaid-live-editor-beta
|
||||
cd ../docs
|
||||
git add .
|
||||
git commit -m "Beta updated with latest changes"
|
||||
git commit * -m "Beta updated with latest changes"
|
||||
git push
|
||||
popd
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/bin/bash
|
||||
pushd ../docs
|
||||
# find ./ -type f -exec sed -i 's/service-worker.js/mermaid-live-editor\/service-worker.js/g' {} \;
|
||||
find ./ -type f -exec sed -i 's/manifest.json/\/mermaid-live-editor\/manifest.json/g' {} \;
|
||||
find ./ -type f -exec sed -i 's/favicon.png/\/mermaid-live-editor\/favicon.png/g' {} \;
|
||||
popd
|
||||
@@ -1,45 +0,0 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { parse } from 'node-html-parser';
|
||||
import prettier from 'prettier';
|
||||
|
||||
// parse monaco version out of package.json
|
||||
const packageJson = JSON.parse(fs.readFileSync('package.json'));
|
||||
const monacoVersion = packageJson.dependencies['monaco-editor'].replace('^', '');
|
||||
|
||||
// fetch monaco sri info from cdnjs api
|
||||
const cdnjsAPIResp = await fetch(
|
||||
`https://api.cdnjs.com/libraries/monaco-editor/${monacoVersion}?fields=sri`
|
||||
);
|
||||
if (cdnjsAPIResp.ok) {
|
||||
const respJson = await cdnjsAPIResp.json();
|
||||
const htmlPath = path.join('src', 'app.html');
|
||||
const appHtml = fs
|
||||
.readFileSync(htmlPath, 'utf8')
|
||||
// update monaco version of every asset in app.html
|
||||
.replaceAll(/[0-9.]+\/min\/vs/g, `${monacoVersion}/min/vs`);
|
||||
const root = parse(appHtml);
|
||||
const updateIntegrity = (tag, attr) => {
|
||||
for (const node of root
|
||||
.getElementsByTagName(tag)
|
||||
.filter((node) => node.getAttribute(attr)?.includes('monaco-editor'))) {
|
||||
const file = node.getAttribute(attr).split(`${monacoVersion}/`)[1];
|
||||
node.setAttribute('integrity', respJson.sri[file]);
|
||||
}
|
||||
};
|
||||
|
||||
updateIntegrity('script', 'src');
|
||||
updateIntegrity('link', 'href');
|
||||
|
||||
fs.writeFileSync(
|
||||
htmlPath,
|
||||
prettier.format(root.toString(), {
|
||||
singleQuote: false,
|
||||
parser: 'html',
|
||||
bracketSameLine: true,
|
||||
useTabs: true
|
||||
})
|
||||
);
|
||||
} else {
|
||||
throw Error('Unable to fetch monaco sri data from cdnjs api.');
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"$schema": "https://next.shadcn-svelte.com/schema.json",
|
||||
"tailwind": {
|
||||
"css": "src/app.css",
|
||||
"baseColor": "slate"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://tw3.shadcn-svelte.com/registry/new-york"
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
version: '3.7'
|
||||
services:
|
||||
mermaid:
|
||||
build:
|
||||
context: .
|
||||
target: mermaid-dev
|
||||
volumes:
|
||||
- ./src:/app/src
|
||||
ports:
|
||||
- 3000:3000
|
||||
- 24678:24678
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+2481
File diff suppressed because it is too large
Load Diff
+419
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
+134
@@ -0,0 +1,134 @@
|
||||
@media (prefers-color-scheme: light) {
|
||||
html, body {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#dgraph-div {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body {
|
||||
color: #333;
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgb(0,100,200);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: rgb(0,80,160);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
|
||||
input, button, select, textarea {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
padding: 0.4em;
|
||||
margin: 0 0 0.5em 0;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #f4f4f4;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
button:active {
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
button:focus {
|
||||
border-color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #3f3f3f;
|
||||
color: #c8c8c8;
|
||||
margin: 0;
|
||||
padding: 8px;
|
||||
box-sizing: border-box;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
|
||||
}}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html, body {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
|
||||
a {
|
||||
color: rgb(0,100,200);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: rgb(0,80,160);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
input, button, select, textarea {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
padding: 0.4em;
|
||||
margin: 0 0 0.5em 0;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
input:disabled {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
background-color: #f4f4f4;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
button:active {
|
||||
background-color: #ddd;
|
||||
}
|
||||
|
||||
button:focus {
|
||||
border-color: #666;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset='utf8'>
|
||||
<meta name='viewport' content='width=device-width'>
|
||||
|
||||
<title>Mermaid live editor</title>
|
||||
|
||||
<link rel='icon' type='image/png' href='favicon.png'>
|
||||
<link rel='stylesheet' href='styles.css'>
|
||||
<link rel='stylesheet' href='bundle.css'>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
|
||||
<!-- Google Analytics -->
|
||||
<script>
|
||||
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
|
||||
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
|
||||
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
|
||||
})(window,document,'script','https://www.google-analytics.com/analytics.js','ga');
|
||||
|
||||
ga('create', 'UA-153180559-1', 'auto');
|
||||
</script>
|
||||
<!-- End Google Analytics -->
|
||||
|
||||
<style>
|
||||
.myInlineDecoration {
|
||||
/* cursor: pointer; */
|
||||
/* text-decoration: underline; */
|
||||
font-weight: bold;
|
||||
font-style: oblique;
|
||||
background: #ffcccc;
|
||||
}
|
||||
#dgraph-div {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
||||
<title>Online FlowChart & Diagrams Editor - Mermaid Live Editor</title>
|
||||
<meta name="og:image" content="https://mermaid-js.github.io/images/header.png">
|
||||
<link rel="canonical" href="https://mermaid-js.github.io/mermaid-live-editor/">
|
||||
<meta name="description" content="Simplify documentation and avoid heavy tools. Open source Visio Alternative. Commonly used for explaining your code! Mermaid is a simple markdown-like script language for generating charts from text via javascript.">
|
||||
</head>
|
||||
<body>
|
||||
<script src='bundle.js'></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,43 @@
|
||||
html {
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
padding: 32px;
|
||||
font-family: 'trebuchet ms', verdana, arial;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* @media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #3f3f3f;
|
||||
color: #c8c8c8;
|
||||
}
|
||||
a, label {
|
||||
color: #d8d8d8;
|
||||
}
|
||||
} */
|
||||
.mermaid {
|
||||
/* font-family: 'trebuchet ms', verdana, arial; */
|
||||
}
|
||||
.marketing-links {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.ant-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
.mermaidTooltip {
|
||||
position: absolute;
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import { includeIgnoreFile } from '@eslint/compat';
|
||||
import js from '@eslint/js';
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import sortKeysPlugin from 'eslint-plugin-sort-keys';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import eslintPluginUnicorn from 'eslint-plugin-unicorn';
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import globals from 'globals';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import ts from 'typescript-eslint';
|
||||
import svelteConfig from './svelte.config.js';
|
||||
|
||||
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
||||
|
||||
export default defineConfig(
|
||||
includeIgnoreFile(gitignorePath),
|
||||
js.configs.recommended,
|
||||
...ts.configs.strict,
|
||||
...ts.configs.stylistic,
|
||||
...svelte.configs.recommended,
|
||||
prettier,
|
||||
...svelte.configs.prettier,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser, ...globals.node }
|
||||
},
|
||||
rules: {
|
||||
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
||||
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
||||
'no-undef': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
plugins: {
|
||||
unicorn: eslintPluginUnicorn
|
||||
},
|
||||
rules: {
|
||||
'unicorn/no-null': 'off',
|
||||
'unicorn/filename-case': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['src/**'],
|
||||
plugins: {
|
||||
'sort-keys': sortKeysPlugin
|
||||
},
|
||||
rules: {
|
||||
'sort-keys/sort-keys-fix': ['error', 'asc', { minKeys: 5 }]
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
extraFileExtensions: ['.svelte'],
|
||||
parser: ts.parser,
|
||||
svelteConfig
|
||||
}
|
||||
},
|
||||
rules: {
|
||||
'svelte/no-unused-props': 'off'
|
||||
}
|
||||
},
|
||||
{
|
||||
files: ['**/components/ui/**'],
|
||||
rules: {
|
||||
'unicorn/prefer-export-from': 'off',
|
||||
'unicorn/prevent-abbreviations': 'off',
|
||||
'unicorn/explicit-length-check': 'off',
|
||||
'sort-keys/sort-keys-fix': 'off'
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -1,13 +0,0 @@
|
||||
[build.environment]
|
||||
MERMAID_ANALYTICS_URL = 'https://p.mermaid.live'
|
||||
MERMAID_DOCS_URL = 'https://mermaid.js.org'
|
||||
MERMAID_DOMAIN = 'mermaid.live'
|
||||
MERMAID_RENDERER_URL = 'https://mermaid.ink'
|
||||
MERMAID_KROKI_RENDERER_URL = 'https://kroki.io'
|
||||
MERMAID_IS_ENABLED_MERMAID_CHART_LINKS ='true'
|
||||
|
||||
[[redirects]]
|
||||
from = "/index.html"
|
||||
to = "/edit"
|
||||
status = 301
|
||||
force = true
|
||||
@@ -1,9 +0,0 @@
|
||||
server {
|
||||
listen 8080;
|
||||
server_name mermaid;
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
# Fallback to index.html for other paths
|
||||
try_files $uri /index.html;
|
||||
}
|
||||
}
|
||||
+37
-129
@@ -1,139 +1,47 @@
|
||||
{
|
||||
"name": "mermaid-live-editor",
|
||||
"version": "2.0.67",
|
||||
"type": "module",
|
||||
"version": "1.2.0",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"dev:force": "MERMAID_LOCAL=true pnpm dev --force",
|
||||
"dev:test": "pnpm dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "prettier --check --cache . && eslint .",
|
||||
"lint:fix": "prettier --write --cache . && eslint --fix .",
|
||||
"format": "prettier --write --cache .",
|
||||
"pre-commit": "lint-staged",
|
||||
"postinstall": "husky install && svelte-kit sync && (git config blame.ignoreRevsFile .git-blame-ignore-revs || true)",
|
||||
"test:unit": "vitest",
|
||||
"test:unit:ui": "vitest --ui",
|
||||
"test:unit:coverage": "vitest run --coverage",
|
||||
"test": "pnpm test:unit && pnpm test:e2e",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:debug": "playwright test --debug"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^2.1.0",
|
||||
"@eslint/eslintrc": "^3.3.5",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@fortawesome/fontawesome-free": "^7.2.0",
|
||||
"@iconify-json/hugeicons": "^1.2.29",
|
||||
"@iconify-json/logos": "^1.2.11",
|
||||
"@iconify-json/material-symbols": "^1.2.79",
|
||||
"@iconify-json/mdi": "^1.2.3",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.63.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@types/hammerjs": "^2.0.46",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/pako": "2.0.4",
|
||||
"@vitest/coverage-v8": "^4.1.9",
|
||||
"@vitest/ui": "^4.1.9",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"bits-ui": "^2.18.1",
|
||||
"c8": "11.0.0",
|
||||
"chai": "^6.2.2",
|
||||
"clsx": "^2.1.1",
|
||||
"cssnano": "^8.0.2",
|
||||
"dotenv": "^17.4.2",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-es": "^4.1.0",
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-sort-keys": "^2.3.5",
|
||||
"eslint-plugin-svelte": "^3.19.0",
|
||||
"eslint-plugin-tailwindcss": "^3.18.3",
|
||||
"eslint-plugin-unicorn": "^65.0.1",
|
||||
"globals": "^17.6.0",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^29.1.1",
|
||||
"lint-staged": "^17.0.7",
|
||||
"lucide-svelte": "^1.0.1",
|
||||
"node-html-parser": "^7.1.0",
|
||||
"paneforge": "1.0.2",
|
||||
"prettier": "^3.8.4",
|
||||
"prettier-plugin-svelte": "^4.1.1",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"svelte": "^5.56.3",
|
||||
"svelte-check": "^4.6.0",
|
||||
"svelte-preprocess": "^6.0.5",
|
||||
"svelte-sonner": "^1.1.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"tslib": "^2.8.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-eslint": "^8.60.1",
|
||||
"unplugin-icons": "^23.0.1",
|
||||
"vite": "^8.0.16",
|
||||
"vite-plugin-devtools-json": "^1.0.0",
|
||||
"vitest": "^4.1.9",
|
||||
"vitest-dom": "^0.1.1"
|
||||
"cross-env": "^5.2.0",
|
||||
"css-loader": "^2.1.1",
|
||||
"husky": "^4.3.0",
|
||||
"lint-staged": "^10.5.2",
|
||||
"mini-css-extract-plugin": "^0.6.0",
|
||||
"prettier": "^2.2.1",
|
||||
"prettier-plugin-svelte": "^1.4.1",
|
||||
"serve": "^11.0.0",
|
||||
"style-loader": "^0.23.1",
|
||||
"svelte": "^3.0.0",
|
||||
"svelte-loader": "2.13.3",
|
||||
"svelte-watch-resize": "^1.0.3",
|
||||
"webpack": "^4.30.0",
|
||||
"webpack-cli": "^3.3.0",
|
||||
"webpack-dev-server": "^3.3.1"
|
||||
},
|
||||
"scripts": {
|
||||
"release": "cross-env NODE_ENV=production webpack -p",
|
||||
"release-beta": "yarn release --output-path ./docs/beta",
|
||||
"build": "cross-env NODE_ENV=production webpack",
|
||||
"dev": "webpack-dev-server --content-base docs",
|
||||
"pretty": "prettier --write ./src/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/lang-yaml": "^6.1.3",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.43.1",
|
||||
"@fontsource-variable/recursive": "^5.2.8",
|
||||
"@fsegurai/codemirror-theme-vscode-dark": "^6.2.6",
|
||||
"@fsegurai/codemirror-theme-vscode-light": "^6.2.6",
|
||||
"@mermaid-js/examples": "^1.2.0",
|
||||
"@mermaid-js/layout-elk": "^0.2.1",
|
||||
"@mermaid-js/layout-tidy-tree": "^0.2.2",
|
||||
"@mermaid-js/mermaid-zenuml": "^0.2.3",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.21",
|
||||
"hammerjs": "^2.0.8",
|
||||
"js-base64": "3.7.8",
|
||||
"lodash-es": "^4.18.1",
|
||||
"mermaid": "^11.15.0",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"monaco-editor": "0.55.1",
|
||||
"pako": "2.1.0",
|
||||
"plausible-tracker": "^0.3.9",
|
||||
"random-word-slugs": "0.1.7",
|
||||
"svg-pan-zoom": "3.6.2",
|
||||
"svg2roughjs": "^3.2.3",
|
||||
"uuid": "14.0.0"
|
||||
"mermaid": "8.8.4",
|
||||
"js-base64": "^2.5.1",
|
||||
"json-lint": "^0.1.0",
|
||||
"jsonlint": "^1.6.3",
|
||||
"moment": "^2.24.0",
|
||||
"monaco-editor": "0.18.1",
|
||||
"svelte-routing": "^1.4.0",
|
||||
"svelte-spa-router": "^1.3.0"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "lint-staged"
|
||||
}
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,svelte,js,css,md,json}": [
|
||||
"prettier --plugin-search-dir=. --write",
|
||||
"eslint "
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.16.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.34.4+sha512.8768be55200ae3f2226b6527fcca2687e14bc4e5f12d7721a0f25da3df47915177058648db4177baf348120fa0ba2752d8d8d93f6beaf1fe64ae18da8de961af",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"deasync",
|
||||
"esbuild",
|
||||
"svelte-preprocess"
|
||||
],
|
||||
"ignoredBuiltDependencies": [
|
||||
"vue-demi"
|
||||
]
|
||||
"*.{ts,svelte,js,css,md}": "prettier --plugin-search-dir=. --write"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
Generated
-7273
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,13 @@
|
||||
## :bookmark_tabs: Summary
|
||||
|
||||
Brief description about the content of your PR:
|
||||
|
||||
Resolves #<your issue id here>
|
||||
|
||||
## :straight_ruler: Design Decisions
|
||||
|
||||
Describe the way your implementation works or what design decisions you made if applicable:
|
||||
|
||||
### :clipboard: Tasks
|
||||
|
||||
Make sure you
|
||||
|
||||
- [ ] :book: have read the [contribution guidelines](https://mermaid.js.org/community/contributing.html)
|
||||
- [ ] :computer: have added unit/e2e tests (if appropriate)
|
||||
- [ ] :bookmark: targeted `develop` branch
|
||||
- [ ] :book: have read the [contribution guidelines](https://github.com/mermaid-js/mermaid/blob/develop/CONTRIBUTING.md)
|
||||
- [ ] :computer: have added unit/e2e tests (if appropriate)
|
||||
- [ ] :bookmark: targeted `master` branch
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"extends": [
|
||||
"config:base",
|
||||
":rebaseStalePrs",
|
||||
"group:allNonMajor",
|
||||
"schedule:earlyMondays",
|
||||
":automergeMinor",
|
||||
":automergeTesters",
|
||||
":automergeLinters",
|
||||
":automergeTypes",
|
||||
":automergePatch"
|
||||
],
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackagePatterns": ["^mermaid"],
|
||||
"groupName": "Mermaid packages"
|
||||
},
|
||||
{
|
||||
"matchUpdateTypes": ["minor", "patch", "pin", "digest"],
|
||||
"automerge": true
|
||||
}
|
||||
],
|
||||
"dependencyDashboard": true,
|
||||
"major": {
|
||||
"dependencyDashboardApproval": true
|
||||
},
|
||||
"dependencyDashboardAutoclose": true,
|
||||
"rangeStrategy": "bump"
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<script>
|
||||
import Router from 'svelte-spa-router';
|
||||
// Used for SSR. A falsy value is ignored by the Router.
|
||||
import Edit from './routes/Edit.svelte';
|
||||
import View from './routes/View.svelte';
|
||||
|
||||
const routes = {
|
||||
// Exact path
|
||||
'/': Edit,
|
||||
|
||||
// Using named parameters, with last being optional
|
||||
'/edit/:data': Edit,
|
||||
|
||||
// Wildcard parameter
|
||||
'/view/:data': View,
|
||||
|
||||
// Catch-all
|
||||
// This is optional, but if present it must be the last
|
||||
'*': Edit,
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
</style>
|
||||
|
||||
<svelte:head>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,500;0,600;0,700;0,800;0,900;1,400;1,500;1,600;1,700;1,800;1,900&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap"
|
||||
rel="stylesheet" />
|
||||
</svelte:head>
|
||||
<Router {routes} />
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
@import '@fontsource-variable/recursive/crsv.css';
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@layer base {
|
||||
*,
|
||||
::after,
|
||||
::before,
|
||||
::backdrop,
|
||||
::file-selector-button {
|
||||
border-color: var(--color-gray-200, currentcolor);
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: hsl(0 0% 100%);
|
||||
--foreground: hsl(222.2 84% 4.9%);
|
||||
--card: hsl(0 0% 100%);
|
||||
--card-foreground: hsl(222.2 84% 4.9%);
|
||||
--popover: hsl(0 0% 100%);
|
||||
--popover-foreground: hsl(222.2 84% 4.9%);
|
||||
--primary: hsl(240 10% 91%);
|
||||
--primary-foreground: hsl(255 20% 15%);
|
||||
--secondary: hsl(210 40% 96.1%);
|
||||
--secondary-foreground: hsl(222.2 47.4% 11.2%);
|
||||
--muted: hsl(228 24% 96%);
|
||||
--muted-foreground: hsl(215.4 16.3% 46.9%);
|
||||
--accent: hsl(340 100% 44%);
|
||||
--accent-foreground: hsl(210 40% 98%);
|
||||
--destructive: hsl(0 72.22% 50.59%);
|
||||
--destructive-foreground: hsl(210 40% 98%);
|
||||
--border: hsl(214.3 31.8% 91.4%);
|
||||
--border-dark: hsl(214.3 31.8% 81.4%);
|
||||
--input: hsl(214.3 31.8% 91.4%);
|
||||
--ring: hsl(222.2 84% 4.9%);
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
.dark {
|
||||
--background: hsl(222.2 84% 4.9%);
|
||||
--foreground: hsl(210 40% 98%);
|
||||
--card: hsl(222.2 84% 4.9%);
|
||||
--card-foreground: hsl(210 40% 98%);
|
||||
--popover: hsl(222.2 84% 4.9%);
|
||||
--popover-foreground: hsl(210 40% 98%);
|
||||
--primary: hsl(210 40% 30%);
|
||||
--primary-foreground: hsl(222.2 47.4% 90%);
|
||||
--secondary: hsl(217.2 32.6% 17.5%);
|
||||
--secondary-foreground: hsl(210 40% 98%);
|
||||
--muted: hsl(217.2 32.6% 17.5%);
|
||||
--muted-foreground: hsl(215 20.2% 65.1%);
|
||||
--accent: hsl(340 100% 44%);
|
||||
--accent-foreground: hsl(210 40% 98%);
|
||||
--destructive: hsl(0 62.8% 30.6%);
|
||||
--destructive-foreground: hsl(210 40% 98%);
|
||||
--border: hsl(217.2 32.6% 17.5%);
|
||||
--border-dark: hsl(217.2 32.6% 27.5%);
|
||||
--input: hsl(217.2 32.6% 17.5%);
|
||||
--ring: hsl(212.7 26.8% 83.9%);
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
/* Radius (for rounded-*) */
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
|
||||
/* Colors */
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-border-dark: var(--border-dark);
|
||||
--color-input: var(--input);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-ring: var(--ring);
|
||||
--color-radius: var(--radius);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply h-screen w-screen overflow-hidden bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Recursive Variable', sans-serif;
|
||||
}
|
||||
|
||||
.d {
|
||||
@apply border border-red-500;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Online FlowChart & Diagrams Editor - Mermaid Live Editor</title>
|
||||
<meta name="og:image" content="%sveltekit.assets%/favicon.svg" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Simplify documentation and avoid heavy tools. Open source Visio Alternative. Commonly used for explaining your code! Mermaid is a simple markdown-like script language for generating charts from text via javascript." />
|
||||
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<link rel="mask-icon" href="%sveltekit.assets%/favicon.svg" color="#000000" />
|
||||
<meta name="theme-color" content="#ff3670" />
|
||||
<link rel="manifest" href="%sveltekit.assets%/manifest.json" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body>
|
||||
<div id="svelte">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,3 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const codeErrorStore = writable(undefined);
|
||||
@@ -0,0 +1,55 @@
|
||||
import { writable, get } from 'svelte/store';
|
||||
// import mermaid from '@mermaid-js/mermaid';
|
||||
import mermaid from '@mermaid';
|
||||
import { Base64 } from 'js-base64';
|
||||
import { push, pop, replace } from 'svelte-spa-router';
|
||||
|
||||
export const codeStore = writable({});
|
||||
export const fromUrl = (data) => {
|
||||
let code;
|
||||
let state;
|
||||
const isDarkMode =
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches && false;
|
||||
try {
|
||||
let stateStr = Base64.decode(data);
|
||||
console.log('state from url', stateStr);
|
||||
state = JSON.parse(stateStr);
|
||||
|
||||
if (state.code === undefined) {
|
||||
// not valid json
|
||||
// state = { code: '', mermaid: { theme: themeFromUrl } }
|
||||
}
|
||||
code = state.code;
|
||||
} catch (e) {
|
||||
// console.error('Init error', e);
|
||||
code = `graph TD
|
||||
A[Christmas] -->|Get money| B(Go shopping)
|
||||
B --> C{Let me think}
|
||||
C -->|One| D[Laptop]
|
||||
C -->|Two| E[iPhone]
|
||||
C -->|Three| F[fa:fa-car Car]
|
||||
`;
|
||||
state = { code, mermaid: { theme: isDarkMode ? 'dark' : 'default' } };
|
||||
}
|
||||
|
||||
codeStore.set(state);
|
||||
};
|
||||
export const updateCodeStore = (newState) => {
|
||||
codeStore.set(newState);
|
||||
replace('/edit/' + Base64.encodeURI(JSON.stringify(newState)));
|
||||
};
|
||||
export const updateCode = (code, updateEditor) => {
|
||||
const state = get(codeStore);
|
||||
state.code = code;
|
||||
state.updateEditor = updateEditor;
|
||||
codeStore.set(state);
|
||||
};
|
||||
export const updateConfig = (config) => {
|
||||
const state = get(codeStore);
|
||||
state.mermaid = config;
|
||||
codeStore.set(state);
|
||||
};
|
||||
|
||||
const unsubscribe = codeStore.subscribe((state) => {
|
||||
replace('/edit/' + Base64.encodeURI(JSON.stringify(state)));
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
<script>
|
||||
export let title = '';
|
||||
export let noPadding = false;
|
||||
let contentClass = 'padding';
|
||||
if (noPadding) {
|
||||
contentClass = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#card {
|
||||
border: 1px solid lightgray;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
#title {
|
||||
font-family: 'Playfair Display', serif;
|
||||
font-weight: 400;
|
||||
border-bottom: 1px solid lightgray;
|
||||
font-size: 1.25rem;
|
||||
background-color: #1e60ab;
|
||||
color: #eaebef;
|
||||
}
|
||||
#content {
|
||||
padding-top: 8px;
|
||||
}
|
||||
.padding {
|
||||
padding: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="card">
|
||||
<div id="title" class="padding">{title}</div>
|
||||
<div id="content" class={contentClass}>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,114 @@
|
||||
<script>
|
||||
import {
|
||||
codeStore,
|
||||
updateCodeStore,
|
||||
updateConfig,
|
||||
updateCode,
|
||||
} from '../code-store.js';
|
||||
import { configErrorStore } from '../config-error-store.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { push, pop, replace } from 'svelte-spa-router';
|
||||
import { Base64 } from 'js-base64';
|
||||
// import mermaid from '@mermaid-js/mermaid';
|
||||
import mermaid from '@mermaid';
|
||||
import Error from './Error.svelte';
|
||||
import { getResizeHandler, initEditor } from './editor-utils';
|
||||
import { watchResize } from 'svelte-watch-resize';
|
||||
import 'monaco-editor/esm/vs/editor/browser/controller/coreCommands.js';
|
||||
import 'monaco-editor/esm/vs/editor/contrib/find/findController.js';
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js';
|
||||
|
||||
export let conf = '';
|
||||
export let code = '';
|
||||
export let error = false;
|
||||
|
||||
let edit;
|
||||
let editorElem = null;
|
||||
|
||||
let decorations = [];
|
||||
const decArr = [];
|
||||
let resizeHandler = () => {};
|
||||
|
||||
let oldConf = {};
|
||||
const handleConfUpdate = (conf) => {
|
||||
try {
|
||||
console.log(conf);
|
||||
JSON.parse(conf);
|
||||
// let newState = { code, mermaid: JSON.parse(conf) };
|
||||
// oldConf = newState.mermaid;
|
||||
updateConfig(JSON.parse(conf));
|
||||
configErrorStore.set(undefined);
|
||||
// const model = edit.getModel();
|
||||
// // model.setValue(conf);
|
||||
// // model.dispose();
|
||||
} catch (e) {
|
||||
console.log('Error in parsed', e);
|
||||
configErrorStore.set(e);
|
||||
const str = JSON.stringify({ code, mermaid: oldConf });
|
||||
replace('/edit/' + Base64.encodeURI(str));
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = codeStore.subscribe((state) => {
|
||||
if (editorElem === null) {
|
||||
console.log('Starting stuff', document.getElementById('editor-conf'));
|
||||
editorElem = document.getElementById('editor-conf');
|
||||
}
|
||||
if (!conf && state) {
|
||||
conf = JSON.stringify(state.mermaid, null, 2);
|
||||
}
|
||||
if (state) {
|
||||
code = state.code;
|
||||
}
|
||||
if (!edit && conf && editorElem !== null) {
|
||||
edit = monaco.editor.create(editorElem, {
|
||||
value: [conf].join('\n'),
|
||||
theme: 'myCoolTheme',
|
||||
language: 'JSON',
|
||||
});
|
||||
resizeHandler = getResizeHandler(edit);
|
||||
let decorations = [];
|
||||
edit.onDidChangeModelContent(function (e) {
|
||||
const conf = edit.getValue();
|
||||
handleConfUpdate(conf);
|
||||
});
|
||||
handleConfUpdate(conf);
|
||||
}
|
||||
});
|
||||
|
||||
initEditor(monaco);
|
||||
|
||||
const unsubscribeError = configErrorStore.subscribe((_error) => {
|
||||
// console.log('Error ideintified' + _error.toString());
|
||||
if (_error) {
|
||||
error = _error.toString();
|
||||
} else {
|
||||
error = false;
|
||||
}
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
console.log('Mounting config');
|
||||
self.MonacoEnvironment = {
|
||||
getWorkerUrl: function (moduleId, label) {
|
||||
return './editor.worker.bundle.js';
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#editor-conf {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
max-height: 300px;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="editor-container">
|
||||
<div id="editor-conf" use:watchResize={resizeHandler} />
|
||||
{#if error}
|
||||
<Error errorText={error} />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,136 @@
|
||||
<script>
|
||||
import { codeStore, updateCodeStore } from '../code-store.js';
|
||||
import { codeErrorStore } from '../code-error-store.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { push, pop, replace } from 'svelte-spa-router';
|
||||
import { Base64 } from 'js-base64';
|
||||
// import mermaid from '@mermaid-js/mermaid';
|
||||
import mermaid from '@mermaid';
|
||||
import Error from './Error.svelte';
|
||||
import { getResizeHandler, initEditor } from './editor-utils';
|
||||
import 'monaco-editor/esm/vs/editor/browser/controller/coreCommands.js';
|
||||
import 'monaco-editor/esm/vs/editor/contrib/find/findController.js';
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js';
|
||||
import { watchResize } from 'svelte-watch-resize';
|
||||
|
||||
export let code = '';
|
||||
const isDarkMode =
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches && false;
|
||||
export let conf = { theme: isDarkMode ? 'dark' : 'default' };
|
||||
let edit;
|
||||
export let error = false;
|
||||
|
||||
let decorations = [];
|
||||
const decArr = [];
|
||||
let editorElem = null;
|
||||
let resizeHandler = () => {};
|
||||
const handleCodeUpdate = (code) => {
|
||||
try {
|
||||
mermaid.parse(code);
|
||||
let newState = { code, mermaid: conf, updateEditor: false };
|
||||
updateCodeStore(newState);
|
||||
decArr.forEach((decor) => {
|
||||
edit.deltaDecorations(decor, []);
|
||||
});
|
||||
|
||||
codeErrorStore.set(undefined);
|
||||
const model = edit.getModel();
|
||||
} catch (e) {
|
||||
if (e) {
|
||||
codeErrorStore.set(e);
|
||||
console.log('Error in parsed', e.hash);
|
||||
const str = JSON.stringify({ code: code, mermaid: conf });
|
||||
replace('/edit/' + Base64.encodeURI(str));
|
||||
const l = e.hash.line;
|
||||
decArr.push(
|
||||
edit.deltaDecorations(
|
||||
[],
|
||||
[
|
||||
{
|
||||
range: new monaco.Range(
|
||||
e.hash.loc.first_line,
|
||||
e.hash.loc.last_line,
|
||||
e.hash.loc.first_column,
|
||||
e.hash.loc.last_column
|
||||
),
|
||||
options: { inlineClassName: 'myInlineDecoration' },
|
||||
},
|
||||
]
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = codeStore.subscribe((state) => {
|
||||
console.log('Code change');
|
||||
if (editorElem === null) {
|
||||
editorElem = document.getElementById('editor');
|
||||
}
|
||||
if (!code && state) {
|
||||
code = state.code;
|
||||
}
|
||||
if (state) {
|
||||
conf = state.mermaid;
|
||||
}
|
||||
if (!edit && code && editorElem !== null) {
|
||||
edit = monaco.editor.create(editorElem, {
|
||||
value: [code].join('\n'),
|
||||
theme: 'myCoolTheme',
|
||||
language: 'mermaid',
|
||||
});
|
||||
resizeHandler = getResizeHandler(edit);
|
||||
|
||||
let decorations = [];
|
||||
edit.onDidChangeModelContent(function (e) {
|
||||
const code = edit.getValue();
|
||||
handleCodeUpdate(code);
|
||||
});
|
||||
handleCodeUpdate(code);
|
||||
}
|
||||
if (state && state.updateEditor && edit && code && editorElem !== null) {
|
||||
edit.setValue(state.code);
|
||||
handleCodeUpdate(state.code);
|
||||
}
|
||||
});
|
||||
|
||||
const unsubscribeError = codeErrorStore.subscribe((_error) => {
|
||||
if (_error) {
|
||||
error = true;
|
||||
} else {
|
||||
error = false;
|
||||
}
|
||||
});
|
||||
|
||||
initEditor(monaco);
|
||||
|
||||
onMount(async () => {
|
||||
// editorElem = document.querySelector('#editor')
|
||||
self.MonacoEnvironment = {
|
||||
getWorkerUrl: function (moduleId, label) {
|
||||
return './editor.worker.bundle.js';
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// export let name;
|
||||
// export let params = {};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#editor-container {
|
||||
}
|
||||
#editor {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
max-height: 300px;
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="editor-container">
|
||||
<div id="editor" use:watchResize={resizeHandler} />
|
||||
{#if error}
|
||||
<Error errorText="Syntax Error" />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script>
|
||||
import { codeErrorStore } from '../code-error-store.js';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
onMount(async () => {});
|
||||
|
||||
// export let code = '';
|
||||
export let classes = '';
|
||||
|
||||
// export let error = {};
|
||||
// export let token = '';
|
||||
// export let expected = '';
|
||||
|
||||
// export let params = {}
|
||||
export let errorText = '';
|
||||
|
||||
// const unsubscribeError = codeErrorStore.subscribe( _error => {
|
||||
// if(typeof _error === 'undefined') {
|
||||
// classes = 'invisible';
|
||||
// error = {};
|
||||
// token='';
|
||||
// expected='';
|
||||
// } else {
|
||||
// classes = 'visible';
|
||||
// error = _error;
|
||||
// console.log('error: ', _error);
|
||||
// token = error.hash.token;
|
||||
// expected = error.hash.expected.join(' ');;
|
||||
// }
|
||||
// });
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#error {
|
||||
background: darkred;
|
||||
color: white;
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
/* position: absolute; */
|
||||
}
|
||||
.invisible {
|
||||
display: none;
|
||||
}
|
||||
.visible {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="error" class={classes}>{errorText}</div>
|
||||
Executable
+228
@@ -0,0 +1,228 @@
|
||||
<script>
|
||||
import { link } from 'svelte-spa-router';
|
||||
import { Base64 } from 'js-base64';
|
||||
import moment from 'moment';
|
||||
import { codeStore } from '../code-store.js';
|
||||
|
||||
const getBase64SVG = () => {
|
||||
const container = document.getElementById('container');
|
||||
const svg = container.innerHTML.replaceAll('<br>', '<br/>');
|
||||
return Base64.encode(svg);
|
||||
};
|
||||
|
||||
const exportImage = (event, exporter) => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const svg = document.querySelector('#container svg');
|
||||
const box = svg.getBoundingClientRect();
|
||||
canvas.width = box.width;
|
||||
canvas.height = box.height;
|
||||
if (imagemodeselected === 'width') {
|
||||
const ratio = box.height / box.width;
|
||||
canvas.width = userimagewidth;
|
||||
canvas.height = userimagewidth * ratio;
|
||||
} else if (imagemodeselected === 'height') {
|
||||
const ratio = box.width / box.height;
|
||||
canvas.width = userimageheight * ratio;
|
||||
canvas.height = userimageheight;
|
||||
}
|
||||
const context = canvas.getContext('2d');
|
||||
context.fillStyle = 'white';
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const image = new Image();
|
||||
image.onload = exporter(canvas, context, image);
|
||||
|
||||
console.log('SVG', getBase64SVG());
|
||||
image.src = `data:image/svg+xml;base64,${getBase64SVG()}`;
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const downloadImage = (canvas, context, image) => {
|
||||
return () => {
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.download = `mermaid-diagram-${moment().format('YYYYMMDDHHmmss')}.png`;
|
||||
a.href = canvas
|
||||
.toDataURL('image/png')
|
||||
.replace('image/png', 'image/octet-stream');
|
||||
a.click();
|
||||
};
|
||||
};
|
||||
|
||||
const clipboardCopy = (canvas, context, image) => {
|
||||
return () => {
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
canvas.toBlob((blob) => {
|
||||
try {
|
||||
navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
[blob.type]: blob,
|
||||
}),
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const onCopyClipboard = (event) => {
|
||||
exportImage(event, clipboardCopy);
|
||||
};
|
||||
|
||||
const onDownloadPNG = (event) => {
|
||||
exportImage(event, downloadImage);
|
||||
};
|
||||
|
||||
const onDownloadSVG = (event) => {
|
||||
console.log('event', event.target);
|
||||
event.target.href = `data:image/svg+xml;base64,${getBase64SVG()}`;
|
||||
event.target.download = `mermaid-diagram-${moment().format(
|
||||
'YYYYMMDDHHmmss'
|
||||
)}.svg`;
|
||||
console.log('event', event);
|
||||
};
|
||||
|
||||
const onCopyMarkdown = (event) => {
|
||||
event.target.select();
|
||||
document.execCommand('Copy');
|
||||
};
|
||||
|
||||
let url = '/mermaid-live-editor/#/view';
|
||||
let b64Code;
|
||||
let iUrl;
|
||||
let svgUrl;
|
||||
let mdCode;
|
||||
let imagemodeselected = 'auto';
|
||||
let userimagewidth = 1920;
|
||||
let userimageheight = 1080;
|
||||
|
||||
const unsubscribe = codeStore.subscribe((state) => {
|
||||
b64Code = Base64.encodeURI(JSON.stringify(state));
|
||||
url = `/mermaid-live-editor/#/view/${b64Code}`;
|
||||
iUrl = `https://mermaid.ink/img/${b64Code}`;
|
||||
svgUrl = `https://mermaid.ink/svg/${b64Code}`;
|
||||
mdCode = `[](${window.location.protocol}//${window.location.host}${window.location.pathname}#/edit/${b64Code})`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#links {
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid lightgray;
|
||||
}
|
||||
#markdown {
|
||||
padding: 7px;
|
||||
font-family: monospace;
|
||||
font-size: 14px;
|
||||
width: 95%;
|
||||
margin: 1rem 0;
|
||||
border: 1px solid lightgray;
|
||||
}
|
||||
label[for='markdown'] {
|
||||
cursor: pointer;
|
||||
margin: 0 auto;
|
||||
}
|
||||
/*
|
||||
@media (prefers-color-scheme: dark) {
|
||||
label[for="markdown"] {
|
||||
color: #d8d8d8;
|
||||
}
|
||||
} */
|
||||
.button-style {
|
||||
background-color: #a2d9e2;
|
||||
color: #33a2c4;
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #a2d9e2;
|
||||
margin: 0.25rem;
|
||||
}
|
||||
.button-style:hover {
|
||||
background-color: #fff;
|
||||
color: #33a2c4;
|
||||
border: 1px solid #33a2c4;
|
||||
}
|
||||
.button-style:focus {
|
||||
outline: none;
|
||||
}
|
||||
.link-style {
|
||||
text-decoration: none;
|
||||
color: #33a2c4;
|
||||
}
|
||||
#copy-section {
|
||||
padding-top: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="links">
|
||||
<button class="button-style">
|
||||
<a class="link-style" href={url} download="" on:click={onCopyClipboard}>
|
||||
Copy Image
|
||||
</a>
|
||||
</button>
|
||||
<button class="button-style">
|
||||
<a class="link-style" href={url} download="" on:click={onDownloadPNG}>
|
||||
Download PNG
|
||||
</a>
|
||||
</button>
|
||||
<button class="button-style">
|
||||
<a class="link-style" href={url} use:link>Link to view</a>
|
||||
</button>
|
||||
<button class="button-style">
|
||||
<a class="link-style" href={url} download="" on:click={onDownloadSVG}>
|
||||
Download SVG
|
||||
</a>
|
||||
</button>
|
||||
<button class="button-style">
|
||||
<a class="link-style" href={iUrl}>Link to Image</a>
|
||||
</button>
|
||||
<button class="button-style">
|
||||
<a class="link-style" href={svgUrl}>Link to SVG</a>
|
||||
</button>
|
||||
(markdown is base64 encoded for these urls)
|
||||
</div>
|
||||
<div id="copy-section">
|
||||
<label for="markdown" class="button-style">Copy Markdown</label>
|
||||
<br />
|
||||
<input id="markdown" type="text" value={mdCode} on:click={onCopyMarkdown} />
|
||||
</div>
|
||||
<p>
|
||||
<label>PNG size:</label>
|
||||
<input
|
||||
type="radio"
|
||||
value="auto"
|
||||
id="autosize"
|
||||
bind:group={imagemodeselected} />
|
||||
<label for="autosize">auto</label>
|
||||
<input
|
||||
type="radio"
|
||||
value="width"
|
||||
id="width-active"
|
||||
bind:group={imagemodeselected} />
|
||||
<label for="width">width</label>
|
||||
<input
|
||||
id="width"
|
||||
type="number"
|
||||
min="3"
|
||||
max="10000"
|
||||
bind:value={userimagewidth}
|
||||
disabled={imagemodeselected !== 'width'} />
|
||||
<input
|
||||
type="radio"
|
||||
value="height"
|
||||
id="height-active"
|
||||
bind:group={imagemodeselected} />
|
||||
<label for="height">height</label>
|
||||
<input
|
||||
id="height"
|
||||
type="number"
|
||||
min="3"
|
||||
max="10000"
|
||||
bind:value={userimageheight}
|
||||
disabled={imagemodeselected !== 'height'} /><br />
|
||||
</p>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script>
|
||||
export let noPadding = false;
|
||||
let contentClass = 'padding';
|
||||
if (noPadding) {
|
||||
contentClass = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#tag {
|
||||
height: auto;
|
||||
margin: 0 8px 0 0;
|
||||
padding: 0 7px;
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
cursor: default;
|
||||
font-size: smaller;
|
||||
/* padding: 8px; */
|
||||
color: #52c41a;
|
||||
background: #f6ffed;
|
||||
border-color: #b7eb8f;
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="tag">
|
||||
<slot />
|
||||
</div>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script>
|
||||
import { codeStore } from '../code-store.js';
|
||||
import { codeErrorStore } from '../code-error-store.js';
|
||||
import { configErrorStore } from '../config-error-store.js';
|
||||
import { onMount } from 'svelte';
|
||||
// import mermaid from '@mermaid-js/mermaid';
|
||||
import mermaid from '@mermaid';
|
||||
|
||||
const detectType = (text) => {
|
||||
text = text.replace(/^\s*%%.*\n/g, '\n');
|
||||
console.debug('Detecting diagram type based on the text ' + text);
|
||||
if (text.match(/^\s*sequenceDiagram/)) {
|
||||
return 'sequence';
|
||||
}
|
||||
|
||||
if (text.match(/^\s*gantt/)) {
|
||||
return 'gantt';
|
||||
}
|
||||
|
||||
if (text.match(/^\s*classDiagram/)) {
|
||||
return 'class';
|
||||
}
|
||||
|
||||
if (text.match(/^\s*stateDiagram/)) {
|
||||
return 'state';
|
||||
}
|
||||
|
||||
if (text.match(/^\s*gitGraph/)) {
|
||||
return 'git';
|
||||
}
|
||||
if (text.match(/^\s*flowchart/)) {
|
||||
return 'flowchart';
|
||||
}
|
||||
|
||||
if (text.match(/^\s*info/)) {
|
||||
return 'info';
|
||||
}
|
||||
if (text.match(/^\s*pie/)) {
|
||||
return 'pie';
|
||||
}
|
||||
|
||||
return 'flowchart';
|
||||
};
|
||||
|
||||
// manual debounce
|
||||
let timeout;
|
||||
const saveStatistcs = (graphType) => {
|
||||
clearTimeout(timeout);
|
||||
// Only save statistcs after a 5 sec delay
|
||||
timeout = setTimeout(function () {
|
||||
console.log('ga:', 'send', 'event', 'render', graphType, graphType);
|
||||
ga('send', 'event', graphType, 'render', 'render');
|
||||
}, 5000);
|
||||
};
|
||||
|
||||
let element;
|
||||
let container;
|
||||
onMount(async () => {
|
||||
element = document.querySelector('graph-div');
|
||||
});
|
||||
|
||||
let insertSvg = function (svgCode, bindFunctions) {
|
||||
// element.innerHTML = svgCode;
|
||||
};
|
||||
|
||||
export let code = '';
|
||||
export let configClasses = '';
|
||||
export let codeClasses = '';
|
||||
const unsubscribe = codeStore.subscribe((state) => {
|
||||
try {
|
||||
if (container && state) {
|
||||
code = state.code;
|
||||
|
||||
// mermaid.parse(code)
|
||||
// Replacing special characters '<' and '>' with encoded '<' and '>'
|
||||
let _code = code;
|
||||
_code = _code.replace(/</g, '<');
|
||||
_code = _code.replace(/>/g, '>');
|
||||
|
||||
container.innerHTML = _code;
|
||||
saveStatistcs(detectType(code));
|
||||
delete container.dataset.processed;
|
||||
mermaid.initialize(Object.assign({}, state.mermaid));
|
||||
mermaid.init(undefined, container);
|
||||
if (code) mermaid.render('graph-div', code, insertSvg);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('view fail', e);
|
||||
}
|
||||
});
|
||||
const unsubscribeError = codeErrorStore.subscribe((_error) => {
|
||||
if (typeof _error === 'undefined') {
|
||||
codeClasses = '';
|
||||
} else {
|
||||
codeClasses = 'error';
|
||||
console.log('code error: ', _error);
|
||||
}
|
||||
});
|
||||
const unsubscribeConfigError = configErrorStore.subscribe((_error) => {
|
||||
if (typeof _error === 'undefined') {
|
||||
configClasses = '';
|
||||
} else {
|
||||
configClasses = 'error';
|
||||
console.log('conf error: ', _error);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#view {
|
||||
border: 1px solor darkred;
|
||||
flex: 1;
|
||||
}
|
||||
#container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.error {
|
||||
opacity: 0.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="view" class="{codeClasses} {configClasses}">
|
||||
<div id="container" bind:this={container} />
|
||||
</div>
|
||||
@@ -0,0 +1,86 @@
|
||||
export const initEditor = (monaco) => {
|
||||
monaco.languages.register({ id: 'mermaid' });
|
||||
|
||||
// Register a tokens provider for the language
|
||||
monaco.languages.setMonarchTokensProvider('mermaid', {
|
||||
typeKeywords: [
|
||||
'graph',
|
||||
'stateDiagram',
|
||||
'sequenceDiagram',
|
||||
'classDiagram',
|
||||
'pie',
|
||||
'flowchart',
|
||||
'gantt',
|
||||
],
|
||||
keywords: ['patricipant', 'as'],
|
||||
arrows: ['---', '===', '-->', '==>'],
|
||||
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/[{}]/, 'delimiter.bracket'],
|
||||
[
|
||||
/[a-z_$][\w$]*/,
|
||||
{ cases: { '@typeKeywords': 'keyword', '@keywords': 'keyword' } },
|
||||
],
|
||||
[/[-=>ox]+/, { cases: { '@arrows': 'transition' } }],
|
||||
[/[\[\{\(}]+.+?[\)\]\}]+/, 'string'],
|
||||
[/\".*\"/, 'string'],
|
||||
],
|
||||
},
|
||||
whitespace: [
|
||||
[/[ \t\r\n]+/, 'white'],
|
||||
[/\%\%.*$/, 'comment'],
|
||||
],
|
||||
});
|
||||
|
||||
monaco.editor.defineTheme('myCoolTheme', {
|
||||
base: 'vs',
|
||||
inherit: false,
|
||||
rules: [
|
||||
{ token: 'keyword', foreground: '880000', fontStyle: 'bold' },
|
||||
{ token: 'custom-error', foreground: 'ff0000', fontStyle: 'bold' },
|
||||
{ token: 'string', foreground: 'AA8500' },
|
||||
{ token: 'transition', foreground: '008800', fontStyle: 'bold' },
|
||||
{ token: 'delimiter.bracket', foreground: '000000', fontStyle: 'bold' },
|
||||
],
|
||||
});
|
||||
|
||||
// Register a completion item provider for the new language
|
||||
monaco.languages.registerCompletionItemProvider('mermaid', {
|
||||
provideCompletionItems: () => {
|
||||
var suggestions = [
|
||||
{
|
||||
label: 'simpleText',
|
||||
kind: monaco.languages.CompletionItemKind.Text,
|
||||
insertText: 'simpleText',
|
||||
},
|
||||
{
|
||||
label: 'testing',
|
||||
kind: monaco.languages.CompletionItemKind.Keyword,
|
||||
insertText: 'testing(${1:condition})',
|
||||
insertTextRules:
|
||||
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
},
|
||||
{
|
||||
label: 'ifelse',
|
||||
kind: monaco.languages.CompletionItemKind.Snippet,
|
||||
insertText: [
|
||||
'if (${1:condition}) {',
|
||||
'\t$0',
|
||||
'} else {',
|
||||
'\t',
|
||||
'}',
|
||||
].join('\n'),
|
||||
insertTextRules:
|
||||
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'If-Else Statement',
|
||||
},
|
||||
];
|
||||
return { suggestions: suggestions };
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const getResizeHandler = (editor) => {
|
||||
return (node) => editor && editor.layout();
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const configErrorStore = writable(undefined);
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
// (1) Desired editor features:
|
||||
import 'monaco-editor/esm/vs/editor/browser/controller/coreCommands.js';
|
||||
// import 'monaco-editor/esm/vs/editor/browser/widget/codeEditorWidget.js';
|
||||
// import 'monaco-editor/esm/vs/editor/browser/widget/diffEditorWidget.js';
|
||||
// import 'monaco-editor/esm/vs/editor/browser/widget/diffNavigator.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/bracketMatching/bracketMatching.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/caretOperations/caretOperations.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/caretOperations/transpose.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/clipboard/clipboard.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/codelens/codelensController.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/colorPicker/colorDetector.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/comment/comment.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/contextmenu/contextmenu.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/cursorUndo/cursorUndo.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/dnd/dnd.js';
|
||||
import 'monaco-editor/esm/vs/editor/contrib/find/findController.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/folding/folding.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/format/formatActions.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/goToDeclaration/goToDeclarationCommands.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/goToDeclaration/goToDeclarationMouse.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/gotoError/gotoError.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/hover/hover.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/inPlaceReplace/inPlaceReplace.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/linesOperations/linesOperations.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/links/links.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/multicursor/multicursor.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/parameterHints/parameterHints.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/quickFix/quickFixCommands.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/referenceSearch/referenceSearch.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/rename/rename.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/smartSelect/smartSelect.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/snippet/snippetController2.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/suggest/suggestController.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/toggleTabFocusMode/toggleTabFocusMode.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/wordHighlighter/wordHighlighter.js';
|
||||
// import 'monaco-editor/esm/vs/editor/contrib/wordOperations/wordOperations.js';
|
||||
// import 'monaco-editor/esm/vs/editor/standalone/browser/accessibilityHelp/accessibilityHelp.js';
|
||||
// import 'monaco-editor/esm/vs/editor/standalone/browser/inspectTokens/inspectTokens.js';
|
||||
// import 'monaco-editor/esm/vs/editor/standalone/browser/iPadShowKeyboard/iPadShowKeyboard.js';
|
||||
// import 'monaco-editor/esm/vs/editor/standalone/browser/quickOpen/quickOutline.js';
|
||||
// import 'monaco-editor/esm/vs/editor/standalone/browser/quickOpen/gotoLine.js';
|
||||
// import 'monaco-editor/esm/vs/editor/standalone/browser/quickOpen/quickCommand.js';
|
||||
// import 'monaco-editor/esm/vs/editor/standalone/browser/toggleHighContrast/toggleHighContrast.js';
|
||||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js';
|
||||
|
||||
// (2) Desired languages:
|
||||
// import 'monaco-editor/esm/vs/language/typescript/monaco.contribution';
|
||||
// import 'monaco-editor/esm/vs/language/css/monaco.contribution';
|
||||
// import 'monaco-editor/esm/vs/language/json/monaco.contribution';
|
||||
// import 'monaco-editor/esm/vs/language/html/monaco.contribution';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/bat/bat.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/coffee/coffee.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/cpp/cpp.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/csharp/csharp.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/csp/csp.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/css/css.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/dockerfile/dockerfile.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/fsharp/fsharp.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/go/go.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/handlebars/handlebars.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/html/html.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/ini/ini.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/java/java.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/less/less.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/lua/lua.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/markdown/markdown.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/msdax/msdax.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/mysql/mysql.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/objective-c/objective-c.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/pgsql/pgsql.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/php/php.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/postiats/postiats.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/powershell/powershell.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/pug/pug.contribution.js';
|
||||
import 'monaco-editor/esm/vs/basic-languages/python/python.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/r/r.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/razor/razor.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/redis/redis.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/redshift/redshift.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/ruby/ruby.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/sb/sb.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/scss/scss.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/solidity/solidity.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/sql/sql.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/swift/swift.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/vb/vb.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/xml/xml.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution.js';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/javascript/javascript.contribution';
|
||||
// import 'monaco-editor/esm/vs/basic-languages/typescript/typescript.contribution';
|
||||
|
||||
monaco.languages.register({ id: 'mermaid' });
|
||||
|
||||
// Register a tokens provider for the language
|
||||
monaco.languages.setMonarchTokensProvider('mermaid', {
|
||||
tokenizer: {
|
||||
root: [
|
||||
[/\[graph.*/, 'custom-error'],
|
||||
[/\[notice.*/, 'custom-notice'],
|
||||
[/\[info.*/, 'custom-info'],
|
||||
[/\[[a-zA-Z 0-9:]+\]/, 'custom-date'],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
self.MonacoEnvironment = {
|
||||
getWorkerUrl: function (moduleId, label) {
|
||||
return './editor.worker.bundle.js';
|
||||
},
|
||||
};
|
||||
|
||||
// Define a new theme that contains only rules that match this language
|
||||
monaco.editor.defineTheme('myCoolTheme', {
|
||||
base: 'vs',
|
||||
inherit: false,
|
||||
rules: [
|
||||
{ token: 'custom-info', foreground: '808080' },
|
||||
{ token: 'custom-error', foreground: 'ff0000', fontStyle: 'bold' },
|
||||
{ token: 'custom-notice', foreground: 'FFA500' },
|
||||
{ token: 'custom-date', foreground: '008800' },
|
||||
],
|
||||
});
|
||||
|
||||
// Register a completion item provider for the new language
|
||||
monaco.languages.registerCompletionItemProvider('mySpecialLanguage', {
|
||||
provideCompletionItems: () => {
|
||||
var suggestions = [
|
||||
{
|
||||
label: 'simpleText',
|
||||
kind: monaco.languages.CompletionItemKind.Text,
|
||||
insertText: 'simpleText',
|
||||
},
|
||||
{
|
||||
label: 'testing',
|
||||
kind: monaco.languages.CompletionItemKind.Keyword,
|
||||
insertText: 'testing(${1:condition})',
|
||||
insertTextRules:
|
||||
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
},
|
||||
{
|
||||
label: 'ifelse',
|
||||
kind: monaco.languages.CompletionItemKind.Snippet,
|
||||
insertText: [
|
||||
'if (${1:condition}) {',
|
||||
'\t$0',
|
||||
'} else {',
|
||||
'\t',
|
||||
'}',
|
||||
].join('\n'),
|
||||
insertTextRules:
|
||||
monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet,
|
||||
documentation: 'If-Else Statement',
|
||||
},
|
||||
];
|
||||
return { suggestions: suggestions };
|
||||
},
|
||||
});
|
||||
|
||||
const edit = monaco.editor.create(document.getElementById('editor'), {
|
||||
value: [code].join('\n'),
|
||||
theme: 'myCoolTheme',
|
||||
// value: getCode(),
|
||||
//language: 'mySpecialLanguage'
|
||||
language: 'mermaid',
|
||||
});
|
||||
edit.onDidChangeModelContent(function (e) {
|
||||
codeStore.set(edit.getValue());
|
||||
});
|
||||
Vendored
-17
@@ -1,17 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly MERMAID_RENDERER_URL?: string;
|
||||
readonly MERMAID_KROKI_RENDERER_URL?: string;
|
||||
readonly MERMAID_ANALYTICS_URL?: string;
|
||||
readonly MERMAID_DOCS_URL?: string;
|
||||
readonly MERMAID_DOMAIN?: string;
|
||||
readonly MERMAID_IS_ENABLED_MERMAID_CHART_LINKS?: string;
|
||||
readonly MERMAID_PRIVACY_POLICY_URL?: string;
|
||||
readonly MERMAID_HIDE_PRIVACY_POLICY?: string;
|
||||
// more env variables...
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
@@ -1,174 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$/components/ui/button';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import CloseIcon from '~icons/material-symbols/close-rounded';
|
||||
|
||||
interface Props {
|
||||
show: boolean;
|
||||
input: string;
|
||||
onClose: () => void;
|
||||
onHeightChange?: (height: number) => void;
|
||||
onTryFree: () => void;
|
||||
}
|
||||
|
||||
let { show, input = $bindable(), onClose, onHeightChange, onTryFree }: Props = $props();
|
||||
|
||||
let textarea = $state<HTMLTextAreaElement>();
|
||||
let container = $state<HTMLDivElement>();
|
||||
|
||||
$effect(() => {
|
||||
if (!container || !onHeightChange) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.target instanceof HTMLElement) {
|
||||
onHeightChange(entry.target.offsetHeight);
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(container);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
function resizeTextarea() {
|
||||
if (!textarea) return;
|
||||
const computed = globalThis.getComputedStyle(textarea);
|
||||
const lineHeight =
|
||||
Number.parseFloat(computed.lineHeight) || Number.parseFloat(computed.fontSize) * 1.5 || 0;
|
||||
const paddingTop = Number.parseFloat(computed.paddingTop) || 0;
|
||||
const paddingBottom = Number.parseFloat(computed.paddingBottom) || 0;
|
||||
const minHeight = lineHeight + paddingTop + paddingBottom;
|
||||
const maxLines = 8;
|
||||
const maxHeight = lineHeight * maxLines + paddingTop + paddingBottom;
|
||||
|
||||
textarea.style.height = 'auto';
|
||||
const nextHeight = Math.max(minHeight, Math.min(textarea.scrollHeight, maxHeight));
|
||||
textarea.style.height = `${nextHeight}px`;
|
||||
textarea.style.overflowY = textarea.scrollHeight > maxHeight ? 'auto' : 'hidden';
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (input !== undefined) {
|
||||
resizeTextarea();
|
||||
}
|
||||
});
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (show && e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleOutsideClick(e: MouseEvent) {
|
||||
if (show && container && e.target instanceof Node && !container.contains(e.target)) {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} onmousedown={handleOutsideClick} />
|
||||
|
||||
{#if show}
|
||||
<div
|
||||
bind:this={container}
|
||||
class={cn(
|
||||
'button-container-for-animation relative z-50 mr-6 flex w-auto flex-col gap-2 rounded-xl border-2 border-border bg-background p-2 shadow-xl dark:border-border-dark dark:bg-secondary',
|
||||
!input.trim() && 'rainbow-border'
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
tabindex="-1">
|
||||
<div class="relative flex min-h-2 items-start gap-1 px-1">
|
||||
<textarea
|
||||
bind:this={textarea}
|
||||
bind:value={input}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (input.trim()) {
|
||||
onTryFree();
|
||||
}
|
||||
}
|
||||
}}
|
||||
placeholder="Describe what to add or change"
|
||||
rows="1"
|
||||
class="focus font-recursive min-h-0 flex-1 resize-none border-none bg-transparent px-1 text-sm font-normal text-foreground placeholder:text-muted-foreground focus:ring-0 focus:outline-none disabled:opacity-50 dark:text-foreground dark:placeholder:text-muted-foreground"
|
||||
style="height: 20px; overflow-y: hidden;"></textarea>
|
||||
<button onclick={onClose} class="text-muted-foreground hover:text-foreground">
|
||||
<CloseIcon class="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-recursive text-xs font-normal text-foreground dark:text-foreground"
|
||||
>Sign Up at Mermaid.ai to try AI</span>
|
||||
<Button
|
||||
class="font-recursive h-6 w-16 gap-1.5 rounded-sm bg-accent p-1 text-xs font-medium text-white no-underline hover:bg-accent/90 hover:text-white hover:no-underline active:bg-accent/80 dark:bg-accent dark:text-white! dark:hover:bg-accent/90 dark:active:bg-accent/80"
|
||||
onclick={onTryFree}>
|
||||
Try free
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
@property --gradient-angle {
|
||||
syntax: '<angle>';
|
||||
initial-value: 0deg;
|
||||
inherits: false;
|
||||
}
|
||||
|
||||
@keyframes gradient-angle-shift {
|
||||
0% {
|
||||
--gradient-angle: 0deg;
|
||||
}
|
||||
100% {
|
||||
--gradient-angle: 360deg;
|
||||
}
|
||||
}
|
||||
|
||||
.button-container-for-animation {
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.button-container-for-animation::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
border-radius: 14px;
|
||||
padding: 2px;
|
||||
background: conic-gradient(
|
||||
from var(--gradient-angle, 0deg),
|
||||
color-mix(in srgb, var(--color-accent) 0%, transparent) 0%,
|
||||
color-mix(in srgb, var(--color-accent) 0%, transparent) 12%,
|
||||
color-mix(in srgb, var(--color-accent) 12%, transparent) 18%,
|
||||
color-mix(in srgb, var(--color-accent) 42%, transparent) 24%,
|
||||
color-mix(in srgb, var(--color-accent) 77%, transparent) 32%,
|
||||
rgba(93, 85, 212, 0.923) 41%,
|
||||
rgba(93, 85, 212, 0.5) 52%,
|
||||
color-mix(in srgb, var(--color-accent) 58%, transparent) 72%,
|
||||
color-mix(in srgb, var(--color-accent) 56%, transparent) 82%,
|
||||
color-mix(in srgb, var(--color-accent) 36%, transparent) 87%,
|
||||
color-mix(in srgb, var(--color-accent) 19%, transparent) 92%,
|
||||
color-mix(in srgb, var(--color-accent) 10%, transparent) 96%,
|
||||
color-mix(in srgb, var(--color-accent) 3%, transparent) 98%,
|
||||
color-mix(in srgb, var(--color-accent) 0%, transparent) 100%
|
||||
);
|
||||
-webkit-mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask:
|
||||
linear-gradient(#fff 0 0) content-box,
|
||||
linear-gradient(#fff 0 0);
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease-out;
|
||||
}
|
||||
|
||||
.button-container-for-animation.rainbow-border::before {
|
||||
opacity: 1;
|
||||
animation: gradient-angle-shift 2s linear infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -1,323 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Card from '$/components/Card/Card.svelte';
|
||||
import CopyButton from '$/components/CopyButton.svelte';
|
||||
import CopyInput from '$/components/CopyInput.svelte';
|
||||
import ExternalLinkWrapper from '$/components/ExternalLinkWrapper.svelte';
|
||||
import { Button } from '$/components/ui/button';
|
||||
import { Input } from '$/components/ui/input';
|
||||
import { Separator } from '$/components/ui/separator';
|
||||
import * as ToggleGroup from '$/components/ui/toggle-group';
|
||||
import { TID } from '$/constants';
|
||||
import { getDomain } from '$/util/util';
|
||||
import { browser } from '$app/environment';
|
||||
import { waitForRender } from '$lib/util/autoSync';
|
||||
import { inputState, updateCodeStore, urls, validatedState } from '$lib/util/state.svelte';
|
||||
import { logEvent } from '$lib/util/stats';
|
||||
import { version as FAVersion } from '@fortawesome/fontawesome-free/package.json';
|
||||
import dayjs from 'dayjs';
|
||||
import { toBase64 } from 'js-base64';
|
||||
import DownloadIcon from '~icons/material-symbols/download';
|
||||
import ExternalLinkIcon from '~icons/material-symbols/open-in-new-rounded';
|
||||
import WidthIcon from '~icons/material-symbols/width-rounded';
|
||||
|
||||
const FONT_AWESOME_URL = `https://cdnjs.cloudflare.com/ajax/libs/font-awesome/${FAVersion}/css/all.min.css`;
|
||||
|
||||
type Exporter = (context: CanvasRenderingContext2D, image: HTMLImageElement) => () => void;
|
||||
|
||||
const getFileName = (extension: string) =>
|
||||
`mermaid-diagram-${dayjs().format('YYYY-MM-DD-HHmmss')}.${extension}`;
|
||||
|
||||
/**
|
||||
* Fix text clipping in exported SVG for hand-drawn (rough) mode.
|
||||
* svg2roughjs copies foreignObject elements but their height is often insufficient,
|
||||
* causing text bottom edges to be cut off regardless of language.
|
||||
*/
|
||||
const fixForeignObjectClipping = (svg: HTMLElement) => {
|
||||
const foreignObjects = svg.querySelectorAll('foreignObject');
|
||||
foreignObjects.forEach((foreignObj) => {
|
||||
const currentHeight = parseFloat(foreignObj.getAttribute('height') || '0');
|
||||
if (currentHeight <= 0) return;
|
||||
|
||||
const currentY = parseFloat(foreignObj.getAttribute('y') || '0');
|
||||
const newHeight = currentHeight * 1.5;
|
||||
const heightDiff = newHeight - currentHeight;
|
||||
|
||||
foreignObj.setAttribute('height', newHeight.toString());
|
||||
foreignObj.setAttribute('y', (currentY - heightDiff / 2).toString());
|
||||
|
||||
// Ensure inner HTML elements are vertically centered within the expanded area
|
||||
const htmlElements = foreignObj.querySelectorAll('div, span, p');
|
||||
htmlElements.forEach((htmlEl) => {
|
||||
const el = htmlEl as HTMLElement;
|
||||
el.style.display = 'flex';
|
||||
el.style.alignItems = 'center';
|
||||
el.style.justifyContent = 'center';
|
||||
el.style.height = '100%';
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const getSvgElement = () => {
|
||||
const svgElement = document.querySelector('#container svg')?.cloneNode(true) as HTMLElement;
|
||||
svgElement.setAttribute('xmlns:xlink', 'http://www.w3.org/1999/xlink');
|
||||
return svgElement;
|
||||
};
|
||||
|
||||
const getBase64SVG = (svg?: HTMLElement, width?: number, height?: number): string => {
|
||||
if (svg) {
|
||||
// Prevents the SVG size of the interface from being changed
|
||||
svg = svg.cloneNode(true) as HTMLElement;
|
||||
}
|
||||
if (height) {
|
||||
svg?.setAttribute('height', `${height}px`);
|
||||
}
|
||||
if (width) {
|
||||
svg?.setAttribute('width', `${width}px`);
|
||||
}
|
||||
// Workaround https://stackoverflow.com/questions/28690643/firefox-error-rendering-an-svg-image-to-html5-canvas-with-drawimage
|
||||
|
||||
if (!svg) {
|
||||
svg = getSvgElement();
|
||||
}
|
||||
|
||||
if (validatedState.current.rough) {
|
||||
fixForeignObjectClipping(svg);
|
||||
}
|
||||
|
||||
svg.style.backgroundColor = window
|
||||
.getComputedStyle(document.body)
|
||||
.getPropertyValue('--background');
|
||||
|
||||
const svgString = svg.outerHTML
|
||||
.replaceAll('<br>', '<br/>')
|
||||
.replaceAll(/<img([^>]*)>/g, (m, g: string) => `<img ${g} />`);
|
||||
|
||||
return toBase64(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<?xml-stylesheet href="${FONT_AWESOME_URL}" type="text/css"?>
|
||||
${svgString}`);
|
||||
};
|
||||
|
||||
const simulateDownload = (download: string, href: string): void => {
|
||||
const a = document.createElement('a');
|
||||
a.download = download;
|
||||
a.href = href;
|
||||
a.click();
|
||||
a.remove();
|
||||
};
|
||||
|
||||
const exportImage = async (event: Event, exporter: Exporter) => {
|
||||
updateCodeStore({ panZoom: false });
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
await waitForRender();
|
||||
const canvas = document.createElement('canvas');
|
||||
const svg = document.querySelector<HTMLElement>('#container svg');
|
||||
if (!svg) {
|
||||
throw new Error('svg not found');
|
||||
}
|
||||
|
||||
const box = svg.getBoundingClientRect();
|
||||
|
||||
// In rough mode, SVG has width/height="100%" so getBoundingClientRect returns
|
||||
// the container size, not the actual diagram size. Use viewBox dimensions instead.
|
||||
const svgEl = svg as unknown as SVGSVGElement;
|
||||
const viewBox = svgEl.viewBox?.baseVal;
|
||||
const contentWidth = viewBox && viewBox.width > 0 ? viewBox.width : box.width;
|
||||
const contentHeight = viewBox && viewBox.height > 0 ? viewBox.height : box.height;
|
||||
|
||||
if (imageSizeMode === 'width') {
|
||||
const ratio = contentHeight / contentWidth;
|
||||
canvas.width = imageSize;
|
||||
canvas.height = imageSize * ratio;
|
||||
} else if (imageSizeMode === 'height') {
|
||||
const ratio = contentWidth / contentHeight;
|
||||
canvas.width = imageSize * ratio;
|
||||
canvas.height = imageSize;
|
||||
} else {
|
||||
const multiplier = 2;
|
||||
canvas.width = contentWidth * multiplier;
|
||||
canvas.height = contentHeight * multiplier;
|
||||
}
|
||||
|
||||
const context = canvas.getContext('2d');
|
||||
if (!context) {
|
||||
throw new Error('context not found');
|
||||
}
|
||||
|
||||
context.fillStyle = window.getComputedStyle(document.body).getPropertyValue('--background');
|
||||
context.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
const image = new Image();
|
||||
image.addEventListener('load', () => {
|
||||
exporter(context, image)();
|
||||
updateCodeStore({ panZoom: true });
|
||||
});
|
||||
image.src = `data:image/svg+xml;base64,${getBase64SVG(svg, canvas.width, canvas.height)}`;
|
||||
// Fallback to set panZoom to true after 2 seconds
|
||||
// This is a workaround for the case when the image is not loaded
|
||||
setTimeout(() => {
|
||||
if (!inputState.panZoom) {
|
||||
updateCodeStore({ panZoom: true });
|
||||
}
|
||||
}, 2000);
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const downloadImage: Exporter = (context, image) => {
|
||||
return () => {
|
||||
const { canvas } = context;
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
simulateDownload(
|
||||
getFileName('png'),
|
||||
canvas.toDataURL('image/png').replace('image/png', 'image/octet-stream')
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
const isClipboardAvailable = (): boolean => {
|
||||
return Object.prototype.hasOwnProperty.call(window, 'ClipboardItem');
|
||||
};
|
||||
|
||||
const clipboardCopy: Exporter = (context, image) => {
|
||||
return () => {
|
||||
const { canvas } = context;
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
canvas.toBlob((blob) => {
|
||||
try {
|
||||
if (!blob) {
|
||||
throw new Error('blob is empty');
|
||||
}
|
||||
void navigator.clipboard.write([
|
||||
new ClipboardItem({
|
||||
[blob.type]: blob
|
||||
})
|
||||
]);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const onCopyClipboard = async (event?: Event) => {
|
||||
if (!event) {
|
||||
return;
|
||||
}
|
||||
await exportImage(event, clipboardCopy);
|
||||
logEvent('copyClipboard');
|
||||
};
|
||||
|
||||
const onDownloadPNG = async (event: Event) => {
|
||||
await exportImage(event, downloadImage);
|
||||
logEvent('download', {
|
||||
type: 'png'
|
||||
});
|
||||
};
|
||||
|
||||
const onDownloadSVG = () => {
|
||||
simulateDownload(getFileName('svg'), `data:image/svg+xml;base64,${getBase64SVG()}`);
|
||||
logEvent('download', {
|
||||
type: 'svg'
|
||||
});
|
||||
};
|
||||
|
||||
let gistURL = $state('');
|
||||
$effect(() => {
|
||||
const { loader } = validatedState.current;
|
||||
if (loader?.type === 'gist') {
|
||||
gistURL = loader.config.url;
|
||||
}
|
||||
});
|
||||
|
||||
const loadGist = () => {
|
||||
if (!gistURL) {
|
||||
return alert('Please enter a Gist URL first');
|
||||
}
|
||||
window.location.href = `${window.location.pathname}?gist=${gistURL}`;
|
||||
logEvent('loadGist');
|
||||
};
|
||||
|
||||
let imageSizeMode: 'auto' | 'width' | 'height' = $state('auto');
|
||||
|
||||
$effect(() => {
|
||||
if (!imageSizeMode) {
|
||||
imageSizeMode = 'auto';
|
||||
}
|
||||
});
|
||||
|
||||
let imageSize = $state(1080);
|
||||
|
||||
const isNetlify = browser && window.location.host.includes('netlify');
|
||||
</script>
|
||||
|
||||
{#snippet dualActionButton(text: string, download: (event: Event) => unknown, url?: string)}
|
||||
<div class="flex flex-grow gap-0.5">
|
||||
<Button
|
||||
class={['flex-grow', url && 'rounded-r-none']}
|
||||
onclick={download}
|
||||
data-testid="download-{text}">
|
||||
<DownloadIcon />
|
||||
{text}
|
||||
</Button>
|
||||
<ExternalLinkWrapper domain={getDomain(url)} isVisible={!!url}>
|
||||
<Button class="rounded-l-none" href={url} target="_blank" rel="noreferrer noopener">
|
||||
<ExternalLinkIcon />
|
||||
</Button>
|
||||
</ExternalLinkWrapper>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<Card title="Actions" isStackable icon={{ component: DownloadIcon, class: 'rotate-180' }}>
|
||||
<div class="flex min-w-fit flex-col gap-2 p-2">
|
||||
<div class="flex w-full items-center gap-2 py-2 whitespace-nowrap">
|
||||
PNG size
|
||||
<ToggleGroup.Root type="single" variant="outline" bind:value={imageSizeMode}>
|
||||
<ToggleGroup.Item value="auto">Auto</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value="width">Width</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value="height">Height</ToggleGroup.Item>
|
||||
</ToggleGroup.Root>
|
||||
{#if imageSizeMode !== 'auto'}
|
||||
<WidthIcon
|
||||
class={['size-6 shrink-0 transition-all', imageSizeMode === 'width' && 'rotate-90']} />
|
||||
{/if}
|
||||
<Input
|
||||
type="number"
|
||||
min="3"
|
||||
max="10000"
|
||||
disabled={imageSizeMode === 'auto'}
|
||||
bind:value={imageSize} />
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{@render dualActionButton('PNG', onDownloadPNG, urls.current.png)}
|
||||
{@render dualActionButton('SVG', onDownloadSVG, urls.current.svg)}
|
||||
<ExternalLinkWrapper domain={getDomain(urls.current.kroki)} isVisible={!!urls.current.kroki}>
|
||||
<a target="_blank" rel="noreferrer" class="flex-grow" href={urls.current.kroki}>
|
||||
<Button class="action-btn flex w-full items-center gap-2">
|
||||
<ExternalLinkIcon /> Kroki
|
||||
</Button>
|
||||
</a>
|
||||
</ExternalLinkWrapper>
|
||||
</div>
|
||||
<Separator />
|
||||
{#if isClipboardAvailable()}
|
||||
<CopyButton onclick={onCopyClipboard} label="Copy Image" />
|
||||
{/if}
|
||||
<ExternalLinkWrapper
|
||||
labelPrefix="Thumbnail generated by"
|
||||
domain={getDomain(urls.current.png)}
|
||||
isVisible={!!urls.current.mdCode}>
|
||||
<CopyInput value={urls.current.mdCode} label="Copy Markdown" testID={TID.copyMarkdown} />
|
||||
</ExternalLinkWrapper>
|
||||
<div class="flex w-full items-center gap-2">
|
||||
<Input type="url" bind:value={gistURL} placeholder="Enter Gist URL" />
|
||||
<Button onclick={loadGist}>Load Gist</Button>
|
||||
</div>
|
||||
{#if isNetlify}
|
||||
<div class="flex w-full items-center justify-center">
|
||||
<a class="link text-sm text-gray-500 underline" href="https://netlify.com">
|
||||
This site is powered by Netlify
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Card>
|
||||
@@ -1,85 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { Tab } from '$/types';
|
||||
import type { Component, Snippet } from 'svelte';
|
||||
import { quintOut } from 'svelte/easing';
|
||||
import { slide } from 'svelte/transition';
|
||||
import CollapseAllIcon from '~icons/material-symbols/collapse-all-rounded';
|
||||
import Tabs from './Tabs.svelte';
|
||||
|
||||
interface Props {
|
||||
isClosable?: boolean;
|
||||
isOpen?: boolean;
|
||||
isStackable?: boolean;
|
||||
tabs?: Tab[];
|
||||
activeTabID?: string;
|
||||
title?: string;
|
||||
icon?: {
|
||||
component: Component;
|
||||
class?: string;
|
||||
};
|
||||
onselect?: (tab: Tab) => void;
|
||||
actions?: Snippet;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
isClosable = true,
|
||||
isOpen = false,
|
||||
isStackable = false,
|
||||
tabs = [],
|
||||
activeTabID = '',
|
||||
title,
|
||||
icon,
|
||||
onselect,
|
||||
actions,
|
||||
children
|
||||
}: Props = $props();
|
||||
|
||||
const toggleCardOpen = () => {
|
||||
if (isClosable) {
|
||||
isOpen = !isOpen;
|
||||
}
|
||||
};
|
||||
|
||||
let isTabsShown = $derived(isOpen && tabs.length > 0);
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={[
|
||||
'card flex h-fit flex-col overflow-hidden rounded-2xl border-2 border-muted',
|
||||
isOpen && 'isOpen flex-grow',
|
||||
isStackable ? 'flex-1 group-has-[.isOpen]:w-full group-has-[.isOpen]:flex-none' : 'w-full'
|
||||
]}>
|
||||
<div
|
||||
role="toolbar"
|
||||
tabindex="0"
|
||||
class={[
|
||||
'flex h-11 flex-none cursor-pointer items-center justify-between bg-muted p-2 whitespace-nowrap',
|
||||
isTabsShown && 'pb-1'
|
||||
]}
|
||||
onclick={toggleCardOpen}
|
||||
onkeypress={toggleCardOpen}>
|
||||
{#if icon || title}
|
||||
<span role="menubar" tabindex="0" class="flex w-fit items-center gap-3">
|
||||
{#if icon}
|
||||
<icon.component class={icon.class} />
|
||||
{/if}
|
||||
{title}
|
||||
</span>
|
||||
{/if}
|
||||
{#if isOpen && tabs && tabs.length > 0}
|
||||
<Tabs {onselect} {tabs} {activeTabID} />
|
||||
{/if}
|
||||
|
||||
{@render actions?.()}
|
||||
|
||||
{#if isOpen && isClosable}
|
||||
<CollapseAllIcon />
|
||||
{/if}
|
||||
</div>
|
||||
{#if isOpen}
|
||||
<div class="flex-grow overflow-x-auto" transition:slide={{ easing: quintOut }}>
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,51 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$/components/ui/button';
|
||||
import { Separator } from '$/components/ui/separator';
|
||||
import type { Tab } from '$lib/types';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
let {
|
||||
tabs,
|
||||
activeTabID,
|
||||
onselect
|
||||
}: {
|
||||
tabs: Tab[];
|
||||
activeTabID: string;
|
||||
onselect?: (tab: Tab) => void;
|
||||
} = $props();
|
||||
|
||||
// Derive (don't mutate the prop) so the highlight tracks a bound activeTabID.
|
||||
const effectiveTabID = $derived(activeTabID || tabs[0]?.id);
|
||||
|
||||
const toggleTabs = (tab: Tab) => {
|
||||
return (event: Event) => {
|
||||
event.stopPropagation();
|
||||
onselect?.(tab);
|
||||
};
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="flex w-fit cursor-default items-center gap-2">
|
||||
<ul class="flex gap-2 align-middle" transition:fade>
|
||||
{#each tabs as tab, index (tab.id)}
|
||||
<Button
|
||||
role="tab"
|
||||
variant="ghost"
|
||||
class={[
|
||||
'px-2',
|
||||
effectiveTabID === tab.id && 'rounded-b-none border-b-2 border-b-primary-foreground/50'
|
||||
]}
|
||||
onclick={toggleTabs(tab)}
|
||||
onkeypress={toggleTabs(tab)}>
|
||||
<tab.icon />
|
||||
{tab.title}
|
||||
</Button>
|
||||
|
||||
{#if index < tabs.length - 1}
|
||||
<div class="my-2">
|
||||
<Separator orientation="vertical" class="w-0.5 bg-slate-300" />
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1,40 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$/components/ui/button';
|
||||
import { notify } from '$/util/notify';
|
||||
import { scale } from 'svelte/transition';
|
||||
import CheckIcon from '~icons/material-symbols/check-rounded';
|
||||
import CopyIcon from '~icons/material-symbols/content-copy-outline-rounded';
|
||||
|
||||
let {
|
||||
onclick,
|
||||
label = 'Copy'
|
||||
}: { onclick: (event?: Event) => Promise<unknown>; label?: string } = $props();
|
||||
|
||||
let showCheckIcon = $state(false);
|
||||
</script>
|
||||
|
||||
<Button
|
||||
onclick={async (event) => {
|
||||
try {
|
||||
showCheckIcon = true;
|
||||
setTimeout(() => {
|
||||
showCheckIcon = false;
|
||||
}, 1000);
|
||||
await onclick(event);
|
||||
} catch {
|
||||
notify('Failed to copy');
|
||||
}
|
||||
}}>
|
||||
<div class="grid">
|
||||
{#key showCheckIcon}
|
||||
<span transition:scale class="col-start-1 row-start-1">
|
||||
{#if showCheckIcon}
|
||||
<CheckIcon />
|
||||
{:else}
|
||||
<CopyIcon />
|
||||
{/if}
|
||||
</span>
|
||||
{/key}
|
||||
</div>
|
||||
{label}
|
||||
</Button>
|
||||
@@ -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>
|
||||
@@ -1,273 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { EditorProps } from '$/types';
|
||||
import { env } from '$/util/env';
|
||||
import { urls, validatedState } from '$/util/state.svelte';
|
||||
import { logMermaidChartClick } from '$/util/stats';
|
||||
import { AIPromptViewZoneManager } from '$lib/util/AIPromptViewZoneManager';
|
||||
import { initEditor } from '$lib/util/monacoExtra';
|
||||
import { errorDebug } from '$lib/util/util';
|
||||
import { mode } from 'mode-watcher';
|
||||
import * as monaco from 'monaco-editor';
|
||||
import monacoEditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
|
||||
import monacoJsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
|
||||
import { onMount } from 'svelte';
|
||||
import AIPromptPopup from './AIPromptPopup.svelte';
|
||||
|
||||
const { onUpdate }: EditorProps = $props();
|
||||
|
||||
let divElement: HTMLDivElement | undefined = $state();
|
||||
let aiPromptPopupElement: HTMLDivElement | undefined = $state();
|
||||
let editor: monaco.editor.IStandaloneCodeEditor | undefined;
|
||||
let editorOptions = {
|
||||
minimap: {
|
||||
enabled: false
|
||||
},
|
||||
overviewRulerLanes: 0,
|
||||
glyphMargin: true,
|
||||
lineNumbersMinChars: 4
|
||||
} satisfies monaco.editor.IStandaloneEditorConstructionOptions;
|
||||
let currentText = '';
|
||||
let isUpdatingFromState = false;
|
||||
let showPopup = $state(false);
|
||||
let popupPosition = $state({ top: 0, lineNumber: 0 });
|
||||
let decorationsCollection: monaco.editor.IEditorDecorationsCollection | undefined;
|
||||
let input = $state('');
|
||||
let lastMouseLine = 0;
|
||||
const aiPromptManager = new AIPromptViewZoneManager();
|
||||
|
||||
const applyEditorTheme = (currentMode: typeof mode.current) => {
|
||||
if (!editor) return;
|
||||
monaco.editor.setTheme(`mermaid${currentMode === 'dark' ? '-dark' : ''}`);
|
||||
divElement?.classList.toggle('mermaid-dark', currentMode === 'dark');
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
applyEditorTheme(mode.current);
|
||||
});
|
||||
|
||||
const jsonModel = monaco.editor.createModel(
|
||||
'',
|
||||
'json',
|
||||
monaco.Uri.parse('internal://config.json')
|
||||
);
|
||||
const mermaidModel = monaco.editor.createModel(
|
||||
'',
|
||||
'mermaid',
|
||||
monaco.Uri.parse('internal://mermaid.mmd')
|
||||
);
|
||||
|
||||
const renderAIPromptGutterGlyphIcon = () => {
|
||||
decorationsCollection?.clear();
|
||||
if (!editor || showPopup) {
|
||||
return;
|
||||
}
|
||||
const model = editor.getModel();
|
||||
if (!model) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (lastMouseLine > 0 && model.id === mermaidModel.id) {
|
||||
decorationsCollection?.set([
|
||||
{
|
||||
range: new monaco.Range(lastMouseLine, 1, lastMouseLine, 1),
|
||||
options: {
|
||||
glyphMarginClassName: 'suggestion-icon'
|
||||
}
|
||||
}
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
const closePopup = () => {
|
||||
showPopup = false;
|
||||
input = '';
|
||||
aiPromptManager.hide();
|
||||
renderAIPromptGutterGlyphIcon();
|
||||
};
|
||||
|
||||
const toggleAIPopup = (lineNumber: number) => {
|
||||
if (!divElement || !aiPromptPopupElement) return;
|
||||
popupPosition = {
|
||||
top: 0,
|
||||
lineNumber
|
||||
};
|
||||
showPopup = !showPopup;
|
||||
if (showPopup) {
|
||||
aiPromptManager.show(popupPosition.lineNumber, aiPromptPopupElement, 100);
|
||||
editor?.setSelection(new monaco.Range(0, 0, 0, 0));
|
||||
} else {
|
||||
aiPromptManager.hide();
|
||||
}
|
||||
renderAIPromptGutterGlyphIcon();
|
||||
};
|
||||
|
||||
onMount(() => {
|
||||
self.MonacoEnvironment = {
|
||||
getWorker(_, label) {
|
||||
if (label === 'json') {
|
||||
return new monacoJsonWorker();
|
||||
}
|
||||
return new monacoEditorWorker();
|
||||
}
|
||||
};
|
||||
|
||||
if (!divElement) {
|
||||
throw new Error('divEl is undefined');
|
||||
}
|
||||
|
||||
monaco.json.jsonDefaults.setDiagnosticsOptions({
|
||||
validate: true,
|
||||
enableSchemaRequest: true,
|
||||
schemas: [
|
||||
{
|
||||
fileMatch: ['config.json'],
|
||||
uri: `${env.docsUrl}/schemas/config.schema.json`
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
initEditor(monaco);
|
||||
errorDebug();
|
||||
editor = monaco.editor.create(divElement, editorOptions);
|
||||
aiPromptManager.setEditor(editor);
|
||||
decorationsCollection = editor.createDecorationsCollection([]);
|
||||
|
||||
editor.onMouseDown((e) => {
|
||||
const isGutter = e.target.type === monaco.editor.MouseTargetType.GUTTER_GLYPH_MARGIN;
|
||||
if (isGutter && e.target.position?.lineNumber === lastMouseLine && lastMouseLine > 0) {
|
||||
e.event.preventDefault();
|
||||
e.event.stopPropagation();
|
||||
toggleAIPopup(e.target.position.lineNumber);
|
||||
}
|
||||
});
|
||||
|
||||
editor.onDidChangeModelContent(({ isFlush }) => {
|
||||
const newText = editor?.getValue();
|
||||
if (!newText || currentText === newText || isFlush || isUpdatingFromState) {
|
||||
return;
|
||||
}
|
||||
currentText = newText;
|
||||
onUpdate(currentText);
|
||||
});
|
||||
|
||||
editor.onMouseMove((e) => {
|
||||
if (!editor) return;
|
||||
if (showPopup) return;
|
||||
if (editor.getModel()?.id !== mermaidModel.id) return;
|
||||
|
||||
lastMouseLine = e.target.position?.lineNumber ?? 0;
|
||||
renderAIPromptGutterGlyphIcon();
|
||||
});
|
||||
|
||||
editor.onMouseLeave(() => {
|
||||
lastMouseLine = 0;
|
||||
renderAIPromptGutterGlyphIcon();
|
||||
});
|
||||
|
||||
applyEditorTheme(mode.current);
|
||||
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
editor?.layout({
|
||||
height: entries[0].contentRect.height,
|
||||
width: entries[0].contentRect.width
|
||||
});
|
||||
});
|
||||
|
||||
if (divElement.parentElement) {
|
||||
resizeObserver.observe(divElement);
|
||||
}
|
||||
|
||||
renderAIPromptGutterGlyphIcon();
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
jsonModel.dispose();
|
||||
mermaidModel.dispose();
|
||||
aiPromptManager.destroy();
|
||||
editor?.dispose();
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const { errorMarkers, editorMode, code, mermaid } = validatedState.current;
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const model = editorMode === 'code' ? mermaidModel : jsonModel;
|
||||
|
||||
if (editor.getModel()?.id !== model.id) {
|
||||
editor.setModel(model);
|
||||
renderAIPromptGutterGlyphIcon();
|
||||
}
|
||||
|
||||
// Clear decorations if not in 'code' mode, or if the model changes
|
||||
if (editorMode !== 'code' || editor.getModel()?.id !== mermaidModel.id) {
|
||||
decorationsCollection?.clear();
|
||||
}
|
||||
|
||||
// Update editor text if it's different
|
||||
const newText = editorMode === 'code' ? code : mermaid;
|
||||
if (newText !== currentText) {
|
||||
isUpdatingFromState = true;
|
||||
try {
|
||||
editor.setScrollTop(0);
|
||||
editor.pushUndoStop();
|
||||
editor.executeEdits('updateCode', [
|
||||
{
|
||||
range: model.getFullModelRange(),
|
||||
text: newText
|
||||
}
|
||||
]);
|
||||
editor.pushUndoStop();
|
||||
currentText = newText;
|
||||
} finally {
|
||||
isUpdatingFromState = false;
|
||||
}
|
||||
renderAIPromptGutterGlyphIcon();
|
||||
}
|
||||
|
||||
// Display/clear errors
|
||||
monaco.editor.setModelMarkers(model, 'mermaid', errorMarkers);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative h-full grow overflow-hidden">
|
||||
<div bind:this={divElement} id="editor" class="h-full w-full"></div>
|
||||
<div bind:this={aiPromptPopupElement}>
|
||||
<AIPromptPopup
|
||||
show={showPopup}
|
||||
bind:input
|
||||
onHeightChange={(height) => aiPromptManager.updateHeight(height)}
|
||||
onClose={closePopup}
|
||||
onTryFree={() => {
|
||||
logMermaidChartClick('vibeDiagramming');
|
||||
window.open(
|
||||
urls.current.mermaidChart({ medium: 'vibe_diagramming' }).save,
|
||||
'_blank',
|
||||
'noopener'
|
||||
);
|
||||
closePopup();
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
:global(.suggestion-icon) {
|
||||
background-color: #e8eaf9;
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
margin-left: 4px;
|
||||
background-image: url('/icons/use-chat.svg');
|
||||
background-size: 16px 16px;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:global(#editor.mermaid-dark .suggestion-icon) {
|
||||
background-color: #2e4d6b;
|
||||
background-image: url('/icons/use-chat-dark.svg');
|
||||
}
|
||||
</style>
|
||||
@@ -1,116 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$/components/ui/button';
|
||||
import { TID } from '$/constants';
|
||||
import type { DocumentationConfig } from '$/types';
|
||||
import { env } from '$/util/env';
|
||||
import { standardizeDiagramType } from '$/util/mermaid';
|
||||
import { validatedState } from '$/util/state.svelte';
|
||||
import BookIcon from '~icons/material-symbols/book-2-outline-rounded';
|
||||
|
||||
const docURLBase = env.docsUrl;
|
||||
const docMap = {
|
||||
architecture: {
|
||||
code: '/syntax/architecture.html'
|
||||
},
|
||||
block: {
|
||||
code: '/syntax/block.html'
|
||||
},
|
||||
c4: {
|
||||
code: '/syntax/c4.html'
|
||||
},
|
||||
class: {
|
||||
code: '/syntax/classDiagram.html',
|
||||
config: '/syntax/classDiagram.html#configuration'
|
||||
},
|
||||
er: {
|
||||
code: '/syntax/entityRelationshipDiagram.html',
|
||||
config: '/syntax/entityRelationshipDiagram.html#styling'
|
||||
},
|
||||
flowchart: {
|
||||
code: '/syntax/flowchart.html',
|
||||
config: '/syntax/flowchart.html#configuration'
|
||||
},
|
||||
gantt: {
|
||||
code: '/syntax/gantt.html',
|
||||
config: '/syntax/gantt.html#configuration'
|
||||
},
|
||||
gitGraph: {
|
||||
code: '/syntax/gitgraph.html',
|
||||
config: '/syntax/gitgraph.html#gitgraph-specific-configuration-options'
|
||||
},
|
||||
journey: {
|
||||
code: '/syntax/userJourney.html'
|
||||
},
|
||||
kanban: {
|
||||
code: '/syntax/kanban.html',
|
||||
config: '/syntax/kanban.html#configuration-options'
|
||||
},
|
||||
mindmap: {
|
||||
code: '/syntax/mindmap.html'
|
||||
},
|
||||
packet: {
|
||||
code: '/syntax/packet.html',
|
||||
config: '/config/schema-docs/config-defs-packet-diagram-config.html'
|
||||
},
|
||||
pie: {
|
||||
code: '/syntax/pie.html',
|
||||
config: '/syntax/pie.html#configuration'
|
||||
},
|
||||
quadrantChart: {
|
||||
code: '/syntax/quadrantChart.html',
|
||||
config: '/syntax/quadrantChart.html#chart-configurations'
|
||||
},
|
||||
requirement: {
|
||||
code: '/syntax/requirementDiagram.html'
|
||||
},
|
||||
sankey: {
|
||||
code: '/syntax/sankey.html',
|
||||
config: '/syntax/sankey.html#configuration'
|
||||
},
|
||||
sequence: {
|
||||
code: '/syntax/sequenceDiagram.html',
|
||||
config: '/syntax/sequenceDiagram.html#configuration'
|
||||
},
|
||||
stateDiagram: {
|
||||
code: '/syntax/stateDiagram.html'
|
||||
},
|
||||
timeline: {
|
||||
code: '/syntax/timeline.html',
|
||||
config: '/syntax/timeline.html#themes'
|
||||
},
|
||||
treemap: {
|
||||
code: '/syntax/treemap.html',
|
||||
config: '/syntax/treemap.html#configuration-options'
|
||||
},
|
||||
xychart: {
|
||||
code: '/syntax/xyChart.html',
|
||||
config: '/syntax/xyChart.html#chart-configurations'
|
||||
},
|
||||
zenuml: {
|
||||
code: '/syntax/zenuml.html'
|
||||
}
|
||||
} as const satisfies DocumentationConfig;
|
||||
|
||||
const doc = $derived.by(() => {
|
||||
const { editorMode, diagramType } = validatedState.current;
|
||||
if (!diagramType) {
|
||||
return { key: '', url: docURLBase };
|
||||
}
|
||||
const key = standardizeDiagramType(diagramType);
|
||||
const docConfig: { code: string; config?: string } = docMap[key as keyof typeof docMap] ?? {
|
||||
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>
|
||||
@@ -1,38 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$/components/ui/button';
|
||||
import * as Popover from '$/components/ui/popover';
|
||||
import type { Component } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
links: { title: string; href: string }[];
|
||||
icon?: Component;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let props: Props = $props();
|
||||
</script>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger class="flex items-center gap-0">
|
||||
<Button variant="ghost" size="sm">
|
||||
<props.icon class={props.class} />
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content>
|
||||
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
||||
<ul tabindex="0" class="flex flex-col">
|
||||
{#each props.links as { href, title } (title)}
|
||||
<li class="rounded-md p-2 hover:bg-muted">
|
||||
<a
|
||||
role="menuitem"
|
||||
tabindex="0"
|
||||
class="whitespace-nowrap underline"
|
||||
target="_blank"
|
||||
{href}>
|
||||
{title}
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -1,83 +0,0 @@
|
||||
<script lang="ts">
|
||||
import DesktopEditor from '$/components/DesktopEditor.svelte';
|
||||
import McWrapper from '$/components/McWrapper.svelte';
|
||||
import MermaidChartIcon from '$/components/MermaidChartIcon.svelte';
|
||||
import MobileEditor from '$/components/MobileEditor.svelte';
|
||||
import { Button } from '$/components/ui/button';
|
||||
import { TID } from '$/constants';
|
||||
import { env } from '$/util/env';
|
||||
import { updateCode, updateConfig, urls, validatedState } from '$lib/util/state.svelte';
|
||||
import { logMermaidChartClick } from '$lib/util/stats';
|
||||
import { debounce } from 'lodash-es';
|
||||
import ExclamationCircleIcon from '~icons/material-symbols/error-outline-rounded';
|
||||
|
||||
const { isMobile } = $props<{ isMobile: boolean }>();
|
||||
const onUpdate = (text: string) => {
|
||||
if (validatedState.current.editorMode === 'code') {
|
||||
updateCode(text);
|
||||
} else {
|
||||
updateConfig(text);
|
||||
}
|
||||
};
|
||||
|
||||
let showError = $state(false);
|
||||
|
||||
const showErrorDebounced = debounce(() => {
|
||||
showError = true;
|
||||
}, 3000);
|
||||
|
||||
$effect(() => {
|
||||
if (validatedState.current.error) {
|
||||
showErrorDebounced();
|
||||
} else {
|
||||
showErrorDebounced.cancel();
|
||||
showError = false;
|
||||
}
|
||||
|
||||
return () => {
|
||||
showErrorDebounced.cancel();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex h-full flex-col">
|
||||
{#if isMobile}
|
||||
<MobileEditor {onUpdate} />
|
||||
{:else}
|
||||
<DesktopEditor {onUpdate} />
|
||||
{/if}
|
||||
{#if showError && validatedState.current.error instanceof Error}
|
||||
<div class="flex flex-col text-sm" data-testid={TID.errorContainer}>
|
||||
<div class="flex items-center justify-between gap-2 bg-slate-900 p-2 text-white">
|
||||
<div class="flex w-fit items-center gap-2">
|
||||
<ExclamationCircleIcon class="size-6 text-destructive" aria-hidden="true" />
|
||||
<div class="flex flex-col">
|
||||
<p>Syntax error</p>
|
||||
{#if env.isEnabledMermaidChartLinks && validatedState.current.editorMode === 'code'}
|
||||
<p class="text-xs text-white/60" data-testid={TID.aiHelpText}>
|
||||
Create a free account to repair with AI
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if validatedState.current.editorMode === 'code'}
|
||||
<McWrapper>
|
||||
<Button
|
||||
variant="accent"
|
||||
size="sm"
|
||||
data-testid={TID.aiRepairButton}
|
||||
href={urls.current.mermaidChart({ medium: 'ai_repair' }).save}
|
||||
target="_blank"
|
||||
onclick={() => logMermaidChartClick('aiRepair')}>
|
||||
<MermaidChartIcon />
|
||||
AI Repair
|
||||
</Button>
|
||||
</McWrapper>
|
||||
{/if}
|
||||
</div>
|
||||
<output class="max-h-32 overflow-auto bg-muted p-2" name="mermaid-error" for="editor">
|
||||
<pre>{validatedState.current.error?.toString()}</pre>
|
||||
</output>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,119 +0,0 @@
|
||||
<script lang="ts">
|
||||
import McWrapper from '$/components/McWrapper.svelte';
|
||||
import MermaidChartIcon from '$/components/MermaidChartIcon.svelte';
|
||||
import { Button } from '$/components/ui/button';
|
||||
import { standardizeDiagramType } from '$/util/mermaid';
|
||||
import { validatedState, urls } from '$/util/state.svelte';
|
||||
import { logMermaidChartClick } from '$/util/stats';
|
||||
import { quintInOut } from 'svelte/easing';
|
||||
import { slide } from 'svelte/transition';
|
||||
|
||||
const visualEditDiagramTypes = new Set([
|
||||
'flowchart',
|
||||
'stateDiagram',
|
||||
'classDiagram',
|
||||
'sequenceDiagram',
|
||||
'er',
|
||||
'requirement',
|
||||
'mindmap'
|
||||
]);
|
||||
|
||||
const diagramType = $derived.by(() => {
|
||||
const dt = validatedState.current.diagramType;
|
||||
return dt ? standardizeDiagramType(dt) : undefined;
|
||||
});
|
||||
|
||||
const showVisualEdit = $derived.by(() => {
|
||||
return diagramType ? visualEditDiagramTypes.has(diagramType) : false;
|
||||
});
|
||||
|
||||
interface EnhancedEditAction {
|
||||
campaign: string;
|
||||
label: string;
|
||||
medium: 'ai_edit' | 'visual_edit' | 'voice_edit';
|
||||
source: string;
|
||||
}
|
||||
|
||||
const cycleIntervalMs = 30_000;
|
||||
|
||||
let currentActionIndex = $state(0);
|
||||
|
||||
const availableActions = $derived.by<EnhancedEditAction[]>(() => {
|
||||
if (!validatedState.current.diagramType) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const actions: EnhancedEditAction[] = [
|
||||
{ campaign: 'voice_1', label: 'with voice', medium: 'voice_edit', source: 'voiceEdit' },
|
||||
{ campaign: 'ai_1', label: 'with AI', medium: 'ai_edit', source: 'aiEdit' }
|
||||
];
|
||||
|
||||
if (showVisualEdit) {
|
||||
actions.unshift({
|
||||
campaign: 'visual_1',
|
||||
label: 'visually',
|
||||
medium: 'visual_edit',
|
||||
source: 'visualEdit'
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
});
|
||||
|
||||
const currentAction = $derived.by(() => {
|
||||
const actions = availableActions;
|
||||
if (actions.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return actions[currentActionIndex % actions.length];
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const actionCount = availableActions.length;
|
||||
|
||||
if (actionCount === 0) {
|
||||
currentActionIndex = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentActionIndex >= actionCount) {
|
||||
currentActionIndex = 0;
|
||||
}
|
||||
|
||||
if (actionCount <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalID = setInterval(() => {
|
||||
currentActionIndex = (currentActionIndex + 1) % actionCount;
|
||||
}, cycleIntervalMs);
|
||||
|
||||
return () => clearInterval(intervalID);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if currentAction}
|
||||
<McWrapper>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
href={urls.current.mermaidChart({
|
||||
medium: currentAction.medium,
|
||||
campaign: currentAction.campaign
|
||||
}).save}
|
||||
target="_blank"
|
||||
onclick={() => logMermaidChartClick(currentAction.source)}>
|
||||
<MermaidChartIcon />
|
||||
Edit
|
||||
{#key currentAction.label}
|
||||
<span
|
||||
class="-ml-1"
|
||||
in:slide={{ axis: 'x', easing: quintInOut, delay: 400 }}
|
||||
out:slide={{ axis: 'x', easing: quintInOut }}>
|
||||
{currentAction.label}
|
||||
</span>
|
||||
{/key}
|
||||
</Button>
|
||||
</McWrapper>
|
||||
{/if}
|
||||
@@ -1,63 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { validatedState } from '$/util/state.svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import type { ComponentProps, Snippet } from 'svelte';
|
||||
import ExternalLinkIcon from '~icons/material-symbols/open-in-new-rounded';
|
||||
|
||||
let {
|
||||
children,
|
||||
domain,
|
||||
shouldCheckDiagramType = true,
|
||||
side = 'bottom',
|
||||
labelPrefix = 'Opens your diagram in',
|
||||
isVisible = true,
|
||||
sharesData = true,
|
||||
showPopup = true
|
||||
}: {
|
||||
children: Snippet;
|
||||
domain: string;
|
||||
shouldCheckDiagramType?: boolean;
|
||||
side?: ComponentProps<typeof Tooltip.Content>['side'];
|
||||
labelPrefix?: string;
|
||||
isVisible?: boolean;
|
||||
sharesData?: boolean;
|
||||
showPopup?: boolean;
|
||||
} = $props();
|
||||
|
||||
let shouldDisableComponent = $derived(
|
||||
shouldCheckDiagramType && validatedState.current.diagramType === 'zenuml'
|
||||
);
|
||||
</script>
|
||||
|
||||
{#if isVisible}
|
||||
<Tooltip.Provider>
|
||||
<Tooltip.Root delayDuration={100}>
|
||||
<Tooltip.Trigger>
|
||||
<div class={[shouldDisableComponent && 'pointer-events-none cursor-not-allowed grayscale']}>
|
||||
{@render children()}
|
||||
</div>
|
||||
</Tooltip.Trigger>
|
||||
{#if showPopup}
|
||||
<Tooltip.Content {side} class="bg-secondary shadow-xl">
|
||||
<div
|
||||
class="flex cursor-help items-center gap-2"
|
||||
title={sharesData
|
||||
? 'Your diagram will be sent to the external service'
|
||||
: 'Your diagram is not shared'}>
|
||||
{#if shouldDisableComponent}
|
||||
<div class="text-muted-foreground">
|
||||
This diagram type is not supported in {domain}
|
||||
</div>
|
||||
{:else}
|
||||
<ExternalLinkIcon />
|
||||
<span class="flex items-center gap-1">
|
||||
{labelPrefix}
|
||||
<div class="text-accent">{domain}</div>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Tooltip.Content>
|
||||
{/if}
|
||||
</Tooltip.Root>
|
||||
</Tooltip.Provider>
|
||||
{/if}
|
||||
@@ -1,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>
|
||||
@@ -1,53 +0,0 @@
|
||||
<!--
|
||||
@component
|
||||
Exports a `waitForFontAwesomeToLoad` function that waits for FontAwesome to load.
|
||||
|
||||
Usage:
|
||||
|
||||
```svelte
|
||||
<script lang="ts">
|
||||
import FontAwesome from '$lib/client/components/FontAwesome';
|
||||
let waitForFontAwesomeToLoad: FontAwesome["waitForFontAwesomeToLoad"];
|
||||
</script>
|
||||
<FontAwesome bind:waitForFontAwesomeToLoad />
|
||||
```
|
||||
-->
|
||||
<script context="module" lang="ts">
|
||||
/**
|
||||
* Returns `true` if the code may contain FontAwesome icons, and we should
|
||||
* wait for the fonts to load before rendering.
|
||||
*/
|
||||
export function mayContainFontAwesome(code: string) {
|
||||
// taken from https://github.com/mermaid-js/mermaid/blob/7043892e871d0c413ec63dc1570a8ef738d15568/packages/mermaid/src/diagrams/flowchart/flowRenderer-v2.js#L63
|
||||
// Not ideal, since we're looking at unparsed code.
|
||||
const regex = /fa[blrs]?:fa-[\w-]+/g;
|
||||
return regex.test(code);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
// Vite will automatically take care of adding this to our `<head>`
|
||||
let lazyLoadFontAwesomeCSS = import('./FontAwesomeCSS.svelte');
|
||||
|
||||
let fontsLoaded = (async () => {
|
||||
await lazyLoadFontAwesomeCSS;
|
||||
|
||||
// Once the stylesheet has been parsed, the fonts will be in document.fonts
|
||||
// TODO: Maybe we should scan the CSS style sheet only, and only load FontAwesome fonts
|
||||
await Promise.allSettled(Array.from(document.fonts, (font) => font.load()));
|
||||
})();
|
||||
|
||||
/**
|
||||
* Wait for FontAwesome to load.
|
||||
*
|
||||
* @returns A promise that resolves when FontAwesome is
|
||||
* loaded, or there was an error that we ignore.
|
||||
*/
|
||||
export async function waitForFontAwesomeToLoad() {
|
||||
return await fontsLoaded;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#await lazyLoadFontAwesomeCSS then FontAwesomeCSS}
|
||||
<FontAwesomeCSS.default />
|
||||
{/await}
|
||||
@@ -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>
|
||||
@@ -1,209 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Card from '$lib/components/Card/Card.svelte';
|
||||
import type { HistoryEntry, HistoryType, State, Tab } from '$lib/types';
|
||||
import { notify, prompt } from '$lib/util/notify';
|
||||
import { serializeState } from '$lib/util/serde';
|
||||
import { inputState, replaceInputState } from '$lib/util/state.svelte';
|
||||
import { logEvent } from '$lib/util/stats';
|
||||
import dayjs from 'dayjs';
|
||||
import dayjsRelativeTime from 'dayjs/plugin/relativeTime';
|
||||
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 OpenInNewIcon from '~icons/material-symbols/open-in-new-rounded';
|
||||
import { Button } from '../ui/button';
|
||||
import { Separator } from '../ui/separator';
|
||||
import {
|
||||
addManualEntry,
|
||||
clearActive,
|
||||
historyState,
|
||||
removeEntry,
|
||||
restoreEntries,
|
||||
setMode
|
||||
} from './historyState.svelte';
|
||||
|
||||
dayjs.extend(dayjsRelativeTime);
|
||||
|
||||
const baseTabs: Tab[] = [
|
||||
{ id: 'manual', title: 'Saved', icon: BookmarkIcon },
|
||||
{ id: 'auto', title: 'Timeline', icon: HistoryIcon }
|
||||
];
|
||||
const loaderTab: Tab = { id: 'loader', title: 'Revisions', icon: GitAltIcon };
|
||||
|
||||
const tabs = $derived(
|
||||
historyState.loaderEntries.length > 0 ? [loaderTab, ...baseTabs] : baseTabs
|
||||
);
|
||||
|
||||
// Surface revisions once when they first appear; the user can switch away after.
|
||||
let revisionsShown = false;
|
||||
$effect(() => {
|
||||
if (historyState.loaderEntries.length > 0 && !revisionsShown) {
|
||||
revisionsShown = true;
|
||||
setMode('loader');
|
||||
}
|
||||
});
|
||||
|
||||
const emptyMessage = $derived(
|
||||
historyState.mode === 'auto'
|
||||
? 'No timeline snapshots yet.\nThe Timeline is saved automatically every minute.'
|
||||
: 'No saved states yet.\nClick the Save button to bookmark the current diagram and restore it later.'
|
||||
);
|
||||
|
||||
const tabSelectHandler = (tab: Tab) => {
|
||||
setMode(tab.id as HistoryType);
|
||||
};
|
||||
|
||||
const downloadHistory = () => {
|
||||
const data = historyState.entries;
|
||||
const blob = new Blob([JSON.stringify(data)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `mermaid-history-${dayjs().format('YYYY-MM-DD-HHmmss')}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
logEvent('history', { action: 'download' });
|
||||
};
|
||||
|
||||
const uploadHistory = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'application/json';
|
||||
input.addEventListener('change', async ({ target }: Event) => {
|
||||
const file = (target as HTMLInputElement)?.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
const data: HistoryEntry[] = JSON.parse(await file.text());
|
||||
const { restored, invalid, duplicates } = restoreEntries(data);
|
||||
notify(`${restored} restored, ${duplicates} duplicate, ${invalid} invalid.`);
|
||||
});
|
||||
input.click();
|
||||
};
|
||||
|
||||
const saveHistory = () => {
|
||||
if (!addManualEntry($state.snapshot(inputState))) {
|
||||
notify('State already saved.');
|
||||
}
|
||||
};
|
||||
|
||||
const clearAll = () => {
|
||||
if (prompt('Clear all saved items?')) {
|
||||
clearActive();
|
||||
}
|
||||
};
|
||||
|
||||
const restoreHistoryItem = (state: State): void => {
|
||||
replaceInputState({ ...state, updateDiagram: true });
|
||||
};
|
||||
|
||||
// Absolute editor URL for an entry, so the link can be opened in a new tab or copied.
|
||||
const entryUrl = (state: State): string =>
|
||||
`${window.location.origin}${window.location.pathname}#${serializeState(state)}`;
|
||||
|
||||
// Serialize each entry's URL once per change rather than per row on every render.
|
||||
const entriesWithUrl = $derived(
|
||||
historyState.entries.map((entry) => ({ ...entry, openUrl: entryUrl(entry.state) }))
|
||||
);
|
||||
</script>
|
||||
|
||||
<Card onselect={tabSelectHandler} isOpen isClosable={false} {tabs} activeTabID={historyState.mode}>
|
||||
{#snippet actions()}
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
id="uploadHistory"
|
||||
onclick={uploadHistory}
|
||||
title="Upload history"><UploadIcon /></Button>
|
||||
{#if historyState.entries.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 historyState.mode !== 'loader'}
|
||||
<Button
|
||||
id="clearHistory"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
class="hover:text-destructive"
|
||||
onclick={clearAll}
|
||||
title="Delete all saved states"><TrashAltIcon /></Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
<ul class="flex h-full min-w-fit flex-col gap-2 overflow-auto p-2" id="historyList">
|
||||
{#if entriesWithUrl.length > 0}
|
||||
{#each entriesWithUrl as { id, state, time, name, url, type, openUrl } (id)}
|
||||
<li class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex flex-col">
|
||||
{#if url}
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
title="Open revision in new tab"
|
||||
class="text-blue-500 hover:underline">{name}</a>
|
||||
{:else}
|
||||
<span class="whitespace-nowrap">{name}</span>
|
||||
{/if}
|
||||
<span class="text-xs whitespace-nowrap text-primary-foreground/30">
|
||||
{new Date(time).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm whitespace-nowrap text-primary-foreground/50">
|
||||
{dayjs(time).fromNow()}
|
||||
</span>
|
||||
<Button
|
||||
href={openUrl}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
title="Open in new tab">
|
||||
<OpenInNewIcon />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
title="Restore this version"
|
||||
onclick={() => restoreHistoryItem(state)}>
|
||||
<UndoIcon />
|
||||
</Button>
|
||||
{#if type !== 'loader'}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
class="hover:text-destructive"
|
||||
title="Delete this version"
|
||||
onclick={() => removeEntry(id)}>
|
||||
<TrashAltIcon />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<Separator />
|
||||
</li>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="m-2 text-center whitespace-pre-line">{emptyMessage}</div>
|
||||
{/if}
|
||||
</ul>
|
||||
</Card>
|
||||
@@ -1,177 +0,0 @@
|
||||
import type { HistoryEntry, HistoryType, Optional, State } from '$lib/types';
|
||||
import { persisted, readJSON, type Persisted } from '$lib/util/persist.svelte';
|
||||
import { inputState } from '$lib/util/state.svelte';
|
||||
import { logEvent } from '$lib/util/stats';
|
||||
import { generateSlug } from 'random-word-slugs';
|
||||
import { v4 as uuidV4 } from 'uuid';
|
||||
|
||||
const MAX_AUTO_HISTORY_LENGTH = 30;
|
||||
const AUTO_SAVE_INTERVAL = 60_000;
|
||||
|
||||
const auto = persisted<HistoryEntry[]>('autoHistoryStore', []);
|
||||
const manual = persisted<HistoryEntry[]>('manualHistoryStore', []);
|
||||
const mode = persisted<HistoryType>('autoHistoryMode', 'manual');
|
||||
let loader = $state<HistoryEntry[]>([]);
|
||||
|
||||
// Loader entries are in-memory, so a persisted 'loader' mode is empty after reload.
|
||||
if (mode.value === 'loader') {
|
||||
mode.value = 'manual';
|
||||
}
|
||||
|
||||
// The persisted slot backing a mode; loader is in-memory and has no slot.
|
||||
const slotFor = (m: HistoryType): Persisted<HistoryEntry[]> | null => {
|
||||
switch (m) {
|
||||
case 'auto': {
|
||||
return auto;
|
||||
}
|
||||
case 'manual': {
|
||||
return manual;
|
||||
}
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const historyState = {
|
||||
get entries(): HistoryEntry[] {
|
||||
return slotFor(mode.value)?.value ?? loader;
|
||||
},
|
||||
get loaderEntries(): HistoryEntry[] {
|
||||
return loader;
|
||||
},
|
||||
get mode(): HistoryType {
|
||||
return mode.value;
|
||||
}
|
||||
};
|
||||
|
||||
export const setMode = (next: HistoryType): void => {
|
||||
mode.value = next;
|
||||
};
|
||||
|
||||
// Dedup key: only the fields that define the diagram, so volatile/view-only
|
||||
// fields (renderCount, pan/zoom, …) don't count as a change.
|
||||
export const stateKey = (state: State): string =>
|
||||
JSON.stringify({ code: state.code, mermaid: state.mermaid });
|
||||
|
||||
const createEntry = (state: State, type: 'auto' | 'manual'): HistoryEntry => ({
|
||||
id: uuidV4(),
|
||||
name: generateSlug(2),
|
||||
state,
|
||||
time: Date.now(),
|
||||
type
|
||||
});
|
||||
|
||||
// Returns true if added, false if it duplicated the most recent entry.
|
||||
const addEntry = (
|
||||
slot: Persisted<HistoryEntry[]>,
|
||||
state: State,
|
||||
type: 'auto' | 'manual',
|
||||
maxLength?: number
|
||||
): boolean => {
|
||||
const entries = slot.value;
|
||||
if (entries.length > 0 && stateKey(entries[0].state) === stateKey(state)) {
|
||||
return false;
|
||||
}
|
||||
const trimmed =
|
||||
maxLength && entries.length >= maxLength ? entries.slice(0, maxLength - 1) : entries;
|
||||
slot.value = [createEntry(state, type), ...trimmed];
|
||||
logEvent('history', { action: 'save', type });
|
||||
return true;
|
||||
};
|
||||
|
||||
export const addManualEntry = (state: State): boolean => addEntry(manual, state, 'manual');
|
||||
|
||||
export const addAutoEntry = (state: State): boolean =>
|
||||
addEntry(auto, state, 'auto', MAX_AUTO_HISTORY_LENGTH);
|
||||
|
||||
// Replaces the in-memory revisions (e.g. when a gist is loaded), assigning ids.
|
||||
export const setLoaderEntries = (entries: Optional<HistoryEntry, 'id'>[]): void => {
|
||||
loader = entries.map((entry) =>
|
||||
entry.id ? (entry as HistoryEntry) : { ...entry, id: uuidV4() }
|
||||
);
|
||||
};
|
||||
|
||||
export const removeEntry = (id: string): void => {
|
||||
const slot = slotFor(mode.value);
|
||||
if (!slot) {
|
||||
return;
|
||||
}
|
||||
slot.value = slot.value.filter((entry) => entry.id !== id);
|
||||
logEvent('history', { action: 'clear', type: 'single' });
|
||||
};
|
||||
|
||||
export const clearActive = (): void => {
|
||||
const slot = slotFor(mode.value);
|
||||
if (!slot) {
|
||||
return;
|
||||
}
|
||||
slot.value = [];
|
||||
logEvent('history', { action: 'clear', type: 'all' });
|
||||
};
|
||||
|
||||
const validateEntry = (entry: HistoryEntry): boolean =>
|
||||
Boolean(entry && entry.type && entry.state) && typeof entry.time === 'number';
|
||||
|
||||
export interface RestoreResult {
|
||||
restored: number;
|
||||
invalid: number;
|
||||
duplicates: number;
|
||||
}
|
||||
|
||||
// Routes each uploaded entry to the store matching its own type, skipping ids
|
||||
// that already exist.
|
||||
export const restoreEntries = (data: HistoryEntry[]): RestoreResult => {
|
||||
const valid = data.filter((entry) => validateEntry(entry));
|
||||
const invalid = data.length - valid.length;
|
||||
let restored = 0;
|
||||
|
||||
const slots: [HistoryType, Persisted<HistoryEntry[]>][] = [
|
||||
['auto', auto],
|
||||
['manual', manual]
|
||||
];
|
||||
for (const [type, slot] of slots) {
|
||||
const incoming = valid.filter((entry) => entry.type === type);
|
||||
if (incoming.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const existingIDs = slot.value.map(({ id }) => id);
|
||||
const fresh = incoming.filter(({ id }) => !existingIDs.includes(id));
|
||||
restored += fresh.length;
|
||||
slot.value = [...slot.value, ...fresh].sort((a, b) => b.time - a.time);
|
||||
}
|
||||
|
||||
const duplicates = valid.length - restored;
|
||||
logEvent('history', { action: 'restore', duplicates, invalid, success: restored });
|
||||
return { restored, invalid, duplicates };
|
||||
};
|
||||
|
||||
const setIDs = (entries: HistoryEntry[]): HistoryEntry[] =>
|
||||
entries.map((entry) => (entry.id ? entry : { ...entry, id: uuidV4() }));
|
||||
|
||||
// One-time migration: re-reads localStorage so entries written by an older
|
||||
// version get ids, then persists and updates the reactive state.
|
||||
export const injectHistoryIDs = (): void => {
|
||||
auto.value = setIDs(readJSON<HistoryEntry[]>('autoHistoryStore', []));
|
||||
manual.value = setIDs(readJSON<HistoryEntry[]>('manualHistoryStore', []));
|
||||
};
|
||||
|
||||
let autoSaveTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
// Idempotent; returns the stop function for use as a lifecycle cleanup.
|
||||
export const startAutoSave = (): (() => void) => {
|
||||
if (autoSaveTimer === undefined) {
|
||||
autoSaveTimer = setInterval(
|
||||
() => addAutoEntry($state.snapshot(inputState)),
|
||||
AUTO_SAVE_INTERVAL
|
||||
);
|
||||
}
|
||||
return stopAutoSave;
|
||||
};
|
||||
|
||||
export const stopAutoSave = (): void => {
|
||||
if (autoSaveTimer !== undefined) {
|
||||
clearInterval(autoSaveTimer);
|
||||
autoSaveTimer = undefined;
|
||||
}
|
||||
};
|
||||
@@ -1,314 +0,0 @@
|
||||
import type { HistoryEntry } from '$lib/types';
|
||||
import { defaultState, replaceInputState } from '$lib/util/state.svelte';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
addAutoEntry,
|
||||
addManualEntry,
|
||||
clearActive,
|
||||
historyState,
|
||||
injectHistoryIDs,
|
||||
removeEntry,
|
||||
restoreEntries,
|
||||
setLoaderEntries,
|
||||
setMode,
|
||||
startAutoSave,
|
||||
stateKey,
|
||||
stopAutoSave
|
||||
} from './historyState.svelte';
|
||||
|
||||
const codeState = (code: string) => ({ ...defaultState, code });
|
||||
|
||||
/** Read the entries currently shown for a given mode. */
|
||||
const entriesFor = (mode: 'auto' | 'manual' | 'loader'): HistoryEntry[] => {
|
||||
setMode(mode);
|
||||
return historyState.entries;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset every store through the public API so tests don't leak into each other.
|
||||
setMode('manual');
|
||||
clearActive();
|
||||
setMode('auto');
|
||||
clearActive();
|
||||
setLoaderEntries([]);
|
||||
setMode('manual');
|
||||
});
|
||||
|
||||
describe('stateKey', () => {
|
||||
it('ignores volatile and view-only fields, keying only on code + config', () => {
|
||||
const a = {
|
||||
...defaultState,
|
||||
code: 'graph TD\n A-->B',
|
||||
panZoom: true,
|
||||
renderCount: 1,
|
||||
updateDiagram: true
|
||||
};
|
||||
const b = {
|
||||
...defaultState,
|
||||
code: 'graph TD\n A-->B',
|
||||
pan: { x: 5, y: 5 },
|
||||
panZoom: false,
|
||||
renderCount: 99,
|
||||
updateDiagram: false
|
||||
};
|
||||
expect(stateKey(a)).toBe(stateKey(b));
|
||||
});
|
||||
|
||||
it('differs when code differs', () => {
|
||||
expect(stateKey(codeState('graph TD\n A-->B'))).not.toBe(
|
||||
stateKey(codeState('graph TD\n A-->C'))
|
||||
);
|
||||
});
|
||||
|
||||
it('differs when config differs', () => {
|
||||
const a = { ...defaultState, mermaid: '{"theme":"dark"}' };
|
||||
const b = { ...defaultState, mermaid: '{"theme":"forest"}' };
|
||||
expect(stateKey(a)).not.toBe(stateKey(b));
|
||||
});
|
||||
});
|
||||
|
||||
describe('addManualEntry', () => {
|
||||
it('adds to the manual store only, never the auto store', () => {
|
||||
expect(addManualEntry(codeState('graph TD\n A-->B'))).toBe(true);
|
||||
expect(entriesFor('manual')).toHaveLength(1);
|
||||
expect(entriesFor('auto')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns false and does not add a duplicate of the latest entry', () => {
|
||||
const state = codeState('graph TD\n A-->B');
|
||||
expect(addManualEntry(state)).toBe(true);
|
||||
expect(addManualEntry(state)).toBe(false);
|
||||
expect(entriesFor('manual')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('treats states differing only in volatile/view fields as duplicates', () => {
|
||||
expect(addManualEntry({ ...defaultState, code: 'graph TD\n A-->B', renderCount: 1 })).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
addManualEntry({
|
||||
...defaultState,
|
||||
code: 'graph TD\n A-->B',
|
||||
panZoom: false,
|
||||
renderCount: 2,
|
||||
updateDiagram: true
|
||||
})
|
||||
).toBe(false);
|
||||
expect(entriesFor('manual')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('adds a new entry when the code changes', () => {
|
||||
expect(addManualEntry(codeState('graph TD\n A-->B'))).toBe(true);
|
||||
expect(addManualEntry(codeState('graph TD\n A-->C'))).toBe(true);
|
||||
expect(entriesFor('manual')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('generates an id and a name for each entry', () => {
|
||||
addManualEntry(codeState('graph TD\n A-->B'));
|
||||
const [entry] = entriesFor('manual');
|
||||
expect(entry.id).toBeTruthy();
|
||||
expect(entry.name).toBeTruthy();
|
||||
expect(entry.type).toBe('manual');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addAutoEntry', () => {
|
||||
it('adds to the auto store only, never the manual store', () => {
|
||||
expect(addAutoEntry(codeState('graph TD\n A-->B'))).toBe(true);
|
||||
expect(entriesFor('auto')).toHaveLength(1);
|
||||
expect(entriesFor('manual')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns false and does not add a duplicate of the latest entry', () => {
|
||||
const state = codeState('graph TD\n A-->B');
|
||||
expect(addAutoEntry(state)).toBe(true);
|
||||
expect(addAutoEntry(state)).toBe(false);
|
||||
expect(entriesFor('auto')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('caps the auto store at 30 entries, dropping the oldest', () => {
|
||||
for (let i = 0; i < 35; i++) {
|
||||
addAutoEntry(codeState(`graph TD\n A-->B${i}`));
|
||||
}
|
||||
const entries = entriesFor('auto');
|
||||
expect(entries).toHaveLength(30);
|
||||
expect(entries[0].state.code).toBe('graph TD\n A-->B34');
|
||||
});
|
||||
});
|
||||
|
||||
describe('historyState.entries', () => {
|
||||
it('reflects the active mode', () => {
|
||||
addManualEntry(codeState('manual-code'));
|
||||
addAutoEntry(codeState('auto-code'));
|
||||
|
||||
setMode('manual');
|
||||
expect(historyState.entries).toHaveLength(1);
|
||||
expect(historyState.entries[0].state.code).toBe('manual-code');
|
||||
|
||||
setMode('auto');
|
||||
expect(historyState.entries).toHaveLength(1);
|
||||
expect(historyState.entries[0].state.code).toBe('auto-code');
|
||||
|
||||
setMode('loader');
|
||||
expect(historyState.entries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeEntry / clearActive', () => {
|
||||
it('removes a single entry from the active store by id', () => {
|
||||
addManualEntry(codeState('graph TD\n A-->B'));
|
||||
addManualEntry(codeState('graph TD\n A-->C'));
|
||||
setMode('manual');
|
||||
const target = historyState.entries[1].id;
|
||||
removeEntry(target);
|
||||
expect(historyState.entries).toHaveLength(1);
|
||||
expect(historyState.entries.some((e) => e.id === target)).toBe(false);
|
||||
});
|
||||
|
||||
it('clears all entries in the active store only', () => {
|
||||
addManualEntry(codeState('graph TD\n A-->B'));
|
||||
addAutoEntry(codeState('graph TD\n A-->C'));
|
||||
setMode('manual');
|
||||
clearActive();
|
||||
expect(entriesFor('manual')).toHaveLength(0);
|
||||
expect(entriesFor('auto')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does nothing in loader mode', () => {
|
||||
setLoaderEntries([
|
||||
{ name: 'rev', state: defaultState, time: 1, type: 'loader', url: 'http://x' }
|
||||
]);
|
||||
setMode('loader');
|
||||
clearActive();
|
||||
expect(historyState.entries).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setLoaderEntries', () => {
|
||||
it('replaces the in-memory revisions and assigns ids', () => {
|
||||
setLoaderEntries([
|
||||
{ name: 'v1', state: defaultState, time: 1, type: 'loader', url: 'http://x/1' },
|
||||
{ name: 'v2', state: defaultState, time: 2, type: 'loader', url: 'http://x/2' }
|
||||
]);
|
||||
setMode('loader');
|
||||
expect(historyState.entries).toHaveLength(2);
|
||||
expect(historyState.entries.every((e) => e.id)).toBe(true);
|
||||
|
||||
setLoaderEntries([
|
||||
{ name: 'only', state: defaultState, time: 3, type: 'loader', url: 'http://x/3' }
|
||||
]);
|
||||
expect(historyState.entries).toHaveLength(1);
|
||||
expect(historyState.entries[0].name).toBe('only');
|
||||
});
|
||||
});
|
||||
|
||||
describe('restoreEntries', () => {
|
||||
it('routes each entry to the store matching its own type', () => {
|
||||
const result = restoreEntries([
|
||||
{ id: 'a1', name: 'a', state: defaultState, time: 10, type: 'auto' },
|
||||
{ id: 'm1', name: 'm', state: defaultState, time: 20, type: 'manual' }
|
||||
]);
|
||||
expect(result.restored).toBe(2);
|
||||
expect(entriesFor('auto').map((e) => e.id)).toEqual(['a1']);
|
||||
expect(entriesFor('manual').map((e) => e.id)).toEqual(['m1']);
|
||||
});
|
||||
|
||||
it('skips duplicates by id and reports them', () => {
|
||||
restoreEntries([{ id: 'm1', name: 'm', state: defaultState, time: 20, type: 'manual' }]);
|
||||
const result = restoreEntries([
|
||||
{ id: 'm1', name: 'm', state: defaultState, time: 20, type: 'manual' },
|
||||
{ id: 'm2', name: 'm2', state: defaultState, time: 30, type: 'manual' }
|
||||
]);
|
||||
expect(result.restored).toBe(1);
|
||||
expect(result.duplicates).toBe(1);
|
||||
expect(entriesFor('manual')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('reports invalid entries and does not restore them', () => {
|
||||
const result = restoreEntries([
|
||||
{ id: 'm1', name: 'm', state: defaultState, time: 20, type: 'manual' },
|
||||
{ foo: 'bar' } as unknown as HistoryEntry
|
||||
]);
|
||||
expect(result.restored).toBe(1);
|
||||
expect(result.invalid).toBe(1);
|
||||
});
|
||||
|
||||
it('sorts restored entries newest first', () => {
|
||||
restoreEntries([
|
||||
{ id: 'm1', name: 'old', state: defaultState, time: 10, type: 'manual' },
|
||||
{ id: 'm2', name: 'new', state: defaultState, time: 30, type: 'manual' },
|
||||
{ id: 'm3', name: 'mid', state: defaultState, time: 20, type: 'manual' }
|
||||
]);
|
||||
expect(entriesFor('manual').map((e) => e.time)).toEqual([30, 20, 10]);
|
||||
});
|
||||
|
||||
it('restores entries whose time is 0 (epoch) instead of treating them as invalid', () => {
|
||||
const result = restoreEntries([
|
||||
{ id: 'm0', name: 'epoch', state: defaultState, time: 0, type: 'manual' }
|
||||
]);
|
||||
expect(result.restored).toBe(1);
|
||||
expect(result.invalid).toBe(0);
|
||||
expect(entriesFor('manual')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('injectHistoryIDs migration', () => {
|
||||
it('adds ids to persisted entries that lack them', () => {
|
||||
window.localStorage.setItem(
|
||||
'manualHistoryStore',
|
||||
'[{"state":{"code":"a"},"time":1,"type":"manual","name":"x"}]'
|
||||
);
|
||||
window.localStorage.setItem(
|
||||
'autoHistoryStore',
|
||||
'[{"state":{"code":"b"},"time":2,"type":"auto","name":"y"}]'
|
||||
);
|
||||
injectHistoryIDs();
|
||||
const manual = JSON.parse(
|
||||
window.localStorage.getItem('manualHistoryStore') ?? '[]'
|
||||
) as HistoryEntry[];
|
||||
const auto = JSON.parse(
|
||||
window.localStorage.getItem('autoHistoryStore') ?? '[]'
|
||||
) as HistoryEntry[];
|
||||
expect(manual).toHaveLength(1);
|
||||
expect(auto).toHaveLength(1);
|
||||
expect(manual.every(({ id }) => id !== undefined)).toBe(true);
|
||||
expect(auto.every(({ id }) => id !== undefined)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto-save lifecycle', () => {
|
||||
afterEach(() => {
|
||||
stopAutoSave();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('records an auto entry on each interval from the current editor state', () => {
|
||||
vi.useFakeTimers();
|
||||
replaceInputState(codeState('graph TD\n auto-saved'));
|
||||
startAutoSave();
|
||||
vi.advanceTimersByTime(60_000);
|
||||
const entries = entriesFor('auto');
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0].state.code).toBe('graph TD\n auto-saved');
|
||||
});
|
||||
|
||||
it('is idempotent: calling startAutoSave twice does not double-record', () => {
|
||||
vi.useFakeTimers();
|
||||
replaceInputState(codeState('graph TD\n once'));
|
||||
startAutoSave();
|
||||
startAutoSave();
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(entriesFor('auto')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('stops recording after stopAutoSave', () => {
|
||||
vi.useFakeTimers();
|
||||
replaceInputState(codeState('graph TD\n stoppable'));
|
||||
startAutoSave();
|
||||
vi.advanceTimersByTime(60_000);
|
||||
stopAutoSave();
|
||||
replaceInputState(codeState('graph TD\n after-stop'));
|
||||
vi.advanceTimersByTime(60_000);
|
||||
expect(entriesFor('auto')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,143 +0,0 @@
|
||||
<script lang="ts">
|
||||
import McWrapper from '$/components/McWrapper.svelte';
|
||||
import * as Popover from '$/components/ui/popover';
|
||||
import { Switch } from '$/components/ui/switch';
|
||||
import { env } from '$/util/env';
|
||||
import { urls } from '$/util/state.svelte';
|
||||
import { logMermaidChartClick } from '$/util/stats';
|
||||
import { cn } from '$/utils';
|
||||
import { mode, setMode } from 'mode-watcher';
|
||||
import type { Component, Snippet } from 'svelte';
|
||||
import MermaidTailIcon from '~icons/custom/mermaid-tail';
|
||||
import AddIcon from '~icons/material-symbols/add-2-rounded';
|
||||
import BookIcon from '~icons/material-symbols/book-2-outline-rounded';
|
||||
import DuplicateIcon from '~icons/material-symbols/content-copy-outline-rounded';
|
||||
import ContrastIcon from '~icons/material-symbols/contrast';
|
||||
import PluginIcon from '~icons/material-symbols/electrical-services-rounded';
|
||||
import MenuIcon from '~icons/material-symbols/menu-rounded';
|
||||
import CommunityIcon from '~icons/material-symbols/person-play-outline-rounded';
|
||||
import PlaygroundIcon from '~icons/material-symbols/shape-line-outline';
|
||||
import MermaidChartIcon from './MermaidChartIcon.svelte';
|
||||
|
||||
interface MenuItem {
|
||||
label: string;
|
||||
icon: Component;
|
||||
href: string;
|
||||
class?: string;
|
||||
onclick?: () => void;
|
||||
sharesData?: boolean;
|
||||
checkDiagramType?: boolean;
|
||||
isSectionEnd?: boolean;
|
||||
renderer: Snippet<[Omit<MenuItem, 'renderer'>]>;
|
||||
}
|
||||
|
||||
const menuItems: MenuItem[] = $derived([
|
||||
{ label: 'New', icon: AddIcon, href: urls.current.new, renderer: menuItem },
|
||||
{ label: 'Duplicate', icon: DuplicateIcon, href: window.location.href, renderer: menuItem },
|
||||
{
|
||||
href: urls.current.mermaidChart({ medium: 'main_menu' }).playground,
|
||||
icon: PlaygroundIcon,
|
||||
isSectionEnd: true,
|
||||
label: 'Edit in Playground',
|
||||
onclick: () => logMermaidChartClick('editInPlayground'),
|
||||
renderer: mcMenuItem
|
||||
},
|
||||
{
|
||||
label: 'Mermaid.js',
|
||||
icon: MermaidTailIcon,
|
||||
href: env.docsUrl,
|
||||
renderer: menuItem
|
||||
},
|
||||
{
|
||||
label: 'Documentation',
|
||||
icon: BookIcon,
|
||||
href: `${env.docsUrl}/intro/`,
|
||||
renderer: menuItem
|
||||
},
|
||||
{
|
||||
label: 'Community',
|
||||
icon: CommunityIcon,
|
||||
href: 'https://discord.gg/sKeNQX4Wtj',
|
||||
renderer: menuItem
|
||||
},
|
||||
{
|
||||
checkDiagramType: false,
|
||||
href: urls.current.mermaidChart({ medium: 'main_menu' }).plugins,
|
||||
icon: PluginIcon,
|
||||
label: 'Plugins',
|
||||
onclick: () => logMermaidChartClick('plugins'),
|
||||
renderer: mcMenuItem,
|
||||
sharesData: false
|
||||
},
|
||||
{
|
||||
href: '#',
|
||||
icon: ContrastIcon,
|
||||
isSectionEnd: true,
|
||||
label: 'Dark Mode',
|
||||
renderer: darkModeMenuItem
|
||||
},
|
||||
{
|
||||
checkDiagramType: false,
|
||||
class: 'text-accent border-b-0',
|
||||
href: urls.current.mermaidChart({ medium: 'main_menu' }).home,
|
||||
icon: MermaidChartIcon,
|
||||
label: 'Mermaid',
|
||||
onclick: () => logMermaidChartClick('mermaidHome'),
|
||||
renderer: mcMenuItem,
|
||||
sharesData: false
|
||||
}
|
||||
]);
|
||||
</script>
|
||||
|
||||
{#snippet menuItem(options: Omit<MenuItem, 'renderer'>)}
|
||||
<a
|
||||
href={options.href}
|
||||
target="_blank"
|
||||
onclick={options.onclick}
|
||||
class={cn(
|
||||
'flex items-center justify-start gap-2 border-b-2 p-2 px-3 hover:bg-muted',
|
||||
options.isSectionEnd && 'border-border-dark',
|
||||
options.class
|
||||
)}>
|
||||
<options.icon class="size-5" />
|
||||
{options.label}
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
{#snippet mcMenuItem(item: Omit<MenuItem, 'renderer'>)}
|
||||
<McWrapper
|
||||
side="right"
|
||||
labelPrefix={item.sharesData === false ? 'Opens a new tab in' : undefined}
|
||||
sharesData={item.sharesData}
|
||||
shouldCheckDiagramType={item.checkDiagramType}>
|
||||
{@render menuItem(item)}
|
||||
</McWrapper>
|
||||
{/snippet}
|
||||
|
||||
{#snippet darkModeMenuItem(options: Omit<MenuItem, 'renderer'>)}
|
||||
<div
|
||||
class={cn(
|
||||
'flex cursor-pointer items-center justify-between border-b-2 px-3 py-2 hover:bg-muted',
|
||||
options.isSectionEnd && 'border-border-dark',
|
||||
options.class
|
||||
)}>
|
||||
<span class="flex items-center gap-2">
|
||||
<ContrastIcon />
|
||||
Dark Mode
|
||||
</span>
|
||||
<Switch
|
||||
checked={mode.current === 'dark'}
|
||||
onCheckedChange={(dark) => setMode(dark ? 'dark' : 'light')} />
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger class="shrink-0">
|
||||
<MenuIcon class="size-6" />
|
||||
</Popover.Trigger>
|
||||
<Popover.Content align="start" class="flex flex-col overflow-hidden border-2 p-0" sideOffset={16}>
|
||||
{#each menuItems as { renderer, ...item } (item.label)}
|
||||
{@render renderer(item)}
|
||||
{/each}
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -1,24 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { env } from '$/util/env';
|
||||
import { isOnMermaidAI } from '$/util/migration/domainMigration';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import ExternalLinkWrapper from './ExternalLinkWrapper.svelte';
|
||||
|
||||
let {
|
||||
children,
|
||||
...props
|
||||
}: Omit<
|
||||
ComponentProps<typeof ExternalLinkWrapper>,
|
||||
'isVisible' | 'domain' | 'showPopup'
|
||||
> = $props();
|
||||
|
||||
const mermaidChartDomain = 'mermaid.ai';
|
||||
</script>
|
||||
|
||||
<ExternalLinkWrapper
|
||||
{...props}
|
||||
domain={mermaidChartDomain}
|
||||
isVisible={env.isEnabledMermaidChartLinks}
|
||||
showPopup={!isOnMermaidAI()}>
|
||||
{@render children()}
|
||||
</ExternalLinkWrapper>
|
||||
@@ -1,8 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { asset } from '$app/paths';
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
|
||||
let { class: className }: { class?: ClassValue } = $props();
|
||||
</script>
|
||||
|
||||
<img class={['size-4', className]} src={asset('/mermaidchart-logo.svg')} alt="Mermaid Chart" />
|
||||
@@ -1,100 +0,0 @@
|
||||
<script lang="ts">
|
||||
import type { EditorProps } from '$/types';
|
||||
import { validatedState } from '$/util/state.svelte';
|
||||
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;
|
||||
// Deliberately not $state: the sync effect below both reads and writes it,
|
||||
// so a reactive currentText would make every keystroke re-run the effect
|
||||
// against the not-yet-revalidated state and revert the user's input.
|
||||
let currentText = '';
|
||||
const themeCompartment = new Compartment();
|
||||
const languageCompartment = new Compartment();
|
||||
|
||||
const { onUpdate }: EditorProps = $props();
|
||||
|
||||
$effect(() => {
|
||||
editorView?.dispatch({
|
||||
effects: themeCompartment.reconfigure(mode.current === 'dark' ? vsCodeDark : vsCodeLight)
|
||||
});
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
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
|
||||
});
|
||||
|
||||
return () => {
|
||||
editorView?.destroy();
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const { editorMode, code, mermaid } = validatedState.current;
|
||||
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() })
|
||||
)
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<div bind:this={editorContainer} class="size-full"></div>
|
||||
@@ -1,102 +0,0 @@
|
||||
<script lang="ts" module>
|
||||
import { logEvent, logMermaidChartClick } from '$lib/util/stats';
|
||||
import { version } from 'mermaid/package.json';
|
||||
|
||||
void logEvent('version', {
|
||||
mermaidVersion: version
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { resolve } from '$app/paths';
|
||||
import MainMenu from '$/components/MainMenu.svelte';
|
||||
import { Button } from '$/components/ui/button';
|
||||
import { Separator } from '$/components/ui/separator';
|
||||
import { dismissPromotion, getActivePromotion } from '$lib/util/promos/promo.svelte';
|
||||
import { untrack, type ComponentProps, type Snippet } from 'svelte';
|
||||
import MermaidIcon from '~icons/custom/mermaid';
|
||||
import CloseIcon from '~icons/material-symbols/close-rounded';
|
||||
import GithubIcon from '~icons/mdi/github';
|
||||
import DropdownNavMenu from './DropdownNavMenu.svelte';
|
||||
|
||||
interface Props {
|
||||
mobileToggle?: Snippet;
|
||||
children: Snippet;
|
||||
hidePromotion?: boolean;
|
||||
}
|
||||
|
||||
let { children, mobileToggle, hidePromotion = false }: Props = $props();
|
||||
|
||||
type Links = ComponentProps<typeof DropdownNavMenu>['links'];
|
||||
|
||||
const githubLinks: Links = [
|
||||
{ title: 'Mermaid JS', href: 'https://github.com/mermaid-js/mermaid' },
|
||||
{
|
||||
title: 'Mermaid Live Editor',
|
||||
href: 'https://github.com/mermaid-js/mermaid-live-editor'
|
||||
},
|
||||
{
|
||||
title: 'Mermaid CLI',
|
||||
href: 'https://github.com/mermaid-js/mermaid-cli'
|
||||
}
|
||||
];
|
||||
|
||||
let activePromotion = $state(untrack(() => (hidePromotion ? undefined : getActivePromotion())));
|
||||
|
||||
const trackBannerClick = () => {
|
||||
if (!activePromotion) {
|
||||
return;
|
||||
}
|
||||
logEvent('bannerClick', {
|
||||
promotion: activePromotion.id
|
||||
});
|
||||
logMermaidChartClick('banner');
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if activePromotion}
|
||||
<div class="top-bar z-10 flex h-fit w-full bg-primary">
|
||||
<div
|
||||
class="flex grow"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={trackBannerClick}
|
||||
onkeypress={trackBannerClick}>
|
||||
<activePromotion.component {closeBanner} />
|
||||
</div>
|
||||
{#snippet closeBanner()}
|
||||
<Button
|
||||
title="Dismiss banner"
|
||||
variant="ghost"
|
||||
class="hover:bg-transparent hover:text-[#261A56]"
|
||||
size="sm"
|
||||
onclick={() => {
|
||||
dismissPromotion(activePromotion?.id);
|
||||
activePromotion = undefined;
|
||||
}}>
|
||||
<CloseIcon />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<nav class="z-50 flex p-4 sm:p-6">
|
||||
<div class="flex flex-1 items-center gap-2">
|
||||
<MainMenu />
|
||||
<MermaidIcon class="size-6" />
|
||||
<a href={resolve('/', {})} class="whitespace-nowrap text-accent">
|
||||
{#if !mobileToggle}
|
||||
Mermaid
|
||||
{/if}
|
||||
Live Editor
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
id="menu"
|
||||
class="hidden flex-nowrap items-center justify-between gap-3 overflow-hidden md:flex">
|
||||
<DropdownNavMenu icon={GithubIcon} links={githubLinks} />
|
||||
<Separator orientation="vertical" />
|
||||
{@render children()}
|
||||
</div>
|
||||
{@render mobileToggle?.()}
|
||||
</nav>
|
||||
@@ -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 { urls } from '$/util/state.svelte';
|
||||
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={urls.current.view} target="_blank">
|
||||
<ExpandIcon />
|
||||
</Button>
|
||||
</FloatingToolbar>
|
||||
@@ -1,110 +0,0 @@
|
||||
<script lang="ts">
|
||||
import Card from '$/components/Card/Card.svelte';
|
||||
import { Button, buttonVariants } from '$/components/ui/button';
|
||||
import * as Popover from '$/components/ui/popover';
|
||||
import { getSampleDiagrams, type SampleExample } from '$/util/mermaid';
|
||||
import { updateCode } from '$lib/util/state.svelte';
|
||||
import { logEvent } from '$lib/util/stats';
|
||||
import { cn } from '$lib/utils';
|
||||
import ShapesIcon from '~icons/material-symbols/account-tree-outline-rounded';
|
||||
import ChevronDownIcon from '~icons/material-symbols/keyboard-arrow-down-rounded';
|
||||
|
||||
const extras: Record<string, SampleExample[]> = {
|
||||
ZenUML: [
|
||||
{
|
||||
title: 'Order Service',
|
||||
isDefault: true,
|
||||
code: `zenuml
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
const samples = { ...getSampleDiagrams(), ...extras };
|
||||
|
||||
const loadSampleDiagram = (diagramType: string, example: SampleExample): void => {
|
||||
updateCode(example.code, {
|
||||
resetPanZoom: true,
|
||||
updateDiagram: true
|
||||
});
|
||||
logEvent('loadSampleDiagram', { diagramType, exampleTitle: example.title });
|
||||
};
|
||||
|
||||
const mainDiagrams = [
|
||||
'Flowchart',
|
||||
'Class',
|
||||
'Sequence',
|
||||
'Entity Relationship',
|
||||
'State',
|
||||
'Mindmap'
|
||||
];
|
||||
|
||||
const diagramOrder = [
|
||||
...mainDiagrams,
|
||||
...Object.keys(samples)
|
||||
.filter((key) => !mainDiagrams.includes(key))
|
||||
.sort()
|
||||
];
|
||||
</script>
|
||||
|
||||
<Card title="Sample Diagrams" isOpen isStackable icon={{ component: ShapesIcon }}>
|
||||
<div class="flex h-fit max-h-52 flex-wrap gap-2 overflow-y-auto p-2">
|
||||
{#each diagramOrder as sample (sample)}
|
||||
{@const examples = samples[sample]}
|
||||
<div class="flex min-w-20 flex-grow">
|
||||
<Button
|
||||
size="sm"
|
||||
class={cn('flex-grow normal-case', examples.length > 1 && 'rounded-r-none')}
|
||||
onclick={() => loadSampleDiagram(sample, examples[0])}>
|
||||
{sample}
|
||||
</Button>
|
||||
{#if examples.length > 1}
|
||||
<Popover.Root>
|
||||
<Popover.Trigger
|
||||
aria-label="Choose a {sample} example"
|
||||
class={cn(
|
||||
buttonVariants({ size: 'sm' }),
|
||||
'rounded-l-none border-l border-primary-foreground/30 px-0.5 [&_svg]:size-5'
|
||||
)}>
|
||||
<ChevronDownIcon />
|
||||
</Popover.Trigger>
|
||||
<Popover.Content align="start" class="flex w-fit flex-col gap-1 p-1">
|
||||
{#each examples as example (example.title)}
|
||||
<Popover.Close
|
||||
class={cn(
|
||||
buttonVariants({ variant: 'ghost', size: 'sm' }),
|
||||
'justify-start normal-case'
|
||||
)}
|
||||
onclick={() => loadSampleDiagram(sample, example)}>
|
||||
{example.title}
|
||||
</Popover.Close>
|
||||
{/each}
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</Card>
|
||||
@@ -1,66 +0,0 @@
|
||||
<script lang="ts">
|
||||
import ExternalLinkWrapper from '$/components/ExternalLinkWrapper.svelte';
|
||||
import * as Dialog from '$/components/ui/dialog';
|
||||
import { env } from '$/util/env';
|
||||
import { isOnMermaidLive } from '$/util/migration/domainMigration';
|
||||
import ShieldIcon from '~icons/material-symbols/shield-lock-outline-rounded';
|
||||
</script>
|
||||
|
||||
{#if env.privacyPolicyUrl}
|
||||
<a href={env.privacyPolicyUrl} target="_blank">
|
||||
<ShieldIcon />
|
||||
</a>
|
||||
{:else}
|
||||
<Dialog.Root>
|
||||
<Dialog.Trigger>
|
||||
<ShieldIcon />
|
||||
</Dialog.Trigger>
|
||||
<Dialog.Content class="max-h-full overflow-hidden overflow-y-auto p-12">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title class="flex items-center gap-2 text-xl">
|
||||
<ShieldIcon class="size-8 text-green-700" />
|
||||
Data security
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
{#if isOnMermaidLive()}
|
||||
<p class="text-xl font-semibold">Your diagrams never leave your browser.</p>
|
||||
<p>They're only stored in the URL and your browser's local storage.</p>
|
||||
<p>
|
||||
This is a fully open source, client-side app deployed on <a
|
||||
href="https://github.com/mermaid-js/mermaid-live-editor/deployments"
|
||||
class="underline"
|
||||
target="_blank">GitHub Pages</a>
|
||||
that works offline as a
|
||||
<a href="https://web.dev/explore/progressive-web-apps" target="_blank"
|
||||
>Progressive Web App</a
|
||||
>.
|
||||
</p>
|
||||
<p>
|
||||
We use self hosted, privacy-friendly Plausible Analytics to collect anonymous usage
|
||||
metadata (diagram types, feature usage, etc.). All data is <a
|
||||
href="https://p.mermaid.live/mermaid.live"
|
||||
class="underline"
|
||||
target="_blank">publicly available</a
|
||||
>.
|
||||
</p>
|
||||
<ExternalLinkWrapper domain="example.com" isVisible>
|
||||
<p class="text-left">
|
||||
External services (PNG/SVG/Kroki exports, "Save to Mermaid Chart", "Repair with AI",
|
||||
etc) will share your diagram with those 3rd parties, and are highlighted in the UI on
|
||||
hover.
|
||||
</p>
|
||||
</ExternalLinkWrapper>
|
||||
{:else}
|
||||
<p>No privacy policy has been configured for this deployment.</p>
|
||||
<p>
|
||||
If you are self-hosting the Mermaid Live Editor, set the
|
||||
<code class="rounded bg-muted px-1.5 py-0.5 text-sm">MERMAID_PRIVACY_POLICY_URL</code>
|
||||
environment variable at build time to link to your privacy policy, or set
|
||||
<code class="rounded bg-muted px-1.5 py-0.5 text-sm">MERMAID_HIDE_PRIVACY_POLICY</code>
|
||||
to <code class="rounded bg-muted px-1.5 py-0.5 text-sm">true</code> to hide this button.
|
||||
</p>
|
||||
{/if}
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
{/if}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user