diff --git a/.changeset/config.json b/.changeset/config.json new file mode 100644 index 0000000..0ffcf5b --- /dev/null +++ b/.changeset/config.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://unpkg.com/@changesets/config@3.0.5/schema.json", + "changelog": "@changesets/cli/changelog", + "commit": false, + "fixed": [ + [ + "@visimer/core", + "@visimer/dom", + "@visimer/react", + "@visimer/codemirror", + "@visimer/monaco" + ] + ], + "linked": [], + "access": "public", + "baseBranch": "main", + "updateInternalDependencies": "patch", + "ignore": ["playground", "js-playground", "site"] +} diff --git a/.changeset/rename-visimer.md b/.changeset/rename-visimer.md new file mode 100644 index 0000000..9be07c3 --- /dev/null +++ b/.changeset/rename-visimer.md @@ -0,0 +1,12 @@ +--- +"@visimer/core": major +"@visimer/dom": major +"@visimer/react": major +"@visimer/codemirror": major +"@visimer/monaco": major +--- + +Rename the library to visimer. Packages move from `@inkeep/mermaid-wysiwyg-*` to +scoped `@visimer/*` (`core`, `dom`, `react`, `codemirror`, `monaco`); the public +repo moves from `inkeep/mermaid-wysiwyg` to `inkeep/visimer`. Update imports and +install commands accordingly. diff --git a/.changeset/visimer-editing-polish.md b/.changeset/visimer-editing-polish.md new file mode 100644 index 0000000..06579e7 --- /dev/null +++ b/.changeset/visimer-editing-polish.md @@ -0,0 +1,23 @@ +--- +"@visimer/core": minor +"@visimer/dom": minor +"@visimer/react": minor +"@visimer/codemirror": minor +"@visimer/monaco": minor +--- + +Textbox-grade inline editing: the canvas holds re-renders while an in-place +label session is typing (live commits still sync the code surfaces), long +labels stay on one line instead of wrapping into the clip, Escape reverts +reliably, and a swap already in flight carries typed text and caret across. + +Popovers behave like part of the diagram: they anchor at the exact twin +element clicked, flip below the node instead of clipping in overflow-hidden +hosts, clamp horizontally, follow external pan/zoom transforms, and close on +any interaction outside the canvas. + +New opt-in `panZoom` canvas option (DOM + React bindings): fit-to-canvas by +default, drag empty space to pan, pinch or ctrl+wheel to zoom at the cursor, +and corner zoom controls. + +All packages are relicensed from GPL-3.0-or-later to MIT. diff --git a/.github/scripts/bridge-public-pr-to-monorepo.mjs b/.github/scripts/bridge-public-pr-to-monorepo.mjs new file mode 100644 index 0000000..664c53c --- /dev/null +++ b/.github/scripts/bridge-public-pr-to-monorepo.mjs @@ -0,0 +1,908 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +// Keep the public PR bridge copies code-shape aligned. They ship to +// separate public repos through Copybara, so they cannot import shared code. +// Sibling bridge copies: +// - public/agents/.github/scripts/bridge-public-pr-to-monorepo.mjs +// - public/agents-optional-local-dev/.github/scripts/bridge-public-pr-to-monorepo.mjs +// - public/open-knowledge/.github/scripts/bridge-public-pr-to-monorepo.mjs +const OSS_SYNC_BOT_NAME = 'inkeep-oss-sync[bot]'; +const OSS_SYNC_BOT_EMAIL = '274976938+inkeep-oss-sync[bot]@users.noreply.github.com'; + +// Strip x-access-token credentials from any string that might end up in an +// error message, log line, or thrown exception. GitHub Actions masks repo +// secrets in its own job log, but this script's exceptions can also surface in +// failure-alert issues or future error-reporting integrations — none of which +// inherit the Actions log mask. Defense-in-depth: redact at the boundary. +function sanitizeErrorMessage(value) { + if (typeof value !== 'string') return value; + return value.replace(/https:\/\/x-access-token:[^@\s]+@/g, 'https://x-access-token:***@'); +} + +function run(command, args, options = {}) { + try { + return execFileSync(command, args, { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + ...options, + }).trim(); + } catch (error) { + const stderr = sanitizeErrorMessage(error.stderr?.toString().trim() ?? ''); + const stdout = sanitizeErrorMessage(error.stdout?.toString().trim() ?? ''); + const details = [stderr, stdout].filter(Boolean).join('\n'); + const fallback = sanitizeErrorMessage(`${command} ${args.join(' ')} failed`); + throw new Error(details || fallback); + } +} + +async function githubRequest({ + token, + method = 'GET', + path: requestPath, + body, + accept = 'application/vnd.github+json', +}) { + const response = await fetch(`https://api.github.com${requestPath}`, { + method, + headers: { + Accept: accept, + Authorization: `Bearer ${token}`, + 'User-Agent': 'inkeep-public-pr-bridge', + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + + const text = await response.text(); + if (!response.ok) { + throw new Error(`${method} ${requestPath} failed (${response.status}): ${text}`); + } + + // .patch and .diff return raw text, not JSON. All other accept types + // (incl. the default application/vnd.github+json) return JSON. + const isTextResponse = + accept === 'application/vnd.github.patch' || accept === 'application/vnd.github.diff'; + return isTextResponse ? text : text ? JSON.parse(text) : null; +} + +async function githubGraphql({ token, query, variables }) { + const result = await githubRequest({ + token, + method: 'POST', + path: '/graphql', + body: { query, variables }, + }); + if (result?.errors?.length) { + const messages = result.errors.map((e) => e.message).join('; '); + throw new Error(`GraphQL error: ${messages}`); + } + return result; +} + +function requireEnv(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`Missing required environment variable: ${name}`); + } + return value; +} + +function getPublicPrBranchName(prefix, prNumber) { + return `${prefix}-${prNumber}`; +} + +function parseJsonEnv(name, fallback) { + const value = process.env[name]; + if (!value) { + return fallback; + } + + try { + return JSON.parse(value); + } catch (error) { + throw new Error(`Invalid JSON in ${name}: ${error.message}`); + } +} + +function publicPrAuthor(publicPr) { + return { + name: publicPr.user.login, + email: `${publicPr.user.id}+${publicPr.user.login}@users.noreply.github.com`, + }; +} + +function normalizeGitHubUserAuthor(user) { + const login = user?.login?.trim(); + const id = user?.id; + if (!login || id === undefined || id === null) return null; + return { + name: login, + email: `${id}+${login}@users.noreply.github.com`, + }; +} + +function normalizeCommitAuthor(author) { + const name = author?.name?.trim(); + const email = author?.email?.trim(); + if (!name || !email || !email.includes('@')) return null; + if (/[\r\n<>]/.test(name) || /[\r\n<>]/.test(email)) return null; + return { name, email }; +} + +function parseCoauthorTrailer(line) { + const match = line.match(/^Co-authored-by:\s*(.+?)\s*<([^<>\s]+@[^<>\s]+)>\s*$/i); + if (!match) return null; + return normalizeCommitAuthor({ name: match[1], email: match[2] }); +} + +function coauthorsFromCommitMessage(message) { + return normalizeCommitMessage(message) + .split('\n') + .map((line) => parseCoauthorTrailer(line.trim())) + .filter(Boolean); +} + +function uniqueCommitAuthors(authors) { + const unique = new Map(); + for (const author of authors) { + const normalized = normalizeCommitAuthor(author); + if (!normalized) continue; + unique.set(`${normalized.name.toLowerCase()} <${normalized.email.toLowerCase()}>`, normalized); + } + return [...unique.values()]; +} + +function normalizePublicPrCommit(commit) { + return { + sha: typeof commit?.sha === 'string' ? commit.sha : null, + author: normalizeGitHubUserAuthor(commit?.author) ?? commit?.commit?.author, + message: typeof commit?.commit?.message === 'string' ? commit.commit.message : '', + }; +} + +async function listPublicPrCommits({ token, repo, prNumber, request = githubRequest }) { + const publicCommits = []; + let page = 1; + while (true) { + const commits = await request({ + token, + path: `/repos/${repo}/pulls/${prNumber}/commits?per_page=100&page=${page}`, + }); + publicCommits.push(...commits.map((commit) => normalizePublicPrCommit(commit))); + if (commits.length < 100) break; + page++; + } + return publicCommits; +} + +function normalizeCommitMessage(message) { + if (typeof message !== 'string') return ''; + return message.replace(/\r\n?/g, '\n').replace(/\0/g, '').trim(); +} + +function formatOriginalCommitMessages(commitMessages, publicRepo) { + const entries = commitMessages + .map((commit) => { + const message = normalizeCommitMessage(commit?.message); + if (!message) return null; + const rawSha = typeof commit?.sha === 'string' ? commit.sha : ''; + const sha = /^[0-9a-f]{7,40}$/i.test(rawSha) ? rawSha : null; + const shortSha = sha ? sha.slice(0, 7) : null; + const author = normalizeCommitAuthor(commit?.author); + return { author, sha, shortSha, message }; + }) + .filter(Boolean); + + if (entries.length === 0) return ''; + + const formatted = entries.map((entry) => { + const lines = []; + if (entry.sha && entry.shortSha && publicRepo) { + lines.push(`[${entry.shortSha}](https://github.com/${publicRepo}/commit/${entry.sha})`); + } else if (entry.shortSha) { + lines.push(entry.shortSha); + } else { + lines.push('Commit'); + } + if (entry.author) { + lines.push(`Author: ${entry.author.name} <${entry.author.email}>`); + } + lines.push(''); + lines.push(entry.message); + return lines.join('\n'); + }); + + return ['Commits:', '', formatted.join('\n\n')].join('\n'); +} + +function buildCommitAttribution({ commitAuthors, commitMessages = [], publicRepo }) { + const authors = uniqueCommitAuthors([ + ...commitAuthors, + ...commitMessages.map((commit) => commit?.author), + ...commitMessages.flatMap((commit) => coauthorsFromCommitMessage(commit?.message)), + ]); + const trailers = authors.map((author) => `Co-authored-by: ${author.name} <${author.email}>`); + const originalCommitMessages = formatOriginalCommitMessages(commitMessages, publicRepo); + const body = [originalCommitMessages, trailers.join('\n')].filter(Boolean).join('\n\n'); + return { trailers, originalCommitMessages, body }; +} + +// True when a `githubRequest` failed because the PR diff exceeds GitHub's +// hard cap on the diff endpoint (currently 20,000 lines). The API surfaces +// this as a 406 with body `diff exceeded the maximum number of lines (20000)`, +// or a JSON error with `diff_too_large` in the message. Detect by message +// text since we don't preserve the HTTP status separately. The patterns are +// kept narrow on purpose: a bare `too_large` would also match unrelated 422s +// (e.g. PR body validation `{"code":"too_long"}` is adjacent — `too_large` +// itself is rare for non-diff endpoints, but we don't rely on coincidence). +function isDiffTooLargeError(error) { + if (!error || typeof error.message !== 'string') return false; + return /diff exceeded the maximum number of lines|diff is too large|diff_too_large/i.test( + error.message + ); +} + +// Compute the PR's diff locally from the public PR refs that syncPublicPr has +// already fetched into agents-private's object store. 3-dot diff mirrors +// GitHub's `.diff` semantics (compares against merge-base). Used as the +// fallback when the API rejects the PR as too large; also implicitly helps +// `git apply --3way` later because the same fetch made the patch's base blobs +// reachable in agents-private's clone. +// +// maxBuffer is bumped to 50 MB because this fallback fires specifically for +// oversized PRs (>20,000 lines on the API endpoint). Node's default 1 MB +// would truncate the very diffs this path is meant to handle. +function fetchPullRequestDiffViaLocalGit({ internalRepoDir, sourceBaseRef, sourceHeadRef }) { + return run('git', ['-C', internalRepoDir, 'diff', `${sourceBaseRef}...${sourceHeadRef}`], { + maxBuffer: 50 * 1024 * 1024, + }); +} + +async function fetchPullRequestDiff({ + publicToken, + publicRepo, + publicPr, + internalRepoDir, + sourceBaseRef, + sourceHeadRef, + refsFetched, +}) { + try { + return await githubRequest({ + token: publicToken, + path: `/repos/${publicRepo}/pulls/${publicPr.number}`, + accept: 'application/vnd.github.diff', + }); + } catch (error) { + if (!isDiffTooLargeError(error)) throw error; + if (!refsFetched) { + throw new Error( + `Bridge: cannot use local-git-diff fallback for PR #${publicPr.number} — ` + + `the public PR refs failed to fetch into agents-private earlier in this run. ` + + `See the preceding "Bridge: fetch at --depth=..." warning for the original ` + + `fetch failure; resolve that and re-run.` + ); + } + console.log( + `Bridge: GitHub diff API rejected PR #${publicPr.number} as too large; ` + + 'falling back to local git diff against fetched public PR refs.' + ); + return fetchPullRequestDiffViaLocalGit({ + internalRepoDir, + sourceBaseRef, + sourceHeadRef, + }); + } +} + +// Drop diff sections whose old or new path matches any excluded prefix. +// Excluded paths are relative to the PUBLIC repo root (pre-prefix). Used to +// stop pre-cutover branches from re-introducing internal-only paths +// (`specs/`, `reports/`, `.codex/`, etc.) that the public mirror no longer +// exports — those paths exist on agents-private's side but should not flow +// back through the bridge. +function filterDiffByPath(patch, excludedPrefixes) { + if (!excludedPrefixes || excludedPrefixes.length === 0) return patch; + + const sections = patch.split(/(?=^diff --git )/m); + const kept = []; + const dropped = []; + + for (const section of sections) { + if (!section.startsWith('diff --git ')) { + kept.push(section); + continue; + } + const match = section.match(/^diff --git a\/(.+?) b\/(.+?)\n/); + if (!match) { + kept.push(section); + continue; + } + const aPath = match[1].replace(/^"(.+)"$/, '$1'); + const bPath = match[2].replace(/^"(.+)"$/, '$1'); + + const isExcluded = excludedPrefixes.some( + (prefix) => aPath.startsWith(prefix) || bPath.startsWith(prefix) + ); + + if (isExcluded) { + dropped.push(aPath === bPath ? aPath : `${aPath} -> ${bPath}`); + } else { + kept.push(section); + } + } + + if (dropped.length > 0) { + const preview = dropped.slice(0, 20).join('\n '); + const more = dropped.length > 20 ? `\n ...and ${dropped.length - 20} more` : ''; + console.log( + `Bridge: filtered ${dropped.length} diff section(s) matching excluded prefixes:\n ${preview}${more}` + ); + } + + return kept.join(''); +} + +function prefixPatchPaths(patch, prefix, pathRewrites = {}) { + const normalizedPrefix = prefix.replace(/^\/+|\/+$/g, ''); + const prefixedPath = (value) => { + if (value === '/dev/null') { + return value; + } + + const unquoted = value.replace(/^"(.+)"$/, '$1'); + const segments = unquoted.split('/'); + if (segments.some((s) => s === '..' || s === '.')) { + throw new Error(`Rejecting patch with path traversal: ${unquoted}`); + } + + const rewrite = pathRewrites[unquoted]; + if (rewrite) { + const rewriteSegments = rewrite.split('/'); + if (rewriteSegments.some((s) => s === '..' || s === '.')) { + throw new Error(`Rejecting patch rewrite with path traversal: ${rewrite}`); + } + } + + const nextValue = rewrite ?? `${normalizedPrefix}/${unquoted}`.replace(/\/+/g, '/'); + return value.startsWith('"') ? `"${nextValue}"` : nextValue; + }; + + return patch + .split('\n') + .map((line) => { + if (line.startsWith('diff --git a/')) { + const match = line.match(/^diff --git a\/(.+?) b\/(.+)$/); + if (!match) { + return line; + } + return `diff --git a/${prefixedPath(match[1])} b/${prefixedPath(match[2])}`; + } + if (line.startsWith('--- a/')) { + return `--- a/${prefixedPath(line.slice(6))}`; + } + if (line.startsWith('+++ b/')) { + return `+++ b/${prefixedPath(line.slice(6))}`; + } + if (line.startsWith('rename from ')) { + return `rename from ${prefixedPath(line.slice('rename from '.length))}`; + } + if (line.startsWith('rename to ')) { + return `rename to ${prefixedPath(line.slice('rename to '.length))}`; + } + if (line.startsWith('copy from ')) { + return `copy from ${prefixedPath(line.slice('copy from '.length))}`; + } + if (line.startsWith('copy to ')) { + return `copy to ${prefixedPath(line.slice('copy to '.length))}`; + } + return line; + }) + .join('\n'); +} + +function internalPullRequestTitle(publicPr) { + return `Sync ${publicPr.base.repo.full_name} public PR #${publicPr.number}: ${publicPr.title}`; +} + +function buildBridgeMetadata(publicPr, mirrorPath) { + return [ + '', + ].join('\n'); +} + +// GitHub PR body hard limit. Exceeding returns 422 "body is too long". +const GITHUB_PR_BODY_LIMIT = 65536; + +function buildInternalPrBody({ publicPr, branchName, mirrorPath }) { + const rawOriginal = publicPr.body?.trim() + ? publicPr.body.trim() + : '_No public PR body was provided._'; + + const compose = (original) => `## Summary +Mirror public PR [#${publicPr.number}](${publicPr.html_url}) from \`${publicPr.base.repo.full_name}\` into \`inkeep/agents-private\` for internal review and merge. + +## Attribution +- Original author: @${publicPr.user.login} +- Public branch: \`${publicPr.head.label}\` +- Monorepo branch: \`${branchName}\` +- Monorepo path: \`${mirrorPath}\` + +## Original PR Body +
+Expand + +${original} + +
+ +## Notes +- Do not edit this PR directly. This branch is fully managed by the public PR bridge and may be overwritten on the next sync. +- Do not merge the public repo PR. Public mirror PRs cannot land changes directly. +- To accept the contribution, merge this monorepo PR. The change will sync back to the public repo automatically, and the public PR will close automatically. +- To make edits or updates to these changes, they should be made directly to the public PR. Contributor updates there will sync back into this monorepo PR. + +${buildBridgeMetadata(publicPr, mirrorPath)}`; + + let body = compose(rawOriginal); + if (body.length > GITHUB_PR_BODY_LIMIT) { + const footer = `\n\n_...truncated. Original body exceeded GitHub's ${GITHUB_PR_BODY_LIMIT}-char PR body limit; see [original PR](${publicPr.html_url}) for full content._`; + const scaffolding = body.length - rawOriginal.length; + const budget = GITHUB_PR_BODY_LIMIT - scaffolding - footer.length - 100; + const truncated = rawOriginal.slice(0, Math.max(budget, 0)) + footer; + console.log( + `Bridge: PR body exceeded GitHub's ${GITHUB_PR_BODY_LIMIT}-char limit ` + + `(original: ${rawOriginal.length} chars, truncated to: ${truncated.length} chars).` + ); + body = compose(truncated); + } + return body; +} + +function buildWelcomePublicComment() { + return `Thanks for the contribution! + +**What happens next:** + +- A maintainer will review your PR. +- If you don't hear back within a few business days, please comment here to nudge our team. +- This repository is maintained through an internal mirror. When your change is accepted, this PR will close automatically. Don't be alarmed when it closes — that's how it merges, and your authorship is preserved.`; +} + +async function createIssueComment({ token, repo, issueNumber, body }) { + const created = await githubRequest({ + token, + method: 'POST', + path: `/repos/${repo}/issues/${issueNumber}/comments`, + body: { body }, + }); + return created.html_url; +} + +async function acknowledgePublicPr() { + const publicToken = requireEnv('PUBLIC_TOKEN'); + const publicRepo = requireEnv('PUBLIC_REPO'); + const publicPrNumber = Number.parseInt(requireEnv('PUBLIC_PR_NUMBER'), 10); + + await createIssueComment({ + token: publicToken, + repo: publicRepo, + issueNumber: publicPrNumber, + body: buildWelcomePublicComment(), + }); +} + +async function findOpenInternalPr({ token, repo, owner, branchName }) { + const pulls = await githubRequest({ + token, + path: `/repos/${repo}/pulls?state=open&head=${encodeURIComponent(`${owner}:${branchName}`)}`, + }); + return pulls[0] ?? null; +} + +async function ensureDraftState({ token, pullRequest, shouldBeDraft }) { + if (Boolean(pullRequest.draft) === Boolean(shouldBeDraft)) { + return; + } + + const query = shouldBeDraft + ? `mutation($id: ID!) { convertPullRequestToDraft(input: { pullRequestId: $id }) { pullRequest { id } } }` + : `mutation($id: ID!) { markPullRequestReadyForReview(input: { pullRequestId: $id }) { pullRequest { id } } }`; + + await githubGraphql({ + token, + query, + variables: { id: pullRequest.node_id }, + }); +} + +/** + * Apply monorepo-specific patches that upstream configs don't have. + * Currently patches next.config.ts to add outputFileTracingRoot which is + * required for Next.js standalone builds in a monorepo context. + * Returns true if any files were modified. + */ +function reconcileMonorepoPatches(repoDir, mirrorPath) { + let changed = false; + + // Patch next.config.ts files under the mirror path to add outputFileTracingRoot + const nextConfigPaths = [path.join(repoDir, mirrorPath, 'agents-manage-ui', 'next.config.ts')]; + + for (const configPath of nextConfigPaths) { + if (!existsSync(configPath)) continue; + + let content = readFileSync(configPath, 'utf8'); + + // Skip if already has outputFileTracingRoot + if (content.includes('outputFileTracingRoot')) continue; + + // Add outputFileTracingRoot next to the output: 'standalone' line + if (content.includes("output: 'standalone'")) { + content = content.replace( + "output: 'standalone'", + "output: 'standalone',\n outputFileTracingRoot: monorepoRoot" + ); + writeFileSync(configPath, content, 'utf8'); + console.log(`Patched outputFileTracingRoot into ${configPath}`); + changed = true; + } + } + + return changed; +} + +async function syncPublicPr() { + const publicToken = requireEnv('PUBLIC_TOKEN'); + const internalToken = requireEnv('INTERNAL_TOKEN'); + const publicRepo = requireEnv('PUBLIC_REPO'); + const internalRepo = requireEnv('INTERNAL_REPO'); + const internalRepoDir = requireEnv('INTERNAL_REPO_DIR'); + const mirrorPath = requireEnv('MONOREPO_PATH_PREFIX'); + const internalBaseRef = requireEnv('INTERNAL_BASE_REF'); + const internalBranchPrefix = requireEnv('INTERNAL_BRANCH_PREFIX'); + const publicPrAction = process.env.PUBLIC_PR_ACTION ?? 'opened'; + const publicPrNumber = Number.parseInt(requireEnv('PUBLIC_PR_NUMBER'), 10); + const pathRewrites = parseJsonEnv('PUBLIC_PR_PATH_REWRITES', {}); + const internalOwner = internalRepo.split('/')[0]; + const branchName = getPublicPrBranchName(internalBranchPrefix, publicPrNumber); + + const publicPr = await githubRequest({ + token: publicToken, + path: `/repos/${publicRepo}/pulls/${publicPrNumber}`, + }); + + let internalPr = await findOpenInternalPr({ + token: internalToken, + repo: internalRepo, + owner: internalOwner, + branchName, + }); + + const metadataOnlyAction = + internalPr && + (publicPrAction === 'edited' || + publicPrAction === 'ready_for_review' || + publicPrAction === 'converted_to_draft'); + + let hasStagedChanges = false; + if (!metadataOnlyAction) { + // Bring agents-private's main into the local clone and check out the new + // branch first. We need this in place before the public-PR-refs fetch so + // any blob already on main is deduplicated; we also need it before + // `git apply --3way` (later) regardless. + run('git', ['-C', internalRepoDir, 'fetch', 'origin', internalBaseRef, '--prune']); + run('git', ['-C', internalRepoDir, 'checkout', '-B', branchName, `origin/${internalBaseRef}`]); + + // Fetch the public PR's base + head into agents-private's object store. + // Two purposes: + // 1. `git apply --3way` resolves the patch's base blobs locally even + // when public-mirror-sync is stalled and agents-private/main has + // drifted from `inkeep//main`. Without this, every conflicting + // hunk fails with "repository lacks the necessary blob to perform + // 3-way merge" — the dominant bridge-failure pattern for drifted + // public PRs. + // 2. Provides the baseline pair of refs for the local-git-diff fallback + // when the GitHub diff endpoint rejects the PR as too large. + const sourceRemote = `bridge-public-${publicPrNumber}`; + const sourceBaseRef = `refs/remotes/${sourceRemote}/pr-base`; + const sourceHeadRef = `refs/remotes/${sourceRemote}/pr-head`; + const publicRepoUrl = `https://x-access-token:${publicToken}@github.com/${publicRepo}.git`; + + try { + run('git', ['-C', internalRepoDir, 'remote', 'remove', sourceRemote]); + } catch { + // remote did not exist; harmless + } + run('git', ['-C', internalRepoDir, 'remote', 'add', sourceRemote, publicRepoUrl]); + + try { + // Initial fetch: --depth=10000 covers the long-running branches that + // trip the size-fallback. On the rare branch whose merge-base is + // deeper, the subsequent `git diff base...head` errors clearly with + // "no merge base" rather than producing a wrong diff — so we re-fetch + // with increasing depth before giving up. + let refsFetched = false; + for (const depth of [10000, 50000]) { + try { + run('git', [ + '-C', + internalRepoDir, + 'fetch', + '--no-tags', + `--depth=${depth}`, + sourceRemote, + `+refs/pull/${publicPrNumber}/head:${sourceHeadRef}`, + `+refs/heads/${publicPr.base.ref}:${sourceBaseRef}`, + ]); + refsFetched = true; + break; + } catch (error) { + console.log( + `Bridge: fetch at --depth=${depth} failed: ${error.message}. ` + + `Retrying with deeper history if available.` + ); + } + } + if (!refsFetched) { + console.log( + 'Bridge: warning: could not fetch public PR refs into agents-private at any depth. ' + + "Continuing — `git apply --3way` will still succeed if the public mirror's blobs already match agents-private/main, " + + 'but the local-git-diff fallback for oversized PRs will not be available.' + ); + } + + // Use .diff (unified squash) not .patch (multi-commit mailbox). .patch + // returns one patch per commit with intermediate blob SHAs that only + // exist in the public repo; any conflicting hunk forces --3way to look + // up those intermediates and fail. See agents copy of this script for + // full rationale. + // + // For PRs whose .diff exceeds GitHub's 20,000-line endpoint cap, + // fetchPullRequestDiff falls back to a local 3-dot `git diff` against + // the refs we just fetched. + const rawPatch = await fetchPullRequestDiff({ + publicToken, + publicRepo, + publicPr, + internalRepoDir, + sourceBaseRef, + sourceHeadRef, + refsFetched, + }); + const excludedPrefixes = parseJsonEnv('BRIDGE_EXCLUDED_PATHS', []); + const patch = filterDiffByPath(rawPatch, excludedPrefixes); + + if (!patch.trim()) { + return; + } + + const tempDir = mkdtempSync(path.join(tmpdir(), 'public-pr-bridge-')); + const patchFile = path.join(tempDir, 'public-pr.patch'); + writeFileSync(patchFile, prefixPatchPaths(patch, mirrorPath, pathRewrites), 'utf8'); + + try { + try { + run('git', ['-C', internalRepoDir, 'apply', '--index', '--3way', patchFile]); + } catch (error) { + throw error; + } + + hasStagedChanges = (() => { + const output = run('git', ['-C', internalRepoDir, 'diff', '--cached', '--name-only']); + return output.length > 0; + })(); + + if (hasStagedChanges) { + run('git', ['-C', internalRepoDir, 'config', 'user.name', OSS_SYNC_BOT_NAME]); + run('git', ['-C', internalRepoDir, 'config', 'user.email', OSS_SYNC_BOT_EMAIL]); + + let publicCommits = []; + try { + publicCommits = await listPublicPrCommits({ + token: publicToken, + repo: publicRepo, + prNumber: publicPr.number, + }); + } catch (error) { + console.warn( + `Bridge: could not fetch public PR commit messages; using PR opener attribution only: ${error.message}` + ); + } + const { body } = buildCommitAttribution({ + commitAuthors: [publicPrAuthor(publicPr)], + commitMessages: publicCommits, + publicRepo, + }); + run('git', [ + '-C', + internalRepoDir, + 'commit', + '-m', + `sync(oss): mirror ${publicRepo}#${publicPr.number}`, + '-m', + body, + ]); + + // Run monorepo reconciliation patches (e.g. outputFileTracingRoot for Next.js) + const reconciled = reconcileMonorepoPatches(internalRepoDir, mirrorPath); + if (reconciled) { + run('git', ['-C', internalRepoDir, 'add', '-A']); + run('git', [ + '-C', + internalRepoDir, + 'commit', + '--author', + `${OSS_SYNC_BOT_NAME} <${OSS_SYNC_BOT_EMAIL}>`, + '-m', + `sync(oss): reconcile monorepo patches for ${publicRepo}#${publicPr.number}`, + ]); + } + + run('git', [ + '-C', + internalRepoDir, + 'push', + '--force-with-lease', + '--set-upstream', + 'origin', + branchName, + ]); + } + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + } finally { + // Always tear down the bridge-public remote, even on early return or + // throw, so subsequent runs (or a retry of the same PR) start clean. + try { + run('git', ['-C', internalRepoDir, 'remote', 'remove', sourceRemote]); + } catch { + // best-effort + } + } + + internalPr = await findOpenInternalPr({ + token: internalToken, + repo: internalRepo, + owner: internalOwner, + branchName, + }); + + if (!internalPr && !hasStagedChanges) { + return; + } + } + + const title = internalPullRequestTitle(publicPr); + const body = buildInternalPrBody({ publicPr, branchName, mirrorPath }); + + if (internalPr) { + internalPr = await githubRequest({ + token: internalToken, + method: 'PATCH', + path: `/repos/${internalRepo}/pulls/${internalPr.number}`, + body: { title, body }, + }); + await ensureDraftState({ + token: internalToken, + pullRequest: internalPr, + shouldBeDraft: publicPr.draft, + }); + } else { + internalPr = await githubRequest({ + token: internalToken, + method: 'POST', + path: `/repos/${internalRepo}/pulls`, + body: { + title, + head: branchName, + base: internalBaseRef, + body, + draft: publicPr.draft, + }, + }); + } +} + +async function closeLinkedInternalPr() { + const publicToken = requireEnv('PUBLIC_TOKEN'); + const internalToken = requireEnv('INTERNAL_TOKEN'); + const publicRepo = requireEnv('PUBLIC_REPO'); + const internalRepo = requireEnv('INTERNAL_REPO'); + const internalBranchPrefix = requireEnv('INTERNAL_BRANCH_PREFIX'); + const publicPrNumber = Number.parseInt(requireEnv('PUBLIC_PR_NUMBER'), 10); + const internalOwner = internalRepo.split('/')[0]; + const branchName = getPublicPrBranchName(internalBranchPrefix, publicPrNumber); + + const publicPr = await githubRequest({ + token: publicToken, + path: `/repos/${publicRepo}/pulls/${publicPrNumber}`, + }); + + const internalPr = await findOpenInternalPr({ + token: internalToken, + repo: internalRepo, + owner: internalOwner, + branchName, + }); + + if (!internalPr) { + return; + } + + if (publicPr.merged_at) { + return; + } + + await githubRequest({ + token: internalToken, + method: 'POST', + path: `/repos/${internalRepo}/issues/${internalPr.number}/comments`, + body: { + body: `Closing because the linked public PR [#${publicPr.number}](${publicPr.html_url}) was closed without merge.`, + }, + }); + + await githubRequest({ + token: internalToken, + method: 'PATCH', + path: `/repos/${internalRepo}/pulls/${internalPr.number}`, + body: { state: 'closed' }, + }); + + try { + await githubRequest({ + token: internalToken, + method: 'DELETE', + path: `/repos/${internalRepo}/git/refs/heads/${branchName}`, + }); + } catch (error) { + console.log(`Branch cleanup skipped: ${error.message}`); + } +} + +async function main() { + const mode = process.argv[2]; + if (mode === 'acknowledge') { + await acknowledgePublicPr(); + return; + } + + if (mode === 'sync') { + await syncPublicPr(); + return; + } + + if (mode === 'close') { + await closeLinkedInternalPr(); + return; + } + + throw new Error(`Unsupported mode: ${mode}`); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + console.error(error.stack ?? error.message); + process.exitCode = 1; + }); +} + +export { + buildCommitAttribution, + buildInternalPrBody, + buildWelcomePublicComment, + listPublicPrCommits, + prefixPatchPaths, +}; diff --git a/.github/workflows/monorepo-pr-bridge.yml b/.github/workflows/monorepo-pr-bridge.yml new file mode 100644 index 0000000..6645422 --- /dev/null +++ b/.github/workflows/monorepo-pr-bridge.yml @@ -0,0 +1,127 @@ +name: Monorepo PR Bridge + +on: + pull_request_target: + types: [opened, reopened, synchronize, ready_for_review, converted_to_draft, edited, closed] + +permissions: + contents: read + pull-requests: write + issues: write + +concurrency: + group: monorepo-pr-bridge-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + acknowledge: + # Public-facing acknowledgement should happen immediately on a new PR and + # stay decoupled from the inkeep-oss-sync environment approval that imports + # the PR into agents-private. + if: github.event.action == 'opened' && github.event.pull_request.head.ref != 'copybara/sync' + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout public repo automation + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 1 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + + - name: Acknowledge public PR + env: + PUBLIC_PR_NUMBER: ${{ github.event.pull_request.number }} + PUBLIC_REPO: ${{ github.repository }} + PUBLIC_TOKEN: ${{ github.token }} + run: node .github/scripts/bridge-public-pr-to-monorepo.mjs acknowledge + + sync: + # Pause before minting the agents-private App token or writing any internal + # branch/PR. The environment has required reviewers configured in the public + # repo, so every public PR revision needs maintainer approval before crossing + # into agents-private. + if: github.event.action != 'closed' && github.event.pull_request.head.ref != 'copybara/sync' + environment: inkeep-oss-sync + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout public repo automation + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 1 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + + - name: Generate inkeep-oss-sync App token + id: app-token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + with: + app-id: ${{ secrets.OSS_SYNC_APP_ID }} + private-key: ${{ secrets.OSS_SYNC_APP_PRIVATE_KEY }} + owner: inkeep + repositories: agents-private + + - name: Checkout agents-private + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + repository: inkeep/agents-private + ref: main + fetch-depth: 0 + token: ${{ steps.app-token.outputs.token }} + path: agents-private + + - name: Sync public PR into agents-private + env: + INTERNAL_BASE_REF: main + INTERNAL_BRANCH_PREFIX: public-pr/visimer + INTERNAL_REPO: inkeep/agents-private + INTERNAL_REPO_DIR: ${{ github.workspace }}/agents-private + INTERNAL_TOKEN: ${{ steps.app-token.outputs.token }} + MONOREPO_PATH_PREFIX: public/visimer + PUBLIC_PR_ACTION: ${{ github.event.action }} + PUBLIC_PR_NUMBER: ${{ github.event.pull_request.number }} + PUBLIC_REPO: ${{ github.repository }} + PUBLIC_TOKEN: ${{ github.token }} + run: node .github/scripts/bridge-public-pr-to-monorepo.mjs sync + + close: + # Skip PRs that originated from agents-private via Copybara (prevents circular sync) + if: github.event.action == 'closed' && github.event.pull_request.head.ref != 'copybara/sync' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout public repo automation + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 1 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + + - name: Generate inkeep-oss-sync App token + id: app-token + uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1 + with: + app-id: ${{ secrets.OSS_SYNC_APP_ID }} + private-key: ${{ secrets.OSS_SYNC_APP_PRIVATE_KEY }} + owner: inkeep + repositories: agents-private + + - name: Handle public PR closure + env: + INTERNAL_BRANCH_PREFIX: public-pr/visimer + INTERNAL_REPO: inkeep/agents-private + INTERNAL_TOKEN: ${{ steps.app-token.outputs.token }} + PUBLIC_PR_NUMBER: ${{ github.event.pull_request.number }} + PUBLIC_REPO: ${{ github.repository }} + PUBLIC_TOKEN: ${{ github.token }} + run: node .github/scripts/bridge-public-pr-to-monorepo.mjs close diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dd6e803 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.log +.DS_Store diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6627490 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Inkeep + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ab104a --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +
+ +# visimer + +**Visual editing for Mermaid diagrams. Every gesture becomes a minimal text edit.** + +Click, drag, and type directly on the diagram; your Mermaid source updates surgically. +Type in the code; the canvas follows live. One document, two surfaces, zero lock-in. + +[![MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) +[![Mermaid 11](https://img.shields.io/badge/mermaid-v11-ff3670.svg)](https://mermaid.js.org) +[![Types](https://img.shields.io/badge/types-included-3178c6.svg)](#packages) + +
+ +--- + +## Why + +Mermaid has become the diagram language of choice for Markdown files. The mermaid ecosystem lacked open source visual editors. Consumers had to use proprietary tools or one-way exporters. `visimer` keeps **text as the source of truth**: the canvas is Mermaid's own SVG with an interaction layer on top, and every visual action (rename, connect, reorder, restyle) compiles to the smallest possible edit against your actual source. Comments, whitespace, and formatting are never touched. Drag one edge,\ +get a one-line diff. + +## Quick start + +```bash +npm i @visimer/core @visimer/dom mermaid +``` + +```ts +import mermaid from 'mermaid' +import { MermaidWysiwygEditor } from '@visimer/core' +import { MermaidCanvasView } from '@visimer/dom' + +const editor = new MermaidWysiwygEditor({ + code: 'flowchart TD\n A[Start] --> B{OK?}\n B -->|yes| C[Ship]', +}) + +new MermaidCanvasView({ + editor, + container: document.querySelector('#canvas')!, + mermaid, + mermaidConfig: { theme: 'dark' }, +}) + +editor.on('change', ({ code }) => console.log(code)) // always-current source +``` + +Try everything locally: + +```bash +pnpm install && pnpm dev # playground at http://localhost:5173 +``` + +## What you can do + +- **Edit text in place**: double-click any label and type right on the diagram; nodes grow as you type +- **Drag to connect** nodes, states, classes, entities, participants, with a ghost edge preview +- **Drag to reorder** sequence messages; the statements reorder in source +- **Popovers on every entity**: shape/type/arrow pickers, cardinalities, color swatches, fragments, notes +- **One undo stack** across canvas and code (⌘Z anywhere) +- **Error tolerant**: broken syntax mid-keystroke never blanks the canvas +- **Lossless**: unknown syntax is preserved verbatim; your diff is only what you changed + +## Diagram support + +**22 of 23 Mermaid diagram types are editable.** + +| Type | View | Item editing | Structural editing | +|---|:---:|:---:|:---:| +| [flowchart](https://mermaid.js.org/syntax/flowchart.html) | ✅ | ✅ | ✅ | +| [sequence](https://mermaid.js.org/syntax/sequenceDiagram.html) | ✅ | ✅ | ✅ | +| [state](https://mermaid.js.org/syntax/stateDiagram.html) | ✅ | ✅ | ✅ | +| [class](https://mermaid.js.org/syntax/classDiagram.html) | ✅ | ✅ | ✅ | +| [ER](https://mermaid.js.org/syntax/entityRelationshipDiagram.html) | ✅ | ✅ | ✅ | +| [pie](https://mermaid.js.org/syntax/pie.html) | ✅ | ✅ | ✅ | +| [gantt](https://mermaid.js.org/syntax/gantt.html) | ✅ | ✅ | ✅ | +| [journey](https://mermaid.js.org/syntax/userJourney.html) | ✅ | ✅ | ❌ | +| [timeline](https://mermaid.js.org/syntax/timeline.html) | ✅ | ✅ | ❌ | +| [quadrant](https://mermaid.js.org/syntax/quadrantChart.html) | ✅ | ✅ | ❌ | +| [kanban](https://mermaid.js.org/syntax/kanban.html) | ✅ | ✅ | ❌ | +| [mindmap](https://mermaid.js.org/syntax/mindmap.html) | ✅ | ✅ | ❌ | +| [treemap](https://mermaid.js.org/syntax/treemap.html) | ✅ | ✅ | ❌ | +| [packet](https://mermaid.js.org/syntax/packet.html) | ✅ | ✅ | ❌ | +| [sankey](https://mermaid.js.org/syntax/sankey.html) | ✅ | ✅ | ❌ | +| [radar](https://mermaid.js.org/syntax/radar.html) | ✅ | ✅ | ❌ | +| [gitgraph](https://mermaid.js.org/syntax/gitgraph.html) | ✅ | ✅ | ❌ | +| [xychart](https://mermaid.js.org/syntax/xyChart.html) | ✅ | ✅ | ❌ | +| [requirement](https://mermaid.js.org/syntax/requirementDiagram.html) | ✅ | ✅ | ❌ | +| [C4](https://mermaid.js.org/syntax/c4.html) | ✅ | ✅ | ❌ | +| [architecture](https://mermaid.js.org/syntax/architecture.html) | ✅ | ✅ | ❌ | +| [block](https://mermaid.js.org/syntax/block.html) | ✅ | ✅ | ❌ | +| [zenuml](https://mermaid.js.org/syntax/zenuml.html) | ✅ | ❌ | ❌ | + +**Item editing** is select, edit in place, add, and delete; **structural editing** adds connect, reorder, and restyle. Every type round-trips losslessly and syncs selection between code and canvas, including view-only zenuml (an external plugin). + +## Packages + +| Package | Purpose | +|---|---| +| `@visimer/core` | Headless engine: lossless CST, semantic graphs, ops → minimal text edits, unified history. Zero DOM deps | +| `@visimer/dom` | Interactive canvas: renders through your `mermaid` instance, correlates SVG ⇄ graph, all gestures | +| `@visimer/codemirror` | CodeMirror 6 pane: two-way sync, entity decorations, mermaid syntax highlighting, shared undo | +| `@visimer/monaco` | Monaco binding for an editor instance you own; zero monaco dependency of its own | +| `@visimer/react` | React bindings: `` drop-in component plus `useMermaidEditor` hook | + +The code-editor integration is a contract, not a dependency. `core` exposes +`bindTextPane`, a five-method adapter that gives any editor the same two-way sync; +the CodeMirror and Monaco packages are implementations of it, and neither is pulled +in unless you install it (editor libraries are peer dependencies or absent entirely). +Some other editor? Implement the adapter, it's about forty lines. + +## Design + +``` + code (source of truth) + ⇅ minimal TextEdits + lossless CST → semantic graph + ⇅ ⇅ + CodeMirror pane Mermaid SVG + overlay +``` + +## How the canvas works + +We never re-implement rendering. Mermaid draws the SVG, untouched. A small per-type correlator then matches every element back to its source line, using id conventions, draw order, or label text, and tags it. All interaction hangs off those tags. + +Anything we can't match degrades to view-only; the diagram still renders perfectly and the code stays editable. The tradeoff is that correlation leans on Mermaid's internal DOM, which can shift between releases. It's contained, because correlators are tiny, isolated, and fail soft. That's the price of pixel-perfect fidelity, and it beats owning a renderer that's wrong in a hundred small ways. + +## License + +[MIT](./LICENSE) © [Inkeep](https://inkeep.com) diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..2e7a995 --- /dev/null +++ b/TESTING.md @@ -0,0 +1,43 @@ +# Testing substrate + +A map of the product seams, what covers each one today, and what no current +substrate can prove. Grades: **A** real component over its real interface, +**B** narrow integration against a realistic fake, **uncovered** no automated +test exercises the seam. + +## Seams + +| Seam | What crosses it | Substrate | Grade | +|---|---|---|---| +| Core engine (parse → ops → minimal edits → history) | Public contract of `@visimer/core`; every op for all 22 editable diagram types | `packages/core/test/*.test.ts` (74 tests) run the real engine on real Mermaid source and assert emitted code, round-trips, and undo | A | +| `bindTextPane` editor contract | The adapter contract any code editor integration implements | `packages/core/test/textpane.test.ts` drives the binding through an in-memory pane that implements exactly the shipped adapter interface: both sync directions, caret selection, reveal, drift resync, dispose | B | +| CodeMirror binding | `@visimer/codemirror` against a real CodeMirror 6 `EditorView` | `packages/codemirror/test/binding.test.ts` (jsdom): engine ops → view, view edits → engine, decorations in the DOM, caret → entity selection, engine-authoritative undo, teardown | A | +| Monaco binding | `@visimer/monaco` against the structural editor interface it binds | `packages/monaco/test/binding.test.ts`: fake implementing exactly the bound surface (both sync directions, decorations, caret reasons, undo keys, dispose), plus a compile-time conformance check that real `monaco-editor` types satisfy the interface | B | +| SVG correlation (dom package ↔ Mermaid's rendered DOM) | Third-party dependency seam: correlators key off Mermaid's internal SVG structure, which can shift between Mermaid releases | None automated. Verified manually in the playground across all 23 diagram types | uncovered | +| Canvas interaction layer (popovers, drag, in-place editing) | `@visimer/dom` gestures compiled to engine ops | None automated. Verified manually in the playground | uncovered | +| React bindings | `@visimer/react` hooks/components over core events | None automated. Thin subscription layer; exercised manually via the playground | uncovered | + +## Honest residual + +What the current suite cannot catch: + +- **A Mermaid upgrade that shifts the rendered SVG structure.** This is the + highest-risk seam. Correlators fail soft (elements degrade to view-only), + so the failure is silent feature loss, not a crash. Closing it needs a + real-browser rung (vitest browser mode or Playwright) that renders real + Mermaid per diagram type and asserts entities were tagged; jsdom cannot + provide it because Mermaid layout requires real text measurement. +- **Pointer-gesture regressions** (drag-to-connect thresholds, double-click + vs drag arbitration, popover anchoring). Same real-browser rung. +- **React render-loop regressions** (stale subscriptions, effect ordering). + Would need @testing-library/react coverage. + +The playground doubles as the manual harness for all three: every editable +type has a sample, and the toolbar/popovers exercise the gesture surface. + +## Running + +```bash +pnpm test # all package suites +pnpm typecheck # all packages and apps +``` diff --git a/apps/js-playground/index.html b/apps/js-playground/index.html new file mode 100644 index 0000000..1f918dc --- /dev/null +++ b/apps/js-playground/index.html @@ -0,0 +1,42 @@ + + + + + + visimer playground + + +
+
+
visimer playground
+
+ + + +
+
+
+ +
+
+
+
+
+
+
Mermaid source
+
+
+
+
+
+ + + diff --git a/apps/js-playground/package.json b/apps/js-playground/package.json new file mode 100644 index 0000000..98042f3 --- /dev/null +++ b/apps/js-playground/package.json @@ -0,0 +1,27 @@ +{ + "name": "js-playground", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "@codemirror/commands": "^6.6.1", + "@codemirror/language": "^6.10.3", + "@codemirror/state": "^6.4.1", + "@codemirror/view": "^6.34.1", + "@lezer/highlight": "^1.2.1", + "@mermaid-js/mermaid-zenuml": "^0.2.0", + "@visimer/codemirror": "workspace:*", + "@visimer/core": "workspace:*", + "@visimer/dom": "workspace:*", + "mermaid": "^11.6.0" + }, + "devDependencies": { + "typescript": "^5.6.3", + "vite": "^6.3.5" + } +} diff --git a/apps/js-playground/src/main.ts b/apps/js-playground/src/main.ts new file mode 100644 index 0000000..a9a0287 --- /dev/null +++ b/apps/js-playground/src/main.ts @@ -0,0 +1,416 @@ +import mermaid from 'mermaid' +import { + MermaidWysiwygEditor, + DIAGRAM_TYPES, + PARTICIPANT_TYPES, + type ShapeId, + type EdgeLine, + type EdgeArrow, + type ParticipantType, +} from '@visimer/core' +import { MermaidCanvasView, type Tool } from '@visimer/dom' +import { MermaidCodeMirror } from '@visimer/codemirror' +import { SAMPLES } from './samples' +import './style.css' + +const $ = (sel: string) => document.querySelector(sel) as T + +const sidebarEl = $('#sidebar') +const canvasEl = $('#canvas') +const toolbarEl = $('#toolbar') +const inspectorEl = $('#inspector') +const statusbarEl = $('#statusbar') +const typebadgeEl = $('#typebadge') +const themeSel = $('#theme') as unknown as HTMLSelectElement +const accentInput = $('#accent') as unknown as HTMLInputElement +const readonlyChk = $('#readonly') as unknown as HTMLInputElement + +// ZenUML needs an external mermaid plugin — register it lazily and remember the outcome. +let zenumlReady: Promise | null = null +function ensureZenuml(): Promise { + if (!zenumlReady) { + zenumlReady = import('@mermaid-js/mermaid-zenuml') + .then(async (m) => { + await mermaid.registerExternalDiagrams([m.default]) + return true + }) + .catch((err) => { + console.warn('zenuml plugin failed to load', err) + return false + }) + } + return zenumlReady +} + +let editor: MermaidWysiwygEditor +let view: MermaidCanvasView +let codePane: MermaidCodeMirror | null = null + +const SHAPES: Array<{ id: ShapeId; label: string }> = [ + { id: 'rect', label: 'Rectangle' }, + { id: 'round', label: 'Rounded' }, + { id: 'stadium', label: 'Stadium' }, + { id: 'diamond', label: 'Diamond' }, + { id: 'hexagon', label: 'Hexagon' }, + { id: 'circle', label: 'Circle' }, + { id: 'cylinder', label: 'Cylinder' }, + { id: 'subroutine', label: 'Subroutine' }, +] + +// ---------- toolbar ---------- + +function buildToolbar() { + toolbarEl.innerHTML = '' + const editable = editor.result.typeInfo?.capability === 'edit' && !readonlyChk.checked + toolbarEl.classList.toggle('disabled', !editable) + + const btn = (label: string, title: string, onClick: () => void, cls = '') => { + const b = document.createElement('button') + b.textContent = label + b.title = title + b.className = cls + b.addEventListener('click', onClick) + toolbarEl.appendChild(b) + return b + } + + const selectBtn = btn('Select', 'Select tool (click entities, Shift-click for multi)', () => setTool('select'), 'tool') + selectBtn.dataset.tool = 'select' + const connectBtn = btn( + 'Connect', + 'Connect tool: drag between nodes/participants (or Alt-drag anytime)', + () => setTool('connect'), + 'tool', + ) + connectBtn.dataset.tool = 'connect' + + const typeId = editor.result.typeInfo?.id + const isSequence = typeId === 'sequence' + + if (editor.result.lineItems) { + const addBtn = document.createElement('button') + addBtn.textContent = '+ Item' + addBtn.title = 'Add an item line' + addBtn.addEventListener('click', () => { + const res = editor.dispatch({ type: 'li.addItem' }) + const created = res?.created?.[0] + if (created) setTimeout(() => view.editEntityLabel(created), 350) + }) + toolbarEl.appendChild(addBtn) + } else if (typeId === 'gantt') { + const addBtn = document.createElement('button') + addBtn.textContent = '+ Task' + addBtn.title = 'Add a task to the last section' + addBtn.addEventListener('click', () => { + const res = editor.dispatch({ type: 'gantt.addTask' }) + const created = res?.created?.[0] + if (created) setTimeout(() => view.editEntityLabel(created), 350) + }) + toolbarEl.appendChild(addBtn) + } else if (typeId === 'pie') { + const addBtn = document.createElement('button') + addBtn.textContent = '+ Slice' + addBtn.title = 'Add a slice' + addBtn.addEventListener('click', () => { + const res = editor.dispatch({ type: 'pie.addSlice' }) + const created = res?.created?.[0] + if (created) setTimeout(() => view.editEntityLabel(created), 350) + }) + toolbarEl.appendChild(addBtn) + } else if (typeId === 'er') { + const addBtn = document.createElement('button') + addBtn.textContent = '+ Entity' + addBtn.title = 'Add an entity' + addBtn.addEventListener('click', () => { + const res = editor.dispatch({ type: 'er.addEntity' }) + const created = res?.created?.[0] + if (created) setTimeout(() => view.editEntityLabel(created), 350) + }) + toolbarEl.appendChild(addBtn) + } else if (typeId === 'class') { + const addBtn = document.createElement('button') + addBtn.textContent = '+ Class' + addBtn.title = 'Add a class' + addBtn.addEventListener('click', () => { + const res = editor.dispatch({ type: 'cl.addClass' }) + const created = res?.created?.[0] + if (created) setTimeout(() => view.editEntityLabel(created), 350) + }) + toolbarEl.appendChild(addBtn) + + const dirSel = document.createElement('select') + dirSel.title = 'Layout direction' + dirSel.innerHTML = ['TB', 'LR', 'BT', 'RL'].map((d) => ``).join('') + const dir = editor.result.classGraph?.direction?.value + if (dir) dirSel.value = dir === 'TD' ? 'TB' : dir + dirSel.addEventListener('change', () => editor.dispatch({ type: 'cl.setDirection', direction: dirSel.value })) + toolbarEl.appendChild(dirSel) + } else if (typeId === 'state') { + const addBtn = document.createElement('button') + addBtn.textContent = '+ State' + addBtn.title = 'Add a state' + addBtn.addEventListener('click', () => { + const res = editor.dispatch({ type: 'st.addState', label: 'New state' }) + const created = res?.created?.[0] + if (created) setTimeout(() => view.editEntityLabel(created), 350) + }) + toolbarEl.appendChild(addBtn) + + const dirSel = document.createElement('select') + dirSel.title = 'Layout direction' + dirSel.innerHTML = ['TB', 'LR', 'BT', 'RL'] + .map((d) => ``) + .join('') + const dir = editor.result.state?.direction?.value + if (dir) dirSel.value = dir === 'TD' ? 'TB' : dir + dirSel.addEventListener('change', () => editor.dispatch({ type: 'st.setDirection', direction: dirSel.value })) + toolbarEl.appendChild(dirSel) + } else if (isSequence) { + const addSel = document.createElement('select') + addSel.title = 'Add a participant' + addSel.innerHTML = + `` + + PARTICIPANT_TYPES.map((t) => ``).join('') + addSel.addEventListener('change', () => { + if (addSel.value) { + editor.dispatch({ type: 'seq.addParticipant', ptype: addSel.value as ParticipantType }) + } + addSel.value = '' + }) + toolbarEl.appendChild(addSel) + + const autoBtn = document.createElement('button') + const autoOn = () => editor.result.sequence?.autonumber.enabled ?? false + autoBtn.textContent = '# Autonumber' + autoBtn.title = 'Toggle mermaid autonumber' + autoBtn.className = autoOn() ? 'active' : '' + autoBtn.addEventListener('click', () => editor.dispatch({ type: 'seq.toggleAutonumber' })) + toolbarEl.appendChild(autoBtn) + } else { + const addSel = document.createElement('select') + addSel.title = 'Add a node' + addSel.innerHTML = + `` + SHAPES.map((s) => ``).join('') + addSel.addEventListener('change', () => { + if (addSel.value) view.addNode(addSel.value as ShapeId) + addSel.value = '' + }) + toolbarEl.appendChild(addSel) + + const dirSel = document.createElement('select') + dirSel.title = 'Layout direction' + dirSel.innerHTML = ['TD', 'LR', 'BT', 'RL'] + .map((d) => ``) + .join('') + const dir = editor.result.flowchart?.direction + if (dir) dirSel.value = dir === 'TB' ? 'TD' : dir + dirSel.addEventListener('change', () => editor.dispatch({ type: 'setDirection', direction: dirSel.value })) + toolbarEl.appendChild(dirSel) + } + + btn('Delete', 'Delete selection (Del)', () => editor.deleteEntities(editor.selection)) + const undoBtn = btn('Undo', 'Undo (Cmd/Ctrl+Z)', () => editor.undo()) + const redoBtn = btn('Redo', 'Redo (Cmd/Ctrl+Shift+Z)', () => editor.redo()) + undoBtn.id = 'btn-undo' + redoBtn.id = 'btn-redo' + + updateToolButtons() +} + +function setTool(tool: Tool) { + view.setTool(tool) + updateToolButtons() +} + +function updateToolButtons() { + toolbarEl.querySelectorAll('button.tool').forEach((b) => { + b.classList.toggle('active', b.dataset.tool === view.currentTool) + }) + const undoBtn = toolbarEl.querySelector('#btn-undo') + const redoBtn = toolbarEl.querySelector('#btn-redo') + if (undoBtn) undoBtn.disabled = !editor.canUndo + if (redoBtn) redoBtn.disabled = !editor.canRedo +} + +// ---------- inspector ---------- + +function buildInspector() { + inspectorEl.innerHTML = '' + const sel = editor.selection + const graph = editor.result.flowchart + if (!graph || sel.length === 0) { + inspectorEl.classList.remove('visible') + return + } + inspectorEl.classList.add('visible') + + const info = document.createElement('span') + info.className = 'ins-label' + inspectorEl.appendChild(info) + + if (sel.length > 1) { + info.textContent = `${sel.length} selected` + return + } + const id = sel[0] + + if (id.startsWith('node:')) { + const node = graph.nodeById.get(id.slice(5)) + if (!node) return + info.textContent = `node ${node.id}` + + const shapeSel = document.createElement('select') + shapeSel.innerHTML = SHAPES.map((s) => ``).join('') + shapeSel.value = SHAPES.some((s) => s.id === node.shape) ? node.shape : 'rect' + shapeSel.addEventListener('change', () => + editor.dispatch({ type: 'setNodeShape', id: node.id, shape: shapeSel.value as ShapeId }), + ) + inspectorEl.appendChild(shapeSel) + + const rename = document.createElement('button') + rename.textContent = 'Rename' + rename.addEventListener('click', () => view.editEntityLabel(id)) + inspectorEl.appendChild(rename) + } else if (id.startsWith('edge:')) { + const edge = graph.edges.find((e) => e.entityId === id) + if (!edge) return + info.textContent = `edge ${edge.source} → ${edge.target}` + + const lineSel = document.createElement('select') + lineSel.innerHTML = ['solid', 'dotted', 'thick'] + .map((l) => ``) + .join('') + lineSel.value = edge.seg.line === 'invisible' ? 'solid' : edge.seg.line + lineSel.addEventListener('change', () => + editor.dispatch({ type: 'setEdgeStyle', edgeId: id, line: lineSel.value as EdgeLine }), + ) + inspectorEl.appendChild(lineSel) + + const arrowSel = document.createElement('select') + arrowSel.innerHTML = [ + ['arrow', 'arrow →'], + ['open', 'open —'], + ['circle', 'circle ●'], + ['cross', 'cross ✕'], + ] + .map(([v, l]) => ``) + .join('') + arrowSel.value = edge.seg.arrowEnd + arrowSel.addEventListener('change', () => + editor.dispatch({ type: 'setEdgeStyle', edgeId: id, arrowEnd: arrowSel.value as EdgeArrow }), + ) + inspectorEl.appendChild(arrowSel) + + const label = document.createElement('button') + label.textContent = 'Edit label' + label.addEventListener('click', () => view.editEntityLabel(id)) + inspectorEl.appendChild(label) + } + + const del = document.createElement('button') + del.textContent = 'Delete' + del.className = 'danger' + del.addEventListener('click', () => editor.deleteEntities(sel)) + inspectorEl.appendChild(del) +} + +// ---------- status ---------- + +function updateStatus() { + const t = editor.result.typeInfo + const cap = t?.capability === 'edit' ? 'WYSIWYG editing' : 'render + code' + typebadgeEl.innerHTML = t + ? `${t.name} ${cap}` + : 'unknown type' + const diags = editor.diagnostics + if (view.renderError) { + statusbarEl.innerHTML = `✕ ${escapeHtml(view.renderError)}` + } else if (diags.length) { + statusbarEl.innerHTML = `✕ ${escapeHtml(diags[0].message)}` + } else { + statusbarEl.innerHTML = `✓ mermaid parse ok` + } +} + +function escapeHtml(s: string) { + return s.replace(/&/g, '&').replace(/Diagram types ${DIAGRAM_TYPES.length} supported` + SAMPLES.forEach((sample, i) => { + const t = DIAGRAM_TYPES.find((d) => d.id === sample.typeId) + const b = document.createElement('button') + b.className = 'side-item' + (i === activeIndex ? ' active' : '') + b.innerHTML = `${sample.title}` + b.title = t?.capability === 'edit' ? 'Full WYSIWYG editing' : 'Rendered via mermaid; code editing with live preview' + b.addEventListener('click', () => loadSample(i)) + sidebarEl.appendChild(b) + }) + const legend = document.createElement('div') + legend.className = 'legend' + legend.innerHTML = `WYSIWYGrender + code` + sidebarEl.appendChild(legend) +} + +// ---------- wiring ---------- + +let currentSample = 0 + +async function loadSample(index: number) { + currentSample = index + const sample = SAMPLES[index] + + if (sample.typeId === 'zenuml') await ensureZenuml() + + view?.destroy() + canvasEl.innerHTML = '' + + editor = new MermaidWysiwygEditor({ code: sample.code }) + view = new MermaidCanvasView({ + editor, + container: canvasEl, + mermaid, + mermaidConfig: { theme: themeSel.value, securityLevel: 'loose' }, + accentColor: accentInput.value, + readOnly: readonlyChk.checked, + }) + + editor.on('change', () => { + buildToolbar() + buildInspector() + void view.validate().then(updateStatus) + }) + + editor.on('selectionChange', () => { + buildInspector() + }) + + editor.on('diagnostics', updateStatus) + view.on('render', () => updateStatus()) + view.on('toolChange', updateToolButtons) + + codePane?.destroy() + const codeHost = $('#codepane') + codeHost.innerHTML = '' + codePane = new MermaidCodeMirror(codeHost, editor) + + buildSidebar(index) + buildToolbar() + buildInspector() + void view.validate().then(updateStatus) +} + +themeSel.addEventListener('change', () => view.setMermaidConfig({ theme: themeSel.value })) +accentInput.addEventListener('input', () => { + view.setAccentColor(accentInput.value) + document.documentElement.style.setProperty('--accent', accentInput.value) +}) +readonlyChk.addEventListener('change', () => { + view.setReadOnly(readonlyChk.checked) + buildToolbar() +}) + +void loadSample(0) diff --git a/apps/js-playground/src/samples.ts b/apps/js-playground/src/samples.ts new file mode 100644 index 0000000..5ca30b3 --- /dev/null +++ b/apps/js-playground/src/samples.ts @@ -0,0 +1,339 @@ +export interface Sample { + /** must match a DiagramTypeInfo id from @visimer/core */ + typeId: string + title: string + code: string +} + +export const SAMPLES: Sample[] = [ + { + typeId: 'flowchart', + title: 'Flowchart — order flow', + code: `flowchart TD + %% double-click nodes to rename, drag with Connect tool + A[Customer order] --> B{In stock?} + B -->|yes| C[Reserve items] + B -->|no| D[Backorder] + C --> E([Charge payment]) + D --> E + E --> F{{Ship}} +`, + }, + { + typeId: 'flowchart', + title: 'Flowchart — architecture', + code: `flowchart LR + subgraph client [Client] + UI[Web app] + end + subgraph api [API layer] + GW[Gateway] --> SVC[Service] + end + UI --> GW + SVC --> DB[(Postgres)] + SVC -.-> CACHE[(Redis)] + classDef store fill:#334155,stroke:#94a3b8,color:#fff + class DB,CACHE store +`, + }, + { + typeId: 'sequence', + title: 'Sequence diagram', + code: `sequenceDiagram + participant Alice + participant Bob + Alice->>Bob: Hello Bob, how are you? + alt is sick + Bob-->>Alice: Not so good :( + else is well + Bob-->>Alice: Feeling fresh! + end + Bob->>Alice: See you later! +`, + }, + { + typeId: 'class', + title: 'Class diagram', + code: `classDiagram + Animal <|-- Duck + Animal <|-- Fish + Animal : +int age + Animal : +String gender + Animal : +isMammal() + class Duck{ + +String beakColor + +swim() + +quack() + } + class Fish{ + -int sizeInFeet + -canEat() + } +`, + }, + { + typeId: 'state', + title: 'State diagram', + code: `stateDiagram-v2 + [*] --> Still + Still --> [*] + Still --> Moving + Moving --> Still + Moving --> Crash + Crash --> [*] +`, + }, + { + typeId: 'er', + title: 'Entity relationship', + code: `erDiagram + CUSTOMER ||--o{ ORDER : places + ORDER ||--|{ LINE-ITEM : contains + CUSTOMER }|..|{ DELIVERY-ADDRESS : uses +`, + }, + { + typeId: 'journey', + title: 'User journey', + code: `journey + title My working day + section Go to work + Make tea: 5: Me + Go upstairs: 3: Me + Do work: 1: Me, Cat + section Go home + Go downstairs: 5: Me + Sit down: 5: Me +`, + }, + { + typeId: 'gantt', + title: 'Gantt chart', + code: `gantt + title A Gantt Diagram + dateFormat YYYY-MM-DD + section Section + A task :a1, 2026-01-01, 30d + Another task :after a1, 20d + section Another + Task in Another :2026-01-12, 12d + another task :24d +`, + }, + { + typeId: 'pie', + title: 'Pie chart', + code: `pie showData + title Key elements in Product X + "Calcium" : 42.96 + "Potassium" : 50.05 + "Magnesium" : 10.01 + "Iron" : 5 +`, + }, + { + typeId: 'quadrant', + title: 'Quadrant chart', + code: `quadrantChart + title Reach and engagement of campaigns + x-axis Low Reach --> High Reach + y-axis Low Engagement --> High Engagement + quadrant-1 We should expand + quadrant-2 Need to promote + quadrant-3 Re-evaluate + quadrant-4 May be improved + Campaign A: [0.3, 0.6] + Campaign B: [0.45, 0.23] + Campaign C: [0.57, 0.69] +`, + }, + { + typeId: 'requirement', + title: 'Requirement diagram', + code: `requirementDiagram + requirement test_req { + id: 1 + text: the test text. + risk: high + verifymethod: test + } + element test_entity { + type: simulation + } + test_entity - satisfies -> test_req +`, + }, + { + typeId: 'gitgraph', + title: 'Gitgraph', + code: `gitGraph + commit + commit + branch develop + checkout develop + commit + commit + checkout main + merge develop + commit +`, + }, + { + typeId: 'c4', + title: 'C4 context', + code: `C4Context + title System Context diagram for Internet Banking System + Person(customerA, "Banking Customer A", "A customer of the bank.") + System(SystemAA, "Internet Banking System", "Allows customers to check accounts.") + System_Ext(SystemE, "Mainframe Banking System", "Stores core banking information.") + Rel(customerA, SystemAA, "Uses") + Rel(SystemAA, SystemE, "Uses") +`, + }, + { + typeId: 'mindmap', + title: 'Mindmap', + code: `mindmap + root((visimer)) + Parsing + Lossless CST + Statement classifier + Editing + Semantic ops + Minimal diffs + Rendering + Mermaid SVG + Interaction overlay +`, + }, + { + typeId: 'timeline', + title: 'Timeline', + code: `timeline + title History of Social Media Platform + 2002 : LinkedIn + 2004 : Facebook + : Google + 2005 : YouTube + 2006 : Twitter +`, + }, + { + typeId: 'zenuml', + title: 'ZenUML (plugin)', + code: `zenuml + title Order Service + @Actor Client + @Boundary OrderController + Client->OrderController.post(payload) { + return ok + } +`, + }, + { + typeId: 'sankey', + title: 'Sankey', + code: `sankey-beta +Agricultural 'waste',Bio-conversion,124.729 +Bio-conversion,Liquid,0.597 +Bio-conversion,Losses,26.862 +Bio-conversion,Solid,280.322 +Bio-conversion,Gas,81.144 +`, + }, + { + typeId: 'xychart', + title: 'XY chart', + code: `xychart-beta + title "Sales Revenue" + x-axis [jan, feb, mar, apr, may, jun] + y-axis "Revenue (in $)" 4000 --> 11000 + bar [5000, 6000, 7500, 8200, 9500, 10500] + line [5000, 6000, 7500, 8200, 9500, 10500] +`, + }, + { + typeId: 'block', + title: 'Block diagram', + code: `block-beta +columns 1 + db(("DB")) + blockArrowId6<["   "]>(down) + block:ID + A + B["A wide one in the middle"] + C + end + space + D + ID --> D + C --> D + style B fill:#969,stroke:#333,stroke-width:4px +`, + }, + { + typeId: 'packet', + title: 'Packet', + code: `packet-beta +0-15: "Source Port" +16-31: "Destination Port" +32-63: "Sequence Number" +64-95: "Acknowledgment Number" +`, + }, + { + typeId: 'kanban', + title: 'Kanban', + code: `kanban + Todo + [Create Documentation] + docs[Create Blog about the new diagram] + [In progress] + id6[Create renderer so that it works in all cases] + id9[Ready for deploy] + id8[Design grammar] +`, + }, + { + typeId: 'architecture', + title: 'Architecture', + code: `architecture-beta + group api(cloud)[API] + + service db(database)[Database] in api + service disk1(disk)[Storage] in api + service disk2(disk)[Storage] in api + service server(server)[Server] in api + + db:L -- R:server + disk1:T -- B:server + disk2:T -- B:db +`, + }, + { + typeId: 'radar', + title: 'Radar', + code: `radar-beta + title Grades + axis m["Math"], s["Science"], e["English"] + axis h["History"], g["Geography"], a["Art"] + curve alice["Alice"]{85, 90, 80, 70, 75, 90} + curve bob["Bob"]{70, 75, 85, 80, 90, 85} + max 100 + min 0 +`, + }, + { + typeId: 'treemap', + title: 'Treemap', + code: `treemap-beta +"Products" + "Electronics" + "Phones": 50 + "Laptops": 30 + "Furniture" + "Chairs": 20 + "Tables": 15 +`, + }, +] diff --git a/apps/js-playground/src/style.css b/apps/js-playground/src/style.css new file mode 100644 index 0000000..652dc75 --- /dev/null +++ b/apps/js-playground/src/style.css @@ -0,0 +1,158 @@ +:root { + --accent: #818cf8; + --bg: #0b1020; + --bg2: #111731; + --bg3: #1a2142; + --fg: #e2e8f0; + --fg-dim: #94a3b8; + --border: #26304f; + --ok: #34d399; + --err: #f87171; + --mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + height: 100%; + background: var(--bg); + color: var(--fg); + font: 14px/1.45 ui-sans-serif, system-ui, -apple-system, sans-serif; +} + +#app { display: flex; flex-direction: column; height: 100vh; } + +/* ---------- top bar ---------- */ +#topbar { + display: flex; align-items: center; justify-content: space-between; + padding: 8px 14px; background: var(--bg2); border-bottom: 1px solid var(--border); + flex: 0 0 auto; +} +.brand { font-weight: 700; letter-spacing: 0.2px; } +.brand .tag { + font-size: 11px; font-weight: 600; color: var(--accent); + border: 1px solid var(--accent); border-radius: 99px; padding: 1px 8px; margin-left: 8px; + vertical-align: 2px; +} +#topbar .controls { display: flex; gap: 14px; align-items: center; color: var(--fg-dim); font-size: 12px; } +#topbar .controls label { display: flex; align-items: center; gap: 6px; } + +/* ---------- layout ---------- */ +#body { display: flex; flex: 1 1 auto; min-height: 0; } + +#sidebar { + width: 210px; flex: 0 0 auto; overflow-y: auto; + background: var(--bg2); border-right: 1px solid var(--border); padding: 8px; +} +.side-title { + font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; + color: var(--fg-dim); padding: 6px 8px; +} +.side-title span { float: right; color: var(--accent); } +.side-item { + display: flex; align-items: center; gap: 8px; width: 100%; + background: none; border: none; color: var(--fg); text-align: left; + padding: 6px 8px; border-radius: 6px; cursor: pointer; font-size: 13px; +} +.side-item:hover { background: var(--bg3); } +.side-item.active { background: var(--bg3); outline: 1px solid var(--accent); } +.dot { width: 8px; height: 8px; border-radius: 99px; flex: 0 0 auto; display: inline-block; } +.dot.edit { background: var(--ok); } +.dot.render { background: var(--fg-dim); } +.legend { display: flex; gap: 14px; padding: 10px 8px; color: var(--fg-dim); font-size: 11px; } +.legend span { display: flex; gap: 5px; align-items: center; } + +#main { flex: 1 1 55%; display: flex; flex-direction: column; min-width: 0; border-right: 1px solid var(--border); } + +/* ---------- toolbar / inspector ---------- */ +#toolbar, #inspector { + display: flex; gap: 6px; align-items: center; padding: 6px 10px; + background: var(--bg2); border-bottom: 1px solid var(--border); flex: 0 0 auto; +} +#toolbar.disabled { opacity: 0.45; pointer-events: none; } +#inspector { display: none; border-top: 1px solid var(--border); border-bottom: none; } +#inspector.visible { display: flex; } +.ins-label { color: var(--fg-dim); font-size: 12px; margin-right: 4px; font-family: var(--mono); } + +button, select { + background: var(--bg3); color: var(--fg); border: 1px solid var(--border); + border-radius: 6px; padding: 4px 10px; font-size: 12.5px; cursor: pointer; +} +button:hover:not(:disabled), select:hover { border-color: var(--accent); } +button:disabled { opacity: 0.4; cursor: default; } +button.active { background: var(--accent); color: #0b1020; font-weight: 600; } +button.danger:hover { border-color: var(--err); color: var(--err); } +input[type="color"] { width: 26px; height: 22px; padding: 0; border: 1px solid var(--border); border-radius: 5px; background: none; } + +/* ---------- canvas ---------- */ +#canvas { + flex: 1 1 auto; min-height: 0; background: + radial-gradient(circle at 1px 1px, #ffffff10 1px, transparent 0) 0 0 / 22px 22px, + var(--bg); + --mw-editor-bg: var(--bg3); + --mw-editor-fg: var(--fg); + --mw-chrome-bg: var(--bg3); + --mw-chrome-border: var(--border); + --mw-chrome-fg: var(--fg); + --mw-chrome-hover: #26304f; + --mw-chrome-dim: var(--fg-dim); +} +#canvas:focus { outline: none; box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--accent) 45%, transparent); } + +/* ---------- code panel ---------- */ +#codepanel { flex: 1 1 45%; display: flex; flex-direction: column; min-width: 320px; max-width: 46%; } +.panel-title { + padding: 8px 12px; font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; + color: var(--fg-dim); background: var(--bg2); border-bottom: 1px solid var(--border); + display: flex; justify-content: space-between; align-items: center; gap: 8px; +} +#typebadge { text-transform: none; letter-spacing: 0; font-size: 12px; } +#typebadge a { color: var(--fg); text-decoration: none; font-weight: 600; } +#typebadge a:hover { color: var(--accent); } +#typebadge em { font-style: normal; margin-left: 6px; border-radius: 99px; padding: 1px 8px; font-size: 10.5px; } +#typebadge em.edit { background: color-mix(in srgb, var(--ok) 18%, transparent); color: var(--ok); } +#typebadge em.render { background: var(--bg3); color: var(--fg-dim); } + +#codepane { flex: 1 1 auto; min-height: 0; position: relative; } +.cp-root { position: absolute; inset: 0; } +.cp-backdrop, .cp-textarea { + position: absolute; inset: 0; margin: 0; padding: 12px 14px; + font: 13px/1.55 var(--mono); white-space: pre; overflow: auto; + border: none; background: transparent; tab-size: 4; +} +.cp-backdrop { color: transparent; overflow: hidden; pointer-events: none; z-index: 0; } +.cp-backdrop mark { + color: transparent; background: color-mix(in srgb, var(--accent) 30%, transparent); + border-radius: 3px; outline: 1px solid color-mix(in srgb, var(--accent) 55%, transparent); +} +.cp-textarea { color: var(--fg); caret-color: var(--accent); resize: none; outline: none; z-index: 1; } + +#statusbar { + padding: 6px 12px; font-size: 12px; font-family: var(--mono); + background: var(--bg2); border-top: 1px solid var(--border); flex: 0 0 auto; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +#statusbar .ok { color: var(--ok); } +#statusbar .err { color: var(--err); } + +/* ---------- CodeMirror pane ---------- */ +#codepane .cm-editor { height: 100%; background: transparent; color: var(--fg); } +#codepane .cm-editor.cm-focused { outline: none; } +#codepane .cm-scroller { font: 13px/1.55 var(--mono); } +#codepane .cm-content { caret-color: var(--accent); } +#codepane .cm-cursor { border-left-color: var(--accent); } +#codepane .cm-gutters { background: var(--bg2); color: var(--fg-dim); border-right: 1px solid var(--border); } +#codepane .cm-activeLine { background: #ffffff08; } +#codepane .cm-activeLineGutter { background: transparent; color: var(--fg); } +#codepane .cm-selectionBackground, #codepane .cm-editor ::selection { background: #334155aa !important; } + +/* mermaid syntax highlighting (classHighlighter token classes) */ +#codepane .tok-comment { color: #64748b; font-style: italic; } +#codepane .tok-string { color: #fbbf24; } +#codepane .tok-keyword { color: #c084fc; } +#codepane .tok-heading { color: #60a5fa; font-weight: 700; } +#codepane .tok-operator { color: #34d399; } +#codepane .tok-number { color: #f472b6; } +#codepane .tok-bracket { color: #94a3b8; } +#codepane .tok-meta { color: #22d3ee; } diff --git a/apps/js-playground/tsconfig.json b/apps/js-playground/tsconfig.json new file mode 100644 index 0000000..474b75a --- /dev/null +++ b/apps/js-playground/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/js-playground/vite.config.ts b/apps/js-playground/vite.config.ts new file mode 100644 index 0000000..feff9e0 --- /dev/null +++ b/apps/js-playground/vite.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' + +export default defineConfig({ + optimizeDeps: { + // workspace TS sources are transformed directly, never pre-bundled + exclude: ['@visimer/core', '@visimer/dom', '@visimer/codemirror'], + // pre-bundle heavy deps up front so a mid-session discovery (e.g. the lazy + // zenuml import) never invalidates the dep cache under an open tab + include: ['mermaid', '@mermaid-js/mermaid-zenuml', '@codemirror/state', '@codemirror/view', '@codemirror/commands', '@codemirror/language', '@lezer/highlight'], + }, + server: { + port: 5174, + strictPort: true, + }, +}) diff --git a/apps/playground/index.html b/apps/playground/index.html new file mode 100644 index 0000000..5e07747 --- /dev/null +++ b/apps/playground/index.html @@ -0,0 +1,12 @@ + + + + + + visimer + + +
+ + + diff --git a/apps/playground/package.json b/apps/playground/package.json new file mode 100644 index 0000000..598331c --- /dev/null +++ b/apps/playground/package.json @@ -0,0 +1,35 @@ +{ + "name": "playground", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "@codemirror/commands": "^6.6.1", + "@codemirror/language": "^6.10.3", + "@codemirror/state": "^6.4.1", + "@codemirror/view": "^6.34.1", + "@lezer/highlight": "^1.2.1", + "@mermaid-js/mermaid-zenuml": "^0.2.0", + "@visimer/codemirror": "workspace:*", + "@visimer/core": "workspace:*", + "@visimer/dom": "workspace:*", + "@visimer/monaco": "workspace:*", + "@visimer/react": "workspace:*", + "mermaid": "^11.6.0", + "monaco-editor": "^0.55.1", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^6.3.5" + } +} diff --git a/apps/playground/src/App.tsx b/apps/playground/src/App.tsx new file mode 100644 index 0000000..bb63f8b --- /dev/null +++ b/apps/playground/src/App.tsx @@ -0,0 +1,383 @@ +import { useEffect, useRef, useState } from 'react' +import mermaid from 'mermaid' +import { + DIAGRAM_TYPES, + PARTICIPANT_TYPES, + type MermaidWysiwygEditor, + type ParticipantType, + type ShapeId, +} from '@visimer/core' +import type { MermaidCanvasView, Tool } from '@visimer/dom' +import { MermaidCanvas, useMermaidEditor } from '@visimer/react' +import { MermaidCodeMirror } from '@visimer/codemirror' +import { MonacoPane } from './MonacoPane' +import { SAMPLES } from '../../js-playground/src/samples' + +let zenumlReady: Promise | null = null +function ensureZenuml(): Promise { + zenumlReady ??= import('@mermaid-js/mermaid-zenuml') + .then(async (m) => (await mermaid.registerExternalDiagrams([m.default]), true)) + .catch(() => false) + return zenumlReady +} + +/** the playground's dark mermaid theme, built on mermaid's `base` theme */ +const INK_THEME = { + darkMode: true, + background: '#000000', + fontFamily: '"Inter Variable", "Inter", ui-sans-serif, system-ui, sans-serif', + fontSize: '14px', + // core nodes and edges + primaryColor: '#111111', + primaryTextColor: '#ededed', + primaryBorderColor: '#3f3f3f', + secondaryColor: '#1a1a1a', + secondaryBorderColor: '#333333', + secondaryTextColor: '#ededed', + tertiaryColor: '#0a0a0a', + tertiaryBorderColor: '#262626', + tertiaryTextColor: '#a1a1a1', + mainBkg: '#111111', + nodeBorder: '#3f3f3f', + nodeTextColor: '#ededed', + lineColor: '#8f8f8f', + textColor: '#ededed', + titleColor: '#ededed', + clusterBkg: '#0a0a0a', + clusterBorder: '#333333', + edgeLabelBackground: '#1a1a1a', + // sequence + actorBkg: '#111111', + actorBorder: '#3f3f3f', + actorTextColor: '#ededed', + actorLineColor: '#333333', + signalColor: '#8f8f8f', + signalTextColor: '#a1a1a1', + labelBoxBkgColor: '#111111', + labelBoxBorderColor: '#3f3f3f', + labelTextColor: '#ededed', + loopTextColor: '#a1a1a1', + noteBkgColor: '#1a1a1a', + noteBorderColor: '#3f3f3f', + noteTextColor: '#ededed', + activationBkgColor: '#1f1f1f', + activationBorderColor: '#454545', + sequenceNumberColor: '#ffffff', + // pie and categorical scales + pie1: '#3784ff', + pie2: '#a78bfa', + pie3: '#4fae72', + pie4: '#d9a13c', + pie5: '#d97757', + pie6: '#69a3ff', + pie7: '#f0716b', + pie8: '#8fb8ff', + pieTitleTextColor: '#ededed', + pieSectionTextColor: '#ffffff', + pieLegendTextColor: '#a1a1a1', + pieStrokeColor: '#000000', + pieOuterStrokeColor: '#000000', + cScale0: '#3784ff', + cScale1: '#a78bfa', + cScale2: '#4fae72', + cScale3: '#d9a13c', + cScale4: '#d97757', + cScale5: '#69a3ff', + cScale6: '#f0716b', + cScale7: '#8fb8ff', + // gantt + sectionBkgColor: '#0a0a0a', + sectionBkgColor2: '#111111', + altSectionBkgColor: '#000000', + gridColor: '#262626', + todayLineColor: '#d97757', + taskBkgColor: '#3784ff', + taskBorderColor: '#3784ff', + taskTextColor: '#ffffff', + taskTextLightColor: '#ffffff', + taskTextOutsideColor: '#ededed', + activeTaskBkgColor: '#a78bfa', + activeTaskBorderColor: '#a78bfa', + doneTaskBkgColor: '#333333', + doneTaskBorderColor: '#454545', + critBkgColor: '#d97757', + critBorderColor: '#d97757', + excludeBkgColor: '#111111', + // er / class + attributeBackgroundColorOdd: '#0a0a0a', + attributeBackgroundColorEven: '#111111', + classText: '#ededed', + // gitgraph + git0: '#3784ff', + git1: '#a78bfa', + git2: '#4fae72', + git3: '#d9a13c', + git4: '#d97757', + git5: '#69a3ff', + git6: '#f0716b', + git7: '#8fb8ff', + commitLabelColor: '#ededed', + commitLabelBackground: '#111111', +} + +function themeConfig(theme: string) { + return theme === 'ink' + ? { theme: 'base', themeVariables: INK_THEME } + : { theme, themeVariables: {} } +} + +const SHAPES: Array<{ id: ShapeId; label: string }> = [ + { id: 'rect', label: 'Rectangle' }, + { id: 'round', label: 'Rounded' }, + { id: 'stadium', label: 'Stadium' }, + { id: 'diamond', label: 'Diamond' }, + { id: 'hexagon', label: 'Hexagon' }, + { id: 'circle', label: 'Circle' }, + { id: 'cylinder', label: 'Cylinder' }, + { id: 'subroutine', label: 'Subroutine' }, +] + +export default function App() { + const [sampleIndex, setSampleIndex] = useState(0) + const [theme, setTheme] = useState('ink') + const [readOnly, setReadOnly] = useState(false) + const [zenumlOk, setZenumlOk] = useState(false) + + const sample = SAMPLES[sampleIndex] + useEffect(() => { + if (sample.typeId === 'zenuml') void ensureZenuml().then(setZenumlOk) + }, [sample]) + + const waitForZenuml = sample.typeId === 'zenuml' && !zenumlOk + + return ( +
+
+
+ + visimer + / + playground +
+
+ + + GitHub +
+
+
+ + {!waitForZenuml && } +
+
+ ) +} + +function Workspace({ code, theme, readOnly }: { code: string; theme: string; readOnly: boolean }) { + const { editor } = useMermaidEditor(code) + const [view, setView] = useState(null) + const [tool, setToolState] = useState('select') + const [status, setStatus] = useState(null) + const [pane, setPane] = useState<'codemirror' | 'monaco'>('codemirror') + + // theme changes flow through the MermaidCanvas mermaidConfig prop + + // subscribe to the editor so toolbar state (undo/redo/selection) stays live + useEditorCode(editor) + // status tracks render outcomes; the view emits after every (debounced) + // render, including the undo-back-to-last-good path + useEffect(() => { + if (!view) return + setStatus(view.renderError) + return view.on('render', () => setStatus(view.renderError)) + }, [view]) + + const setTool = (t: Tool) => { + view?.setTool(t) + setToolState(t) + } + + const typeInfo = editor.result.typeInfo + const editable = typeInfo?.capability === 'edit' && !readOnly + + return ( + <> +
+
+ + + + + + + +
+ +
+
+
+ + Mermaid source + + + + + + {typeInfo && ( + + {typeInfo.name} · {typeInfo.capability === 'edit' ? 'WYSIWYG' : 'render'} + + )} +
+ {pane === 'monaco' ? : } +
+ {status ? ✕ {status} : ✓ mermaid parse ok} +
+
+ + ) +} + +/** re-render on document changes */ +function useEditorCode(editor: MermaidWysiwygEditor) { + const [code, setCode] = useState(editor.code) + useEffect(() => editor.on('change', ({ code: next }) => setCode(next)), [editor]) + return code +} + +function TypeTools({ editor, view }: { editor: MermaidWysiwygEditor; view: MermaidCanvasView | null }) { + const typeId = editor.result.typeInfo?.id + const openEditorSoon = (created?: string[]) => { + const id = created?.[0] + if (id) setTimeout(() => view?.editEntityLabel(id), 350) + } + + if (typeId === 'flowchart') { + return ( + <> + + editor.dispatch({ type: 'setDirection', direction: d })} + /> + + ) + } + if (typeId === 'sequence') { + return ( + <> + + + + ) + } + if (typeId === 'state') { + return ( + <> + + editor.dispatch({ type: 'st.setDirection', direction: d })} + /> + + ) + } + if (typeId === 'class') { + return ( + <> + + editor.dispatch({ type: 'cl.setDirection', direction: d })} + /> + + ) + } + if (typeId === 'er') { + return + } + if (typeId === 'pie') { + return + } + if (typeId === 'gantt') { + return + } + if (editor.result.lineItems) { + return + } + return null +} + +function DirectionSelect({ value, options, onChange }: { value: string; options: string[]; onChange: (d: string) => void }) { + const normalized = value === 'TD' && options.includes('TB') ? 'TB' : value === 'TB' && options.includes('TD') ? 'TD' : value + return ( + + ) +} + +function CodePane({ editor }: { editor: MermaidWysiwygEditor }) { + const ref = useRef(null) + useEffect(() => { + if (!ref.current) return + const cm = new MermaidCodeMirror(ref.current, editor) + return () => cm.destroy() + }, [editor]) + return
+} diff --git a/apps/playground/src/MonacoPane.tsx b/apps/playground/src/MonacoPane.tsx new file mode 100644 index 0000000..c783785 --- /dev/null +++ b/apps/playground/src/MonacoPane.tsx @@ -0,0 +1,87 @@ +import { useEffect, useRef } from 'react' +import type { MermaidWysiwygEditor } from '@visimer/core' +import { bindMonaco, registerMermaidLanguage } from '@visimer/monaco' + +/** + * Monaco pane using @visimer/monaco. The app owns the Monaco setup + * (lazy import, worker env, theme, create options); the package owns the sync. + */ +export function MonacoPane({ editor }: { editor: MermaidWysiwygEditor }) { + const ref = useRef(null) + + useEffect(() => { + const host = ref.current + if (!host) return + let disposed = false + let cleanup: (() => void) | null = null + + void (async () => { + const monaco = await import('monaco-editor') + const { default: EditorWorker } = await import('monaco-editor/esm/vs/editor/editor.worker?worker') + ;(self as { MonacoEnvironment?: unknown }).MonacoEnvironment = { getWorker: () => new EditorWorker() } + if (disposed) return + + registerMermaidLanguage(monaco) + monaco.editor.defineTheme('ink-dark', { + base: 'vs-dark', + inherit: true, + rules: [ + { token: 'comment', foreground: '666666', fontStyle: 'italic' }, + { token: 'string', foreground: 'f8b886' }, + { token: 'keyword', foreground: 'bf7af0' }, + { token: 'operator', foreground: '62dfa8' }, + { token: 'number', foreground: 'ff8fa3' }, + ], + colors: { + 'editor.background': '#000000', + 'editor.foreground': '#ededed', + 'editorLineNumber.foreground': '#666666', + 'editorLineNumber.activeForeground': '#a1a1a1', + 'editorCursor.foreground': '#69a3ff', + 'editor.selectionBackground': '#ffffff22', + 'editor.lineHighlightBackground': '#ffffff08', + 'editorWidget.background': '#0a0a0a', + 'editorWidget.border': '#333333', + }, + }) + + const me = monaco.editor.create(host, { + value: editor.code, + language: 'mermaid', + theme: 'ink-dark', + fontSize: 13, + fontFamily: '"JetBrains Mono Variable", "JetBrains Mono", ui-monospace, Menlo, monospace', + minimap: { enabled: false }, + scrollBeyondLastLine: false, + automaticLayout: true, + // the EditContext input path swallows keydown-based keybindings in some + // browsers; the classic textarea input handles them reliably + editContext: false, + renderLineHighlightOnlyWhenFocus: true, + overviewRulerLanes: 0, + hideCursorInOverviewRuler: true, + folding: false, + lineNumbersMinChars: 3, + padding: { top: 8 }, + }) + const binding = bindMonaco(editor, me) + + cleanup = () => { + binding.dispose() + me.dispose() + } + if (disposed) { + cleanup() + cleanup = null + } + })() + + return () => { + disposed = true + cleanup?.() + cleanup = null + } + }, [editor]) + + return
+} diff --git a/apps/playground/src/app.css b/apps/playground/src/app.css new file mode 100644 index 0000000..a3d0007 --- /dev/null +++ b/apps/playground/src/app.css @@ -0,0 +1,185 @@ +/* Open Knowledge design tokens (dark): neutral oklch grays, azure/sky brand + blues, Inter + JetBrains Mono, 0.625rem base radius. */ +:root { + --bg: oklch(0.145 0 0); + --panel: oklch(0.205 0 0); + --panel-2: oklch(0.269 0 0); + --border: oklch(1 0 0 / 10%); + --border-strong: oklch(1 0 0 / 15%); + --fg: oklch(0.985 0 0); + --dim: oklch(0.708 0 0); + --dimmer: oklch(0.556 0 0); + --blue: #69a3ff; /* sky blue: OK's dark-mode --primary */ + --azure: #3784ff; /* brand azure */ + --green: oklch(0.73 0.13 145); + --red: oklch(0.704 0.191 22.216); + --radius: 0.625rem; + --sans: "Inter Variable", "Inter", ui-sans-serif, system-ui, sans-serif; + --mono: "JetBrains Mono Variable", "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace; +} + +* { box-sizing: border-box; } + +html, body, #root { margin: 0; height: 100%; } + +body { + background: var(--bg); + color: var(--fg); + font: 14px/1.5 var(--sans); + -webkit-font-smoothing: antialiased; +} + +.app { display: flex; flex-direction: column; height: 100vh; } + +/* top bar */ +.topbar { + display: flex; align-items: center; justify-content: space-between; + height: 48px; padding: 0 16px; border-bottom: 1px solid var(--border); + background: var(--bg); flex: 0 0 auto; +} +.brand { display: flex; align-items: center; gap: 10px; font-weight: 600; font-size: 14px; letter-spacing: -0.01em; } +.brand .logo { + width: 16px; height: 16px; border-radius: 5px; + background: linear-gradient(135deg, var(--azure), var(--blue)); +} +.brand .sep { color: var(--border-strong); font-weight: 400; } +.brand .sub { color: var(--dim); font-weight: 400; } +.topbar .controls { display: flex; align-items: center; gap: 8px; color: var(--dim); font-size: 13px; } +.topbar a { color: var(--dim); text-decoration: none; font-size: 13px; } +.topbar a:hover { color: var(--fg); } + +/* layout */ +.body { display: flex; flex: 1 1 auto; min-height: 0; } + +.sidebar { + width: 220px; flex: 0 0 auto; overflow-y: auto; padding: 12px 8px; + border-right: 1px solid var(--border); background: var(--bg); +} +.side-label { + font-size: 11px; font-weight: 500; color: var(--dimmer); + text-transform: uppercase; letter-spacing: 0.06em; padding: 4px 8px 8px; +} +.side-item { + display: flex; align-items: center; gap: 8px; width: 100%; + background: none; border: none; color: var(--dim); text-align: left; + padding: 6px 8px; border-radius: 6px; cursor: pointer; font-size: 13px; font-family: var(--sans); +} +.side-item:hover { background: var(--panel-2); color: var(--fg); } +.side-item.active { background: var(--panel-2); color: var(--fg); } +.dot { width: 6px; height: 6px; border-radius: 99px; flex: 0 0 auto; } +.dot.edit { background: var(--green); } +.dot.render { background: var(--border-strong); } +.legend { display: flex; gap: 12px; padding: 12px 8px 4px; color: var(--dimmer); font-size: 11px; } +.legend span { display: flex; align-items: center; gap: 5px; } + +.main { flex: 1 1 56%; display: flex; flex-direction: column; min-width: 0; } + +/* toolbar */ +.toolbar { + display: flex; align-items: center; gap: 6px; padding: 8px 12px; + border-bottom: 1px solid var(--border); background: var(--bg); flex: 0 0 auto; + flex-wrap: wrap; +} +.toolbar.disabled { opacity: 0.4; pointer-events: none; } +.spacer { flex: 1; } + +button, select { + height: 30px; padding: 0 12px; border-radius: 6px; font-size: 13px; font-family: var(--sans); + background: var(--bg); color: var(--fg); border: 1px solid var(--border-strong); + cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s; +} +button:hover:not(:disabled), select:hover { border-color: oklch(1 0 0 / 25%); } +button:disabled { color: var(--dimmer); cursor: default; } +button.primary { background: var(--fg); color: var(--bg); border-color: var(--fg); font-weight: 500; } +button.primary:hover { background: oklch(0.9 0 0); border-color: oklch(0.9 0 0); } +button.danger:hover { border-color: var(--red); color: var(--red); } +select { appearance: none; padding-right: 26px; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%23a1a1a1' viewBox='0 0 16 16'%3E%3Cpath d='M4 6l4 4 4-4'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 8px center; } + +.switch { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; color: var(--dim); cursor: pointer; } + +/* canvas */ +.canvas { + flex: 1 1 auto; min-height: 0; + background-image: radial-gradient(circle at 1px 1px, oklch(0.24 0 0) 1px, transparent 0); + background-size: 24px 24px; + --mw-accent: var(--blue); + --mw-editor-bg: var(--panel-2); + --mw-editor-fg: var(--fg); + --mw-chrome-bg: var(--panel); + --mw-chrome-border: var(--border-strong); + --mw-chrome-fg: var(--fg); + --mw-chrome-hover: oklch(0.269 0 0); + --mw-chrome-dim: var(--dim); +} +.canvas:focus { outline: none; } + +/* mermaid svg polish (rx/ry as CSS works in Chromium/WebKit) */ +.canvas svg .node rect:not(.mw-noround), .canvas svg rect.actor { rx: 6px; ry: 6px; } +.canvas svg .cluster rect { rx: 8px; ry: 8px; } +.canvas svg .edgeLabel foreignObject { overflow: visible; } +.canvas svg span.edgeLabel:not(:empty) { border-radius: 4px; padding: 1px 6px; margin: -1px -6px; display: inline-block; } +.canvas svg span.edgeLabel:empty { background: transparent !important; } + +/* code panel */ +.codepanel { + flex: 1 1 44%; min-width: 340px; max-width: 46%; + display: flex; flex-direction: column; border-left: 1px solid var(--border); +} +.panel-title { + display: flex; align-items: center; justify-content: space-between; + padding: 8px 14px; border-bottom: 1px solid var(--border); + font-size: 11px; font-weight: 500; color: var(--dimmer); + text-transform: uppercase; letter-spacing: 0.06em; +} +.panel-title-left { display: flex; align-items: center; gap: 10px; } +.seg { + display: inline-flex; padding: 2px; gap: 2px; + border: 1px solid var(--border); border-radius: 6px; background: var(--panel); +} +.seg button { + height: 20px; padding: 0 8px; border: none; border-radius: 4px; + font-size: 11px; letter-spacing: 0; text-transform: none; + background: transparent; color: var(--dim); +} +.seg button:hover { border: none; color: var(--fg); } +.seg button.on { background: oklch(0.269 0 0); color: var(--fg); } +.badge { + font-size: 11px; text-transform: none; letter-spacing: 0; font-weight: 500; + padding: 2px 8px; border-radius: 99px; border: 1px solid var(--border-strong); color: var(--dim); +} +.badge.edit { color: var(--green); border-color: color-mix(in srgb, var(--green) 40%, transparent); } + +.codepane { flex: 1 1 auto; min-height: 0; position: relative; } +.codepane .cm-editor { height: 100%; background: transparent; color: var(--fg); } +.codepane .cm-editor.cm-focused { outline: none; } +.codepane .cm-scroller { font: 13px/1.6 var(--mono); } +.codepane .cm-content { caret-color: var(--blue); } +.codepane .cm-cursor { border-left-color: var(--blue); } +.codepane .cm-gutters { background: var(--bg); color: var(--dimmer); border-right: 1px solid var(--border); } +.codepane .cm-activeLine { background: #ffffff08; } +.codepane .cm-activeLineGutter { background: transparent; color: var(--dim); } +.codepane .cm-selectionBackground, .codepane .cm-editor ::selection { background: #ffffff22 !important; } + +.codepane .tok-comment { color: var(--dimmer); font-style: italic; } +.codepane .tok-string { color: #f8b886; } +.codepane .tok-keyword { color: #bf7af0; } +.codepane .tok-heading { color: var(--blue); font-weight: 700; } +.codepane .tok-operator { color: #62dfa8; } +.codepane .tok-number { color: #ff8fa3; } +.codepane .tok-bracket { color: var(--dim); } +.codepane .tok-meta { color: #56d4dd; } + +.codepane .monaco-editor, .codepane .monaco-editor .margin { background: var(--bg); } +.mw-monaco-entity { + background: color-mix(in srgb, var(--blue) 28%, transparent); + outline: 1px solid color-mix(in srgb, var(--blue) 55%, transparent); + border-radius: 3px; +} + +.statusbar { + padding: 8px 14px; border-top: 1px solid var(--border); + font: 12px/1.4 var(--mono); flex: 0 0 auto; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.statusbar .ok { color: var(--green); } +.statusbar .err { color: var(--red); } diff --git a/apps/playground/src/main.tsx b/apps/playground/src/main.tsx new file mode 100644 index 0000000..62cc956 --- /dev/null +++ b/apps/playground/src/main.tsx @@ -0,0 +1,5 @@ +import { createRoot } from 'react-dom/client' +import App from './App' +import './app.css' + +createRoot(document.getElementById('root')!).render() diff --git a/apps/playground/tsconfig.json b/apps/playground/tsconfig.json new file mode 100644 index 0000000..65c214b --- /dev/null +++ b/apps/playground/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "types": ["vite/client"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/playground/vite.config.ts b/apps/playground/vite.config.ts new file mode 100644 index 0000000..a3bb004 --- /dev/null +++ b/apps/playground/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + optimizeDeps: { + exclude: ['@visimer/core', '@visimer/dom', '@visimer/codemirror', '@visimer/react'], + include: ['mermaid', '@mermaid-js/mermaid-zenuml', 'react', 'react-dom'], + }, + server: { + port: 5173, + strictPort: true, + }, +}) diff --git a/apps/site/README.md b/apps/site/README.md new file mode 100644 index 0000000..0dab4ca --- /dev/null +++ b/apps/site/README.md @@ -0,0 +1,21 @@ +# site + +The public showcase for [`@visimer`](../..). A live playground that renders +Mermaid diagrams and lets you edit them by clicking nodes, dragging to connect, +and rewriting labels in place. Deployed as a Vite static build. + +## Local dev + +```bash +pnpm --filter site dev # from repo root, or `pnpm run dev` here +pnpm --filter site build +``` + +Serves on `http://localhost:5174`; edits to the underlying `packages/*` hot-reload. + +## Where this came from + +visimer is built out of the direct-manipulation editing patterns in +[Open Knowledge](https://github.com/inkeep/open-knowledge). Same +click-a-node-to-edit surface that ships inside OK's `.mmd` and Markdown editors, +lifted out as a standalone toolkit anyone can drop into their own app. diff --git a/apps/site/index.html b/apps/site/index.html new file mode 100644 index 0000000..7062b2d --- /dev/null +++ b/apps/site/index.html @@ -0,0 +1,22 @@ + + + + + + Visimer · Edit Mermaid diagrams by hand + + + + + + +
+ + + diff --git a/apps/site/package.json b/apps/site/package.json new file mode 100644 index 0000000..082dc8a --- /dev/null +++ b/apps/site/package.json @@ -0,0 +1,27 @@ +{ + "name": "site", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "@visimer/codemirror": "workspace:*", + "@visimer/core": "workspace:*", + "@visimer/dom": "workspace:*", + "@visimer/react": "workspace:*", + "mermaid": "^11.6.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@vitejs/plugin-react": "^4.3.4", + "typescript": "^5.6.3", + "vite": "^6.3.5" + } +} diff --git a/apps/site/src/App.tsx b/apps/site/src/App.tsx new file mode 100644 index 0000000..817698f --- /dev/null +++ b/apps/site/src/App.tsx @@ -0,0 +1,1012 @@ +import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react' +import { flushSync } from 'react-dom' +import mermaid from 'mermaid' +import { MermaidCodeMirror } from '@visimer/codemirror' +import type { MermaidWysiwygEditor } from '@visimer/core' +import { MermaidCanvas, useMermaidEditor } from '@visimer/react' + +const REPO = 'inkeep/visimer' +const REPO_URL = `https://github.com/${REPO}` +const INSTALL_CMD = 'npm i @visimer/react' + +const PRESETS: Record = { + flowchart: `flowchart LR + A[Write a doc] --> B{Needs a diagram?} + B -->|Yes| C[Click a node to edit] + B -->|No| D[Keep writing] + C --> E[Source stays in sync]`, + sequence: `sequenceDiagram + participant H as Human + participant E as Editor + participant D as Diagram + H->>E: Click a node + E->>D: Rewrite label + D-->>E: Re-render + E-->>H: Source in sync`, + class: `classDiagram + class Editor { + +String source + +render() + +selectNode() + } + class Diagram { + +Node[] nodes + +Edge[] edges + } + Editor --> Diagram : drives`, + state: `stateDiagram-v2 + [*] --> Editing + Editing --> Previewing : render + Previewing --> Editing : click node + Previewing --> [*] : done`, + er: `erDiagram + DOC ||--o{ DIAGRAM : contains + DIAGRAM ||--|{ NODE : has + NODE }o--o{ EDGE : connects`, + gantt: `gantt + title Release plan + dateFormat YYYY-MM-DD + section Build + Editor core :done, a1, 2026-01-01, 20d + Visual editing :active, a2, after a1, 15d + section Ship + Docs :a3, after a2, 10d + v1.0 :milestone, m1, after a3, 0d`, +} + +const TYPE_LABELS: Record = { + flowchart: 'Flowchart', + sequence: 'Sequence', + class: 'Class', + state: 'State', + er: 'ER', + gantt: 'Gantt', +} + +const THEMES: Array<[string, string]> = [ + ['paper', 'Paper'], + ['neutral', 'Neutral'], + ['forest', 'Forest'], + ['dark', 'Dark'], +] + +function themeConfig(theme: string): Record { + const base = { fontFamily: '"Inter", sans-serif' } + if (theme === 'paper') { + return { + theme: 'base', + themeVariables: { + ...base, + primaryColor: '#EAF3F0', + primaryBorderColor: '#0E7C6B', + primaryTextColor: '#1C1A17', + lineColor: '#8A9B96', + secondaryColor: '#F5E9C9', + secondaryBorderColor: '#C9A227', + tertiaryColor: '#FBF9F4', + tertiaryBorderColor: '#E6E0D4', + noteBkgColor: '#F5E9C9', + noteBorderColor: '#C9A227', + }, + } + } + if (theme === 'neutral' || theme === 'forest' || theme === 'dark') { + return { theme, themeVariables: base } + } + return { theme: 'default', themeVariables: base } +} + +function Logo({ size }: { size: number }) { + return ( + + + + + + + + ) +} + +const mono = "'JetBrains Mono', monospace" + +const FEATURES: Array<{ mark: string; title: string; body: string }> = [ + { + mark: '⇄', + title: 'Two-way sync', + body: 'Type Mermaid or edit visually. Source and canvas stay in lockstep, always.', + }, + { + mark: '⊹', + title: 'Click to edit', + body: 'Select any node to rename, reshape, or restyle it. No hunting through syntax for one label.', + }, + { + mark: '❖', + title: 'Minimal diffs', + body: 'Every gesture compiles to the smallest possible text edit. Comments and formatting are never touched.', + }, + { + mark: '{}', + title: 'Framework-agnostic', + body: 'A headless core with React, vanilla DOM, CodeMirror, and Monaco bindings. Drop it into anything.', + }, +] + +const kw: CSSProperties = { color: '#7FB3A8' } +const str: CSSProperties = { color: '#C9A227' } +const ident: CSSProperties = { color: '#9DBF8F' } +const cmt: CSSProperties = { color: '#8A9B96' } + +/** + * The demo's source pane IS the published CodeMirror binding: two-way sync, + * mermaid syntax highlighting, entity highlights on canvas selection, and the + * engine's unified undo — nothing site-specific beyond styling. + */ +function CodeMirrorPane({ editor }: { editor: MermaidWysiwygEditor }) { + const hostRef = useRef(null) + useEffect(() => { + if (!hostRef.current) return + const cm = new MermaidCodeMirror(hostRef.current, editor) + return () => cm.destroy() + }, [editor]) + return
+} + +function CodeCard({ title, children }: { title: string; children: ReactNode }) { + const preRef = useRef(null) + const [copied, setCopied] = useState(false) + const copy = () => { + const text = preRef.current?.textContent ?? '' + void navigator.clipboard?.writeText(text) + setCopied(true) + window.setTimeout(() => setCopied(false), 1400) + } + return ( +
+
+ {title} + +
+
+        {children}
+      
+
+ ) +} + +export default function App() { + const [type, setType] = useState('flowchart') + const [theme, setTheme] = useState('paper') + const [skin, setSkin] = useState<'light' | 'dark'>('light') + const [expanded, setExpanded] = useState(false) + + // Morph the playground shell between inline and fullscreen. Chrome/Safari get + // the crossfade; Firefox just snaps (state update falls through unchanged). + // flushSync is load-bearing — the View Transitions API captures the DOM + // before returning, so React must commit the state update synchronously. + const setExpandedAnimated = (next: boolean) => { + if (typeof document.startViewTransition === 'function') { + document.startViewTransition(() => flushSync(() => setExpanded(next))) + } else { + setExpanded(next) + } + } + + // fullscreen playground: Esc exits, and the page behind must not scroll + useEffect(() => { + if (!expanded) return + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape' && !(e.target instanceof HTMLElement && e.target.isContentEditable)) { + setExpandedAnimated(false) + } + } + window.addEventListener('keydown', onKey) + const prevOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + return () => { + window.removeEventListener('keydown', onKey) + document.body.style.overflow = prevOverflow + } + }, [expanded]) + const { editor } = useMermaidEditor(PRESETS.flowchart) + const [copied, setCopied] = useState(false) + const [installCopied, setInstallCopied] = useState(false) + const [ghStars, setGhStars] = useState(null) + + // Fade the "Leave a star!" hint out as the "Open source · MIT" badge scrolls + // up under the sticky navbar. We track the badge itself so the fade is tied + // to the thing the user actually sees moving. + const badgeRef = useRef(null) + const [starHintOpacity, setStarHintOpacity] = useState(1) + useEffect(() => { + const badge = badgeRef.current + if (!badge) return + const navBottom = 80 + const startTop = badge.getBoundingClientRect().top + window.scrollY - navBottom + const onScroll = () => { + const distance = badge.getBoundingClientRect().top - navBottom + setStarHintOpacity(Math.max(0, Math.min(1, distance / startTop))) + } + onScroll() + window.addEventListener('scroll', onScroll, { passive: true }) + return () => window.removeEventListener('scroll', onScroll) + }, []) + + const switchType = (next: string) => { + setType(next) + editor.setCode(PRESETS[next], 'api') + } + + useEffect(() => { + fetch(`https://api.github.com/repos/${REPO}`) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { + if (!d) return + if (typeof d.stargazers_count === 'number') setGhStars(d.stargazers_count) + }) + .catch(() => {}) + }, []) + + const starsLabel = + ghStars == null ? 'Star' : ghStars >= 1000 ? `${(ghStars / 1000).toFixed(1).replace(/\.0$/, '')}k` : String(ghStars) + const licenseLabel = 'MIT' + + const copyInstall = () => { + void navigator.clipboard?.writeText(INSTALL_CMD) + setInstallCopied(true) + window.setTimeout(() => setInstallCopied(false), 1400) + } + const copySource = () => { + void navigator.clipboard?.writeText(editor.code) + setCopied(true) + window.setTimeout(() => setCopied(false), 1400) + } + const reset = () => editor.setCode(PRESETS[type], 'api') + + const dark = skin === 'dark' + const chromeBorder = dark ? '#2E2A22' : '#E6E0D4' + const paneBg = dark ? '#1E1B16' : '#FCFAF5' + const previewBg = dark ? '#17140F' : '#FFFFFF' + const paneText = dark ? '#EDE7DA' : '#1C1A17' + + const pill = (active: boolean): CSSProperties => ({ + border: `1px solid ${active ? '#0E7C6B' : chromeBorder}`, + background: active ? '#0E7C6B' : dark ? '#231F19' : '#FFFFFF', + color: active ? '#FFFFFF' : dark ? '#C9C2B4' : '#544F47', + fontFamily: "'Inter', sans-serif", + fontSize: 12.5, + fontWeight: 600, + padding: '6px 12px', + borderRadius: 8, + cursor: 'pointer', + transition: 'all .12s', + }) + const miniPill = (active: boolean): CSSProperties => ({ + border: `1px solid ${active ? '#0E7C6B' : chromeBorder}`, + background: active ? '#EAF3F0' : 'transparent', + color: active ? '#0A5C50' : dark ? '#9C968A' : '#6B6559', + fontFamily: "'Inter', sans-serif", + fontSize: 11.5, + fontWeight: 600, + padding: '4px 9px', + borderRadius: 7, + cursor: 'pointer', + }) + const ghostBtn: CSSProperties = { + border: `1px solid ${chromeBorder}`, + background: 'transparent', + color: dark ? '#9C968A' : '#6B6559', + fontFamily: "'Inter', sans-serif", + fontSize: 11.5, + fontWeight: 600, + padding: '4px 9px', + borderRadius: 7, + cursor: 'pointer', + } + const chromeLabel: CSSProperties = { + fontSize: 11.5, + color: dark ? '#8C8578' : '#8A857A', + fontWeight: 500, + } + + return ( +
+
+ +
+ +
+
+
+ + Open source · {licenseLabel} · React & vanilla +
+

+ Edit Mermaid diagrams by hand. +

+

+ A WYSIWYG editor for Mermaid. Click a node to rename or reshape it. The source rewrites itself. Perfect + for fixing up AI-generated diagrams the visual way. +

+ +
+ $ + {INSTALL_CMD} + +
+
+ +
+
+
+ Playground +
+

+ Take it for a spin. +

+

+ This is the real editor running in your browser. Pick a diagram type, edit the source, click nodes. + Nothing to install. +

+
+
+
+
+ {Object.keys(TYPE_LABELS).map((k) => ( + + ))} +
+
+
+ Theme + {THEMES.map(([k, l]) => ( + + ))} +
+ + +
+
+ +
+
+
+ + {type}.mmd +
+ + +
+
+ +
+ +
+
+ Preview + click to select · double-click to edit · drag empty space to pan · pinch to zoom +
+
+ +
+
+
+
+

+ Tip: click any box in the preview. The popover on the diagram edits it. Watch the source pane rewrite + itself. +

+
+ +
+

+ Diagrams you can actually touch. +

+

+ Everything a human needs to fix a diagram fast, without re-learning Mermaid syntax to move one arrow. +

+
+ {FEATURES.map((f) => ( +
+
+ {f.mark} +
+

{f.title}

+

{f.body}

+
+ ))} +
+
+ {[ + 'read-only mode', + 'error-tolerant rendering', + 'drag-to-connect', + 'drag to reorder messages', + 'unified undo across code + canvas', + 'code ⇄ canvas selection sync', + 'renders through your mermaid instance', + 'entity hooks', + ].map((chip) => ( + + {chip} + + ))} +
+
+ +
+

+ Add it to your own editor. +

+

+ The playground above is this exact package. One headless engine with React, vanilla DOM, CodeMirror, + and Monaco bindings. Six recipes cover the whole surface. +

+
+ + # npm i @visimer/react + {'\n'} + import mermaid from 'mermaid' + {'\n'} + import {'{ MermaidWysiwyg }'} from{' '} + '@visimer/react' + {'\n\n'}<MermaidWysiwyg code={'{diagram}'}{' '} + onCodeChange={'{setDiagram}'} mermaid={'{mermaid}'} /> + + + # framework-free canvas, any app + {'\n'} + const editor = new MermaidWysiwygEditor({'{ '} + code + {' }'}) + {'\n'} + new MermaidCanvasView({'{ '} + editor, container, mermaid + {' }'}) + {'\n'}editor.on('change', ({'{ '} + code + {' }'}) => save(code)) + + + # the source pane in the playground above + {'\n'} + import {'{ MermaidCodeMirror }'} from{' '} + '@visimer/codemirror' + {'\n\n'} + new MermaidCodeMirror(host, editor) + {'\n'} + # typing, highlights, undo, all shared with the canvas + + + # bring your own monaco instance; zero monaco dependency + {'\n'} + import {'{ bindMonaco }'} from{' '} + '@visimer/monaco' + {'\n\n'} + const binding = bindMonaco(editor, monacoEditor) + {'\n'}binding.dispose() + + + # every gesture is also a semantic op + {'\n'}editor.dispatch({'{ '} + type: 'renameNode', id:{' '} + 'B', label: 'Valid?' + {' }'}) + {'\n'}editor.dispatch({'{ '} + type: 'connect', source:{' '} + 'A', target: 'C' + {' }'}) + {'\n'}editor.undo() + + + # chrome follows your tokens; mermaid keeps its themes + {'\n'}.mw-canvas {'{'} + {'\n'} --mw-accent: #0E7C6B; + {'\n'} --mw-chrome-bg: #1E1B16; + {'\n'} --mw-chrome-fg: #EDE7DA; + {'\n'}{'}'} + {'\n'} + # or per-instance: accentColor · mermaidConfig={'{ theme: "dark" }'} + +
+
+ + +
+
+ ) +} diff --git a/apps/site/src/main.tsx b/apps/site/src/main.tsx new file mode 100644 index 0000000..8f0debb --- /dev/null +++ b/apps/site/src/main.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import App from './App' +import './site.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/apps/site/src/site.css b/apps/site/src/site.css new file mode 100644 index 0000000..82a52ef --- /dev/null +++ b/apps/site/src/site.css @@ -0,0 +1,167 @@ +* { + box-sizing: border-box; +} +html { + scroll-behavior: smooth; +} +body { + margin: 0; + background: #f7f4ed; + color: #1c1a17; + font-family: 'Inter', sans-serif; + -webkit-font-smoothing: antialiased; +} +a { + color: #0e7c6b; + text-decoration: none; +} +a:hover { + color: #0a5c50; +} +::selection { + background: #cbe7df; + color: #122a25; +} +textarea { + font-family: 'JetBrains Mono', monospace; +} +@keyframes okfade { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: none; + } +} + +/* Playground expand: smooth 400ms morph via the View Transitions API. */ +::view-transition-old(playground-shell), +::view-transition-new(playground-shell) { + animation-duration: 400ms; + animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1); +} + +/* The live demo embeds the real editor; keep its canvas transparent so the + dotted preview background shows through, and center the rendered SVG the + way the design's preview pane does. */ +/* the package injects `.mw-canvas { position: relative }` after this sheet, + so the fill-the-pane positioning must out-rank it */ +.site-demo-canvas.mw-canvas { + --mw-accent: #0e7c6b; + position: absolute; + inset: 0; +} + +/* Demo source pane — the real @visimer/codemirror binding, + restyled to the site palette. Light and dark variants keyed off the shell. */ +.demo-cm { + flex: 1; + min-height: 0; + overflow: hidden; + display: flex; +} +.demo-cm .cm-editor { + flex: 1; + height: 100%; + background: transparent; + font-family: 'JetBrains Mono', monospace; + font-size: 13px; + outline: none; +} +.demo-cm .cm-editor.cm-focused { + outline: none; +} +.demo-cm .cm-scroller { + overflow: auto; + line-height: 1.7; + font-family: inherit; + padding: 8px 0; +} +.demo-cm .cm-content { + caret-color: #0e7c6b; + color: #1c1a17; +} +.demo-cm .cm-gutters { + background: transparent; + border-right: none; + color: #b8b2a4; + font-size: 11px; + padding-left: 6px; +} +.demo-cm .cm-activeLine { + background: rgba(14, 124, 107, 0.05); +} +.demo-cm .cm-activeLineGutter { + background: transparent; + color: #0e7c6b; +} +.demo-cm .cm-selectionBackground, +.demo-cm .cm-editor ::selection { + background: #cbe7df; +} +/* mermaid token colors (classHighlighter tok-* classes) — light pane */ +.demo-cm .tok-heading { + color: #0e7c6b; + font-weight: 600; + text-decoration: none; +} +.demo-cm .tok-keyword { + color: #0a5c50; +} +.demo-cm .tok-operator { + color: #b58614; +} +.demo-cm .tok-string { + color: #a07c1e; +} +.demo-cm .tok-comment { + color: #a39d90; + font-style: italic; +} +.demo-cm .tok-number { + color: #6b6559; +} +.demo-cm .tok-bracket { + color: #8a857a; +} +.demo-cm .tok-meta { + color: #8a9b96; +} +/* dark pane variant */ +.demo-pane-dark .demo-cm .cm-content { + caret-color: #7fb3a8; + color: #ede7da; +} +.demo-pane-dark .demo-cm .cm-gutters { + color: #7a7365; +} +.demo-pane-dark .demo-cm .cm-activeLine { + background: rgba(127, 179, 168, 0.07); +} +.demo-pane-dark .demo-cm .cm-selectionBackground, +.demo-pane-dark .demo-cm .cm-editor ::selection { + background: #2e4a44; +} +.demo-pane-dark .demo-cm .tok-heading { + color: #7fb3a8; +} +.demo-pane-dark .demo-cm .tok-keyword { + color: #9dbf8f; +} +.demo-pane-dark .demo-cm .tok-operator { + color: #c9a227; +} +.demo-pane-dark .demo-cm .tok-string { + color: #d9b95c; +} +.demo-pane-dark .demo-cm .tok-comment { + color: #8c8578; +} +.demo-pane-dark .demo-cm .tok-number { + color: #c9c2b4; +} +.demo-pane-dark .demo-cm .tok-bracket { + color: #9c968a; +} diff --git a/apps/site/tsconfig.json b/apps/site/tsconfig.json new file mode 100644 index 0000000..65c214b --- /dev/null +++ b/apps/site/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx", + "types": ["vite/client"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/site/vercel.json b/apps/site/vercel.json new file mode 100644 index 0000000..e668a8a --- /dev/null +++ b/apps/site/vercel.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "vite", + "installCommand": "pnpm install --frozen-lockfile", + "buildCommand": "pnpm build", + "outputDirectory": "dist" +} diff --git a/apps/site/vite.config.ts b/apps/site/vite.config.ts new file mode 100644 index 0000000..1886f0c --- /dev/null +++ b/apps/site/vite.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [react()], + optimizeDeps: { + exclude: ['@visimer/core', '@visimer/dom', '@visimer/react', '@visimer/codemirror'], + include: ['mermaid', 'react', 'react-dom', '@codemirror/state', '@codemirror/view', '@codemirror/commands', '@codemirror/language', '@lezer/highlight'], + }, + server: { + port: 5174, + strictPort: true, + }, +}) diff --git a/package.json b/package.json new file mode 100644 index 0000000..aee26de --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "visimer-monorepo", + "private": true, + "packageManager": "pnpm@10.33.0", + "scripts": { + "dev": "pnpm --filter playground dev", + "build": "pnpm -r --filter './packages/*' build", + "test": "pnpm -r run test", + "typecheck": "pnpm -r typecheck", + "dev:js": "pnpm --filter js-playground dev", + "dev:site": "pnpm --filter site dev", + "build:site": "pnpm --filter site build", + "changeset": "changeset", + "version-packages": "changeset version", + "release": "pnpm build && changeset publish" + }, + "devDependencies": { + "tsup": "^8.5.1", + "typescript": "^5.6.3", + "@changesets/cli": "^2.27.10" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "esbuild" + ] + } +} diff --git a/packages/codemirror/README.md b/packages/codemirror/README.md new file mode 100644 index 0000000..b2abfa0 --- /dev/null +++ b/packages/codemirror/README.md @@ -0,0 +1,16 @@ +# @visimer/codemirror + +CodeMirror 6 pane for [visimer](https://github.com/inkeep/visimer): +two-way sync with the editing engine (canvas changes apply as exact minimal edits), +entity selection as decorations, caret → entity selection, mermaid syntax highlighting, +and ⌘Z routed to the engine's unified history, the same undo stack the canvas uses. + +```ts +import { MermaidWysiwygEditor } from '@visimer/core' +import { MermaidCodeMirror } from '@visimer/codemirror' + +const editor = new MermaidWysiwygEditor({ code: 'flowchart TD\n A --> B' }) +new MermaidCodeMirror(hostElement, editor) +``` + +Docs: [github.com/inkeep/visimer](https://github.com/inkeep/visimer) diff --git a/packages/codemirror/package.json b/packages/codemirror/package.json new file mode 100644 index 0000000..1e971b9 --- /dev/null +++ b/packages/codemirror/package.json @@ -0,0 +1,69 @@ +{ + "name": "@visimer/codemirror", + "version": "0.1.0", + "description": "CodeMirror 6 binding for visimer: two-way code sync, entity highlight decorations, shared undo authority.", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json", + "build": "tsup src/index.ts --format esm --dts --sourcemap --out-dir dist", + "test": "vitest run" + }, + "dependencies": { + "@visimer/core": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.6.3", + "vitest": "^2.1.9", + "jsdom": "^25.0.1", + "@codemirror/commands": "^6.6.1", + "@codemirror/language": "^6.10.3", + "@codemirror/state": "^6.4.1", + "@codemirror/view": "^6.34.1", + "@lezer/highlight": "^1.2.1" + }, + "license": "MIT", + "files": [ + "dist", + "src" + ], + "publishConfig": { + "access": "public", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + } + }, + "author": "Inkeep", + "repository": { + "type": "git", + "url": "git+https://github.com/inkeep/visimer.git", + "directory": "packages/codemirror" + }, + "homepage": "https://github.com/inkeep/visimer#readme", + "bugs": "https://github.com/inkeep/visimer/issues", + "keywords": [ + "mermaid", + "wysiwyg", + "diagram", + "editor", + "codemirror", + "visual-editing" + ], + "sideEffects": false, + "peerDependencies": { + "@codemirror/commands": "^6.6.1", + "@codemirror/language": "^6.10.3", + "@codemirror/state": "^6.4.1", + "@codemirror/view": "^6.34.1", + "@lezer/highlight": "^1.2.1" + } +} diff --git a/packages/codemirror/src/index.ts b/packages/codemirror/src/index.ts new file mode 100644 index 0000000..86c7d2b --- /dev/null +++ b/packages/codemirror/src/index.ts @@ -0,0 +1,116 @@ +import { defaultKeymap } from '@codemirror/commands' +import { EditorState, StateEffect, StateField } from '@codemirror/state' +import { Decoration, EditorView, keymap, lineNumbers, type DecorationSet } from '@codemirror/view' +import { bindTextPane, type MermaidWysiwygEditor, type Span, type TextPaneBinding } from '@visimer/core' +import { mermaidLanguage } from './language' + +export { mermaidLanguage } from './language' + +const setHighlights = StateEffect.define() + +const highlightMark = Decoration.mark({ class: 'mw-cm-entity' }) + +const highlightField = StateField.define({ + create: () => Decoration.none, + update(deco, tr) { + deco = deco.map(tr.changes) + for (const e of tr.effects) { + if (e.is(setHighlights)) { + deco = Decoration.set( + e.value.filter((s) => s.end <= tr.newDoc.length).map((s) => highlightMark.range(s.start, s.end)), + ) + } + } + return deco + }, + provide: (f) => EditorView.decorations.from(f), +}) + +const baseTheme = EditorView.baseTheme({ + '.mw-cm-entity': { + backgroundColor: 'color-mix(in srgb, var(--mw-accent, #6366f1) 28%, transparent)', + outline: '1px solid color-mix(in srgb, var(--mw-accent, #6366f1) 55%, transparent)', + borderRadius: '3px', + }, +}) + +/** + * CodeMirror 6 pane bound to a MermaidWysiwygEditor. + * + * A thin adapter over core's editor-agnostic `bindTextPane`; the same contract + * works for Monaco or any other code editor. + * + * - typing flows into the engine (`origin: 'code'`), keeping the canvas live + * - canvas/api/history changes are applied as the engine's exact minimal edits + * - entity selection renders as decorations; the caret selects entities + * - ⌘Z/⌘⇧Z route to the engine's unified history (the same stack the canvas uses) + */ +export class MermaidCodeMirror { + readonly view: EditorView + private binding: TextPaneBinding + + constructor(host: HTMLElement, editor: MermaidWysiwygEditor, extraExtensions: unknown[] = []) { + const updateListener = EditorView.updateListener.of((update) => { + if (update.docChanged) this.binding.notifyTextChange() + else if (update.selectionSet) this.binding.notifyCaretMove(update.state.selection.main.head) + }) + + this.view = new EditorView({ + parent: host, + state: EditorState.create({ + doc: editor.code, + extensions: [ + keymap.of([ + { + key: 'Mod-z', + run: () => { + this.binding.undo() + return true + }, + }, + { + key: 'Mod-Shift-z', + run: () => { + this.binding.redo() + return true + }, + }, + ]), + keymap.of(defaultKeymap), + lineNumbers(), + mermaidLanguage(), + highlightField, + baseTheme, + updateListener, + ...(extraExtensions as []), + ], + }), + }) + + this.binding = bindTextPane(editor, { + getText: () => this.view.state.doc.toString(), + applyEdits: (edits) => { + this.view.dispatch({ changes: edits.map((e) => ({ from: e.start, to: e.end, insert: e.text })) }) + }, + setText: (text) => { + this.view.dispatch({ changes: { from: 0, to: this.view.state.doc.length, insert: text } }) + }, + setHighlights: (spans) => this.view.dispatch({ effects: setHighlights.of(spans) }), + revealPosition: (offset) => { + // Scroll only the editor's own scroller. CodeMirror's scrollIntoView + // effect also scrolls every scrollable ancestor — including the page — + // so a canvas click revealing a span would yank the host app's + // viewport around. + const block = this.view.lineBlockAt(offset) + const scroller = this.view.scrollDOM + const top = block.top - (scroller.clientHeight - block.height) / 2 + scroller.scrollTop = Math.max(0, top) + }, + }) + } + + destroy() { + this.binding.dispose() + this.view.destroy() + } +} diff --git a/packages/codemirror/src/language.ts b/packages/codemirror/src/language.ts new file mode 100644 index 0000000..91cfed0 --- /dev/null +++ b/packages/codemirror/src/language.ts @@ -0,0 +1,38 @@ +import { StreamLanguage, syntaxHighlighting } from '@codemirror/language' +import { classHighlighter } from '@lezer/highlight' +import type { Extension } from '@codemirror/state' + +const HEADERS = + /^(flowchart|graph|sequenceDiagram|classDiagram(-v2)?|stateDiagram(-v2)?|erDiagram|journey|gantt|pie|quadrantChart|requirementDiagram|gitGraph|C4Context|C4Container|C4Component|C4Dynamic|C4Deployment|mindmap|timeline|zenuml|sankey-beta|xychart-beta|block-beta|packet-beta|kanban|architecture-beta|radar-beta|treemap-beta)\b/ + +const KEYWORDS = + /^(participant|actor|note|loop|alt|else|opt|par|and|critical|option|break|rect|box|activate|deactivate|autonumber|over|left|right|of|end|section|title|direction|subgraph|state|class|classDef|linkStyle|style|click|namespace|dateFormat|axisFormat|excludes|includes|todayMarker|tickInterval|weekday|commit|branch|checkout|merge|cherry-pick|showData|accTitle|accDescr|columns|x-axis|y-axis|bar|line|axis|curve|max|min|for|as)\b/ + +// connection operators across diagram types: flowchart arrows, sequence +// messages, class relations, ER cardinalities, state transitions +const OPERATORS = + /^(<\|--|--\|>|<\|\.\.|\.\.\|>|\*--|--\*|o--|--o|<-->|<--|-->|<\.\.|\.\.>|-{2,}[>xo]?|={2,}[>xo]?|-\.+-*[>xo]?|~{3,}|--?>>|--?[x)]|[|}][|o]--[|o][{|]|[|}][|o]\.\.[|o][{|]|:::?)/ + +const mermaidStream = StreamLanguage.define({ + name: 'mermaid', + token(stream) { + if (stream.sol()) stream.eatSpace() + if (stream.match(/^%%.*/)) return 'comment' + if (stream.match(/^"([^"\\]|\\.)*"?/)) return 'string' + if (stream.match(/^<<\w+>>/)) return 'meta' + if (stream.match(HEADERS)) return 'heading' + if (stream.match(KEYWORDS)) return 'keyword' + if (stream.match(OPERATORS)) return 'operator' + if (stream.match(/^\d{4}-\d{2}-\d{2}/)) return 'number' + if (stream.match(/^\d+(\.\d+)?[a-z]*/)) return 'number' + if (stream.match(/^[[\]{}()<>|&]/)) return 'bracket' + if (stream.match(/^[\w-]+/)) return null + stream.next() + return null + }, +}) + +/** Lightweight mermaid syntax highlighting (token classes styled via CSS). */ +export function mermaidLanguage(): Extension { + return [mermaidStream, syntaxHighlighting(classHighlighter)] +} diff --git a/packages/codemirror/test/binding.test.ts b/packages/codemirror/test/binding.test.ts new file mode 100644 index 0000000..42aa09d --- /dev/null +++ b/packages/codemirror/test/binding.test.ts @@ -0,0 +1,68 @@ +// @vitest-environment jsdom +import { describe, expect, it } from 'vitest' +import { MermaidWysiwygEditor } from '@visimer/core' +import { MermaidCodeMirror } from '../src' + +const CODE = 'flowchart TD\n A[Start] --> B[End]\n' + +function mount(code = CODE) { + const editor = new MermaidWysiwygEditor({ code }) + const host = document.createElement('div') + document.body.appendChild(host) + const cm = new MermaidCodeMirror(host, editor) + return { editor, host, cm } +} + +describe('MermaidCodeMirror against real CodeMirror 6', () => { + it('mirrors engine ops into the view', () => { + const { editor, cm } = mount() + editor.dispatch({ type: 'renameNode', id: 'A', label: 'Begin' }) + expect(cm.view.state.doc.toString()).toBe(editor.code) + expect(cm.view.state.doc.toString()).toContain('A[Begin]') + cm.destroy() + }) + + it('flows view edits into the engine without echoing back', () => { + const { editor, cm } = mount() + const pos = cm.view.state.doc.toString().indexOf('B[End]') + cm.view.dispatch({ changes: { from: pos, to: pos + 6, insert: 'B[Done]' } }) + expect(editor.code).toBe(cm.view.state.doc.toString()) + expect(editor.code).toContain('B[Done]') + cm.destroy() + }) + + it('renders entity selection as decorations', () => { + const { editor, host, cm } = mount() + editor.setSelection(['node:A'], 'canvas') + const marks = host.querySelectorAll('.mw-cm-entity') + expect(marks.length).toBeGreaterThan(0) + expect([...marks].map((m) => m.textContent).join('')).toContain('A[Start]') + cm.destroy() + }) + + it('caret movement selects the entity under it', () => { + const { editor, cm } = mount() + const anchor = cm.view.state.doc.toString().indexOf('A[Start]') + 1 + cm.view.dispatch({ selection: { anchor } }) + expect(editor.selection).toEqual(['node:A']) + cm.destroy() + }) + + it('keeps engine undo authoritative across both surfaces', () => { + const { editor, cm } = mount() + editor.dispatch({ type: 'renameNode', id: 'A', label: 'Begin' }) + editor.undo() + expect(editor.code).toBe(CODE) + expect(cm.view.state.doc.toString()).toBe(CODE) + editor.redo() + expect(cm.view.state.doc.toString()).toContain('A[Begin]') + cm.destroy() + }) + + it('stops syncing after destroy', () => { + const { editor, cm } = mount() + cm.destroy() + editor.dispatch({ type: 'renameNode', id: 'A', label: 'Begin' }) + expect(cm.view.state.doc.toString()).toBe(CODE) + }) +}) diff --git a/packages/codemirror/tsconfig.json b/packages/codemirror/tsconfig.json new file mode 100644 index 0000000..3365043 --- /dev/null +++ b/packages/codemirror/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src", "test"] +} diff --git a/packages/core/README.md b/packages/core/README.md new file mode 100644 index 0000000..da2a94e --- /dev/null +++ b/packages/core/README.md @@ -0,0 +1,47 @@ +# @visimer/core + +Headless bidirectional editing engine for [Mermaid](https://mermaid.js.org) diagrams. +Text is the source of truth: a lossless CST and per-type semantic graphs turn visual +operations (rename, connect, reorder, restyle) into **minimal text edits** against your +actual Mermaid source. Zero DOM dependencies. + +```ts +import { MermaidWysiwygEditor } from '@visimer/core' + +const editor = new MermaidWysiwygEditor({ code: 'flowchart TD\n A --> B' }) +editor.dispatch({ type: 'renameNode', id: 'A', label: 'Start' }) +editor.code // 'flowchart TD\n A[Start] --> B' +editor.undo() +``` + +## Bring your own code editor + +`bindTextPane` syncs the engine with any code editor behind a five-method adapter. +For CodeMirror use [`@visimer/codemirror`](https://npmjs.com/package/@visimer/codemirror), +for Monaco use [`@visimer/monaco`](https://npmjs.com/package/@visimer/monaco); +both are implementations of this contract. For anything else, implement the adapter: + +```ts +import { bindTextPane } from '@visimer/core' + +const binding = bindTextPane(editor, { + getText: () => /* current document text */, + applyEdits: (edits) => /* apply {start, end, text} offsets to the document */, + setText: (text) => /* replace the whole document (resync safety net) */, + setHighlights: (spans) => /* highlight the selected entities' ranges */, + revealPosition: (offset) => /* optional: scroll an offset into view */, +}) + +// then wire your editor's events: +// document changed -> binding.notifyTextChange() +// caret moved -> binding.notifyCaretMove(offset) +// undo/redo keys -> binding.undo() / binding.redo() +``` + +Typing flows into the engine, engine changes come back as minimal edits, entity +selection syncs both ways, and undo/redo shares the canvas history stack. The +notify methods no-op while the binding is applying engine edits, so there is no +echo loop to guard against. + +Pairs with [`@visimer/dom`](https://npmjs.com/package/@visimer/dom) +(interactive canvas). Docs: [github.com/inkeep/visimer](https://github.com/inkeep/visimer) diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..c2711ac --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,53 @@ +{ + "name": "@visimer/core", + "version": "0.1.0", + "description": "Headless bidirectional editing engine for Mermaid diagrams: lossless CST, semantic graph, ops compiled to minimal text edits.", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json", + "build": "tsup src/index.ts --format esm --dts --sourcemap --out-dir dist" + }, + "devDependencies": { + "typescript": "^5.6.3", + "vitest": "^2.1.9" + }, + "license": "MIT", + "files": [ + "dist", + "src" + ], + "publishConfig": { + "access": "public", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + } + }, + "author": "Inkeep", + "repository": { + "type": "git", + "url": "git+https://github.com/inkeep/visimer.git", + "directory": "packages/core" + }, + "homepage": "https://github.com/inkeep/visimer#readme", + "bugs": "https://github.com/inkeep/visimer/issues", + "keywords": [ + "mermaid", + "wysiwyg", + "diagram", + "editor", + "flowchart", + "visual-editing" + ], + "sideEffects": false +} diff --git a/packages/core/src/class/graph.ts b/packages/core/src/class/graph.ts new file mode 100644 index 0000000..cd38c9c --- /dev/null +++ b/packages/core/src/class/graph.ts @@ -0,0 +1,202 @@ +import type { LineInfo, Span } from '../types' +import { parseClassStatement, type ClClassDeclStmt, type ClRelationStmt, type ClStmt } from './parse' + +export interface ClMember { + lineIndex: number + text: string + textSpan: Span +} + +export interface ClClass { + /** entity id: `class:` */ + entityId: string + id: string + label: string + decl: ClClassDeclStmt | null + /** open/close line indexes when declared with a `{ }` block */ + block: { open: number; close: number } | null + members: ClMember[] + /** one-liner member/annotation lines outside the block */ + extraLines: number[] + refLines: number[] + firstRefLine: number +} + +export interface ClRelation { + /** entity id: `rel:->#` */ + entityId: string + source: string + target: string + lineIndex: number + stmt: ClRelationStmt + label: string | null + order: number +} + +export interface ClassGraph { + headerLine: number + classes: ClClass[] + classById: Map + relations: ClRelation[] + statements: Map + direction: { value: string; lineIndex: number } | null + lastContentLine: number +} + +export function buildClassGraph(lines: LineInfo[], headerIndex: number): ClassGraph { + const classes = new Map() + const relations: ClRelation[] = [] + const statements = new Map() + const occurrence = new Map() + let direction: ClassGraph['direction'] = null + let lastContentLine = headerIndex + let blockOwner: string | null = null + + const touch = (id: string, lineIndex: number): ClClass => { + let c = classes.get(id) + if (!c) { + c = { + entityId: `class:${id}`, + id, + label: id, + decl: null, + block: null, + members: [], + extraLines: [], + refLines: [], + firstRefLine: lineIndex, + } + classes.set(id, c) + } + return c + } + + for (const line of lines) { + if (line.kind !== 'statement' || line.index === headerIndex) { + if (line.index === headerIndex) lastContentLine = line.index + continue + } + const stmt = parseClassStatement(line, blockOwner) + statements.set(line.index, stmt) + lastContentLine = line.index + + switch (stmt.kind) { + case 'classDecl': { + const c = touch(stmt.id, stmt.lineIndex) + c.decl = stmt + if (stmt.label !== null) c.label = stmt.label + c.refLines.push(stmt.lineIndex) + if (stmt.opensBlock) { + c.block = { open: stmt.lineIndex, close: -1 } + blockOwner = stmt.id + } + break + } + case 'blockClose': { + if (blockOwner) { + const c = classes.get(blockOwner) + if (c?.block) c.block.close = stmt.lineIndex + blockOwner = null + } + break + } + case 'member': { + const owner = stmt.classId ?? blockOwner + if (owner) { + const c = touch(owner, stmt.lineIndex) + c.members.push({ lineIndex: stmt.lineIndex, text: stmt.text, textSpan: stmt.textSpan }) + if (stmt.classId) c.extraLines.push(stmt.lineIndex) + } + break + } + case 'annotation': { + if (stmt.parts.length === 2) { + const c = touch(stmt.parts[1], stmt.lineIndex) + c.extraLines.push(stmt.lineIndex) + } + break + } + case 'relation': { + const src = touch(stmt.source, stmt.lineIndex) + const tgt = touch(stmt.target, stmt.lineIndex) + src.refLines.push(stmt.lineIndex) + tgt.refLines.push(stmt.lineIndex) + const key = `${stmt.source}->${stmt.target}` + const occ = occurrence.get(key) ?? 0 + occurrence.set(key, occ + 1) + relations.push({ + entityId: `rel:${key}#${occ}`, + source: stmt.source, + target: stmt.target, + lineIndex: stmt.lineIndex, + stmt, + label: stmt.label, + order: relations.length, + }) + break + } + case 'direction': + direction = { value: stmt.parts[0], lineIndex: stmt.lineIndex } + break + default: + break + } + } + + return { + headerLine: headerIndex, + classes: [...classes.values()], + classById: classes, + relations, + statements, + direction, + lastContentLine, + } +} + +export function classEntitySpans(graph: ClassGraph, lines: LineInfo[], entityId: string): Span[] { + if (entityId.startsWith('class:')) { + const c = graph.classById.get(entityId.slice(6)) + if (!c) return [] + const spans: Span[] = [] + if (c.decl) { + const line = lines[c.decl.lineIndex] + spans.push({ start: line.start + line.indent.length, end: line.end }) + } + for (const r of graph.relations) { + if (r.source === c.id) spans.push(r.stmt.sourceSpan) + if (r.target === c.id) spans.push(r.stmt.targetSpan) + } + return spans + } + if (entityId.startsWith('rel:')) { + const r = graph.relations.find((rel) => rel.entityId === entityId) + if (!r) return [] + const line = lines[r.lineIndex] + return [{ start: line.start + line.indent.length, end: line.end }] + } + return [] +} + +export function classEntityAt(graph: ClassGraph, lines: LineInfo[], offset: number): string | null { + for (const r of graph.relations) { + const line = lines[r.lineIndex] + if (offset >= line.start && offset <= line.end) { + if (offset >= r.stmt.sourceSpan.start && offset <= r.stmt.sourceSpan.end) return `class:${r.source}` + if (offset >= r.stmt.targetSpan.start && offset <= r.stmt.targetSpan.end) return `class:${r.target}` + return r.entityId + } + } + for (const c of graph.classes) { + const linesToCheck = [ + ...(c.decl ? [c.decl.lineIndex] : []), + ...c.members.map((m) => m.lineIndex), + ...c.extraLines, + ] + for (const li of linesToCheck) { + const line = lines[li] + if (offset >= line.start && offset <= line.end) return c.entityId + } + } + return null +} diff --git a/packages/core/src/class/ops.ts b/packages/core/src/class/ops.ts new file mode 100644 index 0000000..1640c21 --- /dev/null +++ b/packages/core/src/class/ops.ts @@ -0,0 +1,269 @@ +import type { LineInfo, TextEdit } from '../types' +import type { ClassGraph, ClRelation } from './graph' +import type { RelationOp } from './parse' + +export type ClassOp = + | { type: 'cl.addClass'; name?: string } + | { type: 'cl.connect'; source: string; target: string; op?: RelationOp; label?: string } + | { type: 'cl.renameClass'; id: string; name: string } + | { type: 'cl.setRelationType'; relId: string; op: RelationOp } + | { type: 'cl.setRelationLabel'; relId: string; label: string } + | { type: 'cl.reverseRelation'; relId: string } + | { type: 'cl.addMember'; id: string; text?: string } + | { type: 'cl.setMemberText'; id: string; memberLine: number; text: string } + | { type: 'cl.deleteMember'; id: string; memberLine: number } + | { type: 'cl.deleteClass'; id: string } + | { type: 'cl.deleteRelation'; relId: string } + | { type: 'cl.setDirection'; direction: string } + | { type: 'cl.setAnnotation'; id: string; annotation: string | null } + | { type: 'cl.addNoteFor'; id: string; text?: string } + | { type: 'cl.setCardinality'; relId: string; side: 'source' | 'target'; value: string | null } + +export interface ClOpContext { + code: string + lines: LineInfo[] + graph: ClassGraph +} + +export interface ClOpResult { + edits: TextEdit[] + created?: string[] +} + +function bodyIndent(ctx: ClOpContext): string { + for (const line of ctx.lines) { + if (line.kind === 'statement' && line.index !== ctx.graph.headerLine && line.text.trim() !== '') { + return line.indent + } + } + return ' ' +} + +function insertAfterLine(ctx: ClOpContext, lineIndex: number, texts: string[]): TextEdit { + const line = ctx.lines[lineIndex] + return { start: line.end, end: line.end, text: texts.map((t) => `\n${t}`).join('') } +} + +function deleteLine(ctx: ClOpContext, lineIndex: number): TextEdit { + const line = ctx.lines[lineIndex] + if (line.end < ctx.code.length) return { start: line.start, end: line.end + 1, text: '' } + if (line.start > 0) return { start: line.start - 1, end: line.end, text: '' } + return { start: line.start, end: line.end, text: '' } +} + +function findRelation(graph: ClassGraph, relId: string): ClRelation | null { + return graph.relations.find((r) => r.entityId === relId) ?? null +} + +function generateClassName(graph: ClassGraph, preferred?: string): string { + const existing = new Set(graph.classes.map((c) => c.id)) + if (preferred && !existing.has(preferred)) return preferred + let i = 1 + while (existing.has(`Class${i}`)) i++ + return `Class${i}` +} + +function cleanText(text: string): string { + return text.trim().replace(/[{}]/g, '') +} + +export function compileClassOp(ctx: ClOpContext, op: ClassOp): ClOpResult | null { + const { graph, lines } = ctx + + switch (op.type) { + case 'cl.addClass': { + const id = generateClassName(graph, op.name?.replace(/[^\w.~]/g, '')) + const indent = bodyIndent(ctx) + return { + edits: [insertAfterLine(ctx, graph.lastContentLine, [`${indent}class ${id} {`, `${indent}}`])], + created: [`class:${id}`], + } + } + + case 'cl.connect': { + if (!graph.classById.has(op.source) || !graph.classById.has(op.target)) return null + let after = graph.lastContentLine + let found = -1 + for (const id of [op.source, op.target]) { + const c = graph.classById.get(id) + if (!c) continue + // never insert inside a class block — anchor on relations/decl lines only + for (const li of c.refLines) found = Math.max(found, c.block && li === c.block.open ? c.block.close : li) + } + if (found >= 0) after = found + const indent = lines[after].kind === 'statement' ? lines[after].indent : bodyIndent(ctx) + const label = op.label ? ` : ${cleanText(op.label)}` : '' + const occ = graph.relations.filter((r) => r.source === op.source && r.target === op.target).length + return { + edits: [insertAfterLine(ctx, after, [`${indent}${op.source} ${op.op ?? '-->'} ${op.target}${label}`])], + created: [`rel:${op.source}->${op.target}#${occ}`], + } + } + + case 'cl.renameClass': { + const c = graph.classById.get(op.id) + if (!c) return null + const name = op.name.trim().replace(/[^\w.~]/g, '') + if (!name || graph.classById.has(name)) return null + const edits: TextEdit[] = [] + if (c.decl) edits.push({ start: c.decl.idSpan.start, end: c.decl.idSpan.end, text: name }) + for (const r of graph.relations) { + if (r.source === op.id) edits.push({ start: r.stmt.sourceSpan.start, end: r.stmt.sourceSpan.end, text: name }) + if (r.target === op.id) edits.push({ start: r.stmt.targetSpan.start, end: r.stmt.targetSpan.end, text: name }) + } + for (const li of c.extraLines) { + const line = lines[li] + const idStart = line.start + line.text.indexOf(op.id) + if (idStart >= line.start) edits.push({ start: idStart, end: idStart + op.id.length, text: name }) + } + return { edits } + } + + case 'cl.setRelationType': { + const r = findRelation(graph, op.relId) + if (!r) return null + return { edits: [{ start: r.stmt.opSpan.start, end: r.stmt.opSpan.end, text: op.op }] } + } + + case 'cl.setRelationLabel': { + const r = findRelation(graph, op.relId) + if (!r) return null + const label = cleanText(op.label) + if (r.stmt.labelSpan) { + if (!label) { + return { edits: [{ start: r.stmt.targetSpan.end, end: lines[r.lineIndex].end, text: '' }] } + } + return { edits: [{ start: r.stmt.labelSpan.start, end: r.stmt.labelSpan.end, text: ` ${label}` }] } + } + if (!label) return { edits: [] } + return { edits: [{ start: r.stmt.targetSpan.end, end: r.stmt.targetSpan.end, text: ` : ${label}` }] } + } + + case 'cl.reverseRelation': { + const r = findRelation(graph, op.relId) + if (!r) return null + return { + edits: [ + { start: r.stmt.sourceSpan.start, end: r.stmt.sourceSpan.end, text: r.target }, + { start: r.stmt.targetSpan.start, end: r.stmt.targetSpan.end, text: r.source }, + ], + } + } + + case 'cl.addMember': { + const c = graph.classById.get(op.id) + if (!c) return null + const text = cleanText(op.text ?? '+attribute') + if (c.block && c.block.close >= 0) { + const openLine = lines[c.block.open] + const memberIndent = c.members.length + ? lines[c.members[c.members.length - 1].lineIndex].indent + : `${openLine.indent} ` + const anchor = c.members.length ? c.members[c.members.length - 1].lineIndex : c.block.open + return { edits: [insertAfterLine(ctx, anchor, [`${memberIndent}${text}`])] } + } + const anchor = c.decl?.lineIndex ?? c.firstRefLine + const line = lines[anchor] + return { edits: [insertAfterLine(ctx, anchor, [`${line.indent}${c.id} : ${text}`])] } + } + + case 'cl.setMemberText': { + const c = graph.classById.get(op.id) + if (!c) return null + const m = c.members.find((mm) => mm.lineIndex === op.memberLine) + if (!m) return null + const text = cleanText(op.text) + if (!text) return { edits: [deleteLine(ctx, m.lineIndex)] } + const prefix = ctx.code.slice(m.textSpan.start - 1, m.textSpan.start) === ':' ? ' ' : '' + return { edits: [{ start: m.textSpan.start, end: m.textSpan.end, text: `${prefix}${text}` }] } + } + + case 'cl.deleteMember': { + const c = graph.classById.get(op.id) + if (!c) return null + if (!c.members.some((m) => m.lineIndex === op.memberLine)) return null + return { edits: [deleteLine(ctx, op.memberLine)] } + } + + case 'cl.deleteClass': { + const c = graph.classById.get(op.id) + if (!c) return null + const doomed = new Set() + if (c.decl) doomed.add(c.decl.lineIndex) + if (c.block && c.block.close >= 0) { + for (let li = c.block.open; li <= c.block.close; li++) doomed.add(li) + } + for (const li of c.extraLines) doomed.add(li) + for (const m of c.members) doomed.add(m.lineIndex) + for (const r of graph.relations) { + if (r.source === c.id || r.target === c.id) doomed.add(r.lineIndex) + } + return { edits: [...doomed].map((li) => deleteLine(ctx, li)) } + } + + case 'cl.deleteRelation': { + const r = findRelation(graph, op.relId) + if (!r) return null + return { edits: [deleteLine(ctx, r.lineIndex)] } + } + + case 'cl.setAnnotation': { + const c = graph.classById.get(op.id) + if (!c) return null + const annotation = op.annotation?.replace(/[<>\s]/g, '') ?? null + // find an existing `<> Id` line for this class + let annoLine = -1 + for (const [lineIndex, stmt] of graph.statements) { + if (stmt.kind === 'annotation' && stmt.parts[1] === op.id) { + annoLine = lineIndex + break + } + } + if (annoLine >= 0) { + if (!annotation) return { edits: [deleteLine(ctx, annoLine)] } + const line = lines[annoLine] + return { edits: [{ start: line.start, end: line.end, text: `${line.indent}<<${annotation}>> ${op.id}` }] } + } + if (!annotation) return { edits: [] } + const anchor = c.decl?.lineIndex ?? c.firstRefLine + const line = lines[anchor] + return { edits: [insertAfterLine(ctx, anchor, [`${line.indent}<<${annotation}>> ${op.id}`])] } + } + + case 'cl.addNoteFor': { + const c = graph.classById.get(op.id) + if (!c) return null + const text = (op.text ?? 'note').replace(/"/g, "'") + return { + edits: [insertAfterLine(ctx, graph.lastContentLine, [`${bodyIndent(ctx)}note for ${op.id} "${text}"`])], + } + } + + case 'cl.setCardinality': { + const r = findRelation(graph, op.relId) + if (!r) return null + const value = op.value?.replace(/"/g, '').trim() ?? null + const span = op.side === 'source' ? r.stmt.sourceCardSpan : r.stmt.targetCardSpan + if (span) { + if (value === null || value === '') { + // remove the quoted string plus its separating space + return { edits: [{ start: span.start - 2, end: span.end + 1, text: '' }] } + } + return { edits: [{ start: span.start, end: span.end, text: value }] } + } + if (value === null || value === '') return { edits: [] } + if (op.side === 'source') { + return { edits: [{ start: r.stmt.sourceSpan.end, end: r.stmt.sourceSpan.end, text: ` "${value}"` }] } + } + return { edits: [{ start: r.stmt.targetSpan.start, end: r.stmt.targetSpan.start, text: `"${value}" ` }] } + } + + case 'cl.setDirection': { + if (graph.direction) { + const line = lines[graph.direction.lineIndex] + return { edits: [{ start: line.start, end: line.end, text: `${line.indent}direction ${op.direction}` }] } + } + return { edits: [insertAfterLine(ctx, graph.headerLine, [`${bodyIndent(ctx)}direction ${op.direction}`])] } + } + } +} diff --git a/packages/core/src/class/parse.ts b/packages/core/src/class/parse.ts new file mode 100644 index 0000000..f9d81f5 --- /dev/null +++ b/packages/core/src/class/parse.ts @@ -0,0 +1,183 @@ +import type { LineInfo, Span } from '../types' + +/** mermaid class relation operators, longest-first for unambiguous matching */ +export const RELATION_OPS = [ + '<|--', + '--|>', + '<|..', + '..|>', + '*--', + '--*', + 'o--', + '--o', + '<--', + '-->', + '<..', + '..>', + '--', + '..', +] as const + +export type RelationOp = (typeof RELATION_OPS)[number] + +export interface ClRelationStmt { + kind: 'relation' + lineIndex: number + source: string + sourceSpan: Span + target: string + targetSpan: Span + op: RelationOp + opSpan: Span + label: string | null + labelSpan: Span | null + /** `"1"`-style cardinality strings; spans cover the inner text */ + sourceCard: string | null + sourceCardSpan: Span | null + targetCard: string | null + targetCardSpan: Span | null +} + +export interface ClClassDeclStmt { + kind: 'classDecl' + lineIndex: number + id: string + idSpan: Span + /** `class A["Label"]` display label */ + label: string | null + labelSpan: Span | null + opensBlock: boolean +} + +export interface ClMemberStmt { + kind: 'member' + lineIndex: number + /** `A : +int age` one-liner form; null when inside a class block */ + classId: string | null + text: string + textSpan: Span +} + +export interface ClSimpleStmt { + kind: 'blockClose' | 'annotation' | 'note' | 'direction' | 'passthrough' | 'unknown' + lineIndex: number + parts: string[] +} + +export type ClStmt = ClRelationStmt | ClClassDeclStmt | ClMemberStmt | ClSimpleStmt + +const ID = String.raw`[\w.~]+` +const OP_ALT = RELATION_OPS.map((o) => o.replace(/[|*.]/g, (c) => `\\${c}`)).join('|') +const RELATION_RE = new RegExp( + String.raw`^(${ID})\s*(?:"([^"]*)"\s*)?(${OP_ALT})\s*(?:"([^"]*)"\s*)?(${ID})\s*(?::\s*(.*))?$`, +) +const CLASS_DECL_RE = /^class\s+([\w.~]+)(?:\["([^"]*)"\])?\s*(\{)?\s*$/ +const MEMBER_ONELINE_RE = /^([\w.~]+)\s*:\s*(.+)$/ + +function span(line: LineInfo, relStart: number, relEnd: number): Span { + return { start: line.start + relStart, end: line.start + relEnd } +} + +export function parseClassStatement(line: LineInfo, inBlockOf: string | null): ClStmt { + const raw = line.text + const t = raw.trim() + const lineIndex = line.index + const indent = raw.length - raw.trimStart().length + + if (inBlockOf !== null) { + if (t === '}') return { kind: 'blockClose', lineIndex, parts: [] } + // any other line inside a class block is a member (annotations included) + if (/^<<\w+>>$/.test(t)) return { kind: 'annotation', lineIndex, parts: [t] } + return { kind: 'member', lineIndex, classId: null, text: t, textSpan: span(line, indent, raw.length) } + } + + if (t === '}') return { kind: 'blockClose', lineIndex, parts: [] } + if (/^direction\s+(TB|TD|BT|RL|LR)\s*$/.test(t)) return { kind: 'direction', lineIndex, parts: [t.split(/\s+/)[1]] } + if (/^note\b/i.test(t)) return { kind: 'note', lineIndex, parts: [] } + if (/^(classDef|style|cssClass|click|callback|link|namespace)\b/.test(t)) { + return { kind: 'passthrough', lineIndex, parts: [] } + } + const anno = /^<<(\w+)>>\s+([\w.~]+)$/.exec(t) + if (anno) return { kind: 'annotation', lineIndex, parts: [anno[1], anno[2]] } + + const decl = CLASS_DECL_RE.exec(t) + if (decl) { + const id = decl[1] + const idStart = raw.indexOf(id, indent + 5) + let label: string | null = null + let labelSpan: Span | null = null + if (decl[2] !== undefined) { + const lStart = raw.indexOf('"', idStart + id.length) + 1 + label = decl[2] + labelSpan = span(line, lStart, lStart + decl[2].length) + } + return { + kind: 'classDecl', + lineIndex, + id, + idSpan: span(line, idStart, idStart + id.length), + label, + labelSpan, + opensBlock: !!decl[3], + } + } + + const rel = RELATION_RE.exec(t) + if (rel) { + const [, source, srcCard, opRaw, tgtCard, target, labelRaw] = rel + const srcStart = indent + t.indexOf(source) + let sourceCardSpan: Span | null = null + let searchFrom = srcStart + source.length + if (srcCard !== undefined) { + const q = raw.indexOf(`"${srcCard}"`, searchFrom) + sourceCardSpan = span(line, q + 1, q + 1 + srcCard.length) + searchFrom = q + srcCard.length + 2 + } + const opStart = raw.indexOf(opRaw, searchFrom) + let targetCardSpan: Span | null = null + searchFrom = opStart + opRaw.length + if (tgtCard !== undefined) { + const q = raw.indexOf(`"${tgtCard}"`, searchFrom) + targetCardSpan = span(line, q + 1, q + 1 + tgtCard.length) + searchFrom = q + tgtCard.length + 2 + } + const tgtStart = raw.indexOf(target, searchFrom) + let label: string | null = null + let labelSpan: Span | null = null + if (labelRaw !== undefined) { + const colon = raw.indexOf(':', tgtStart + target.length) + label = raw.slice(colon + 1).trim() + labelSpan = span(line, colon + 1, raw.length) + } + return { + kind: 'relation', + lineIndex, + source, + sourceSpan: span(line, srcStart, srcStart + source.length), + target, + targetSpan: span(line, tgtStart, tgtStart + target.length), + op: opRaw as RelationOp, + opSpan: span(line, opStart, opStart + opRaw.length), + label, + labelSpan, + sourceCard: srcCard ?? null, + sourceCardSpan, + targetCard: tgtCard ?? null, + targetCardSpan, + } + } + + const member = MEMBER_ONELINE_RE.exec(t) + if (member) { + const colon = raw.indexOf(':', indent + member[1].length) + return { + kind: 'member', + lineIndex, + classId: member[1], + text: member[2].trim(), + textSpan: span(line, colon + 1, raw.length), + } + } + + return { kind: 'unknown', lineIndex, parts: [] } +} diff --git a/packages/core/src/editor.ts b/packages/core/src/editor.ts new file mode 100644 index 0000000..399c662 --- /dev/null +++ b/packages/core/src/editor.ts @@ -0,0 +1,417 @@ +import type { Diagnostic, DiagramTypeInfo, LineInfo, Origin, Span, TextEdit } from './types' +import { applyEdits, classifyLines, diffText, scanLines } from './text' +import { detectDiagramType } from './registry' +import { buildFlowGraph, entityAtOffset, entitySpans, type FlowGraph } from './flowchart/graph' +import { compileFlowchartOp, type FlowchartOp, type OpResult } from './flowchart/ops' +import { + buildSequenceGraph, + sequenceEntityAt, + sequenceEntitySpans, + type SequenceGraph, +} from './sequence/graph' +import { compileSequenceOp, type SequenceOp } from './sequence/ops' +import { buildStateGraph, stateEntityAt, stateEntitySpans, type StateGraph } from './state/graph' +import { compileStateOp, type StateOp } from './state/ops' +import { buildClassGraph, classEntityAt, classEntitySpans, type ClassGraph } from './class/graph' +import { compileClassOp, type ClassOp } from './class/ops' +import { buildErGraph, erEntityAt, erEntitySpans, type ErGraph } from './er/graph' +import { compileErOp, type ErOp } from './er/ops' +import { buildPieGraph, compilePieOp, pieEntityAt, pieEntitySpans, type PieGraph, type PieOp } from './pie/index' +import { buildGanttGraph, compileGanttOp, ganttEntityAt, ganttEntitySpans, type GanttGraph, type GanttOp } from './gantt/index' +import { buildLineItemsGraph, compileLineItemOp, lineItemAt, lineItemSpans, LINE_ITEM_CONFIGS, type LineItemsGraph, type LineItemOp } from './lineitems/index' + +export type EditorOp = FlowchartOp | SequenceOp | StateOp | ClassOp | ErOp | PieOp | GanttOp | LineItemOp + +export interface ParseResult { + code: string + lines: LineInfo[] + headerIndex: number + typeInfo: DiagramTypeInfo | null + /** present when typeInfo.id === 'flowchart' */ + flowchart: FlowGraph | null + /** present when typeInfo.id === 'sequence' */ + sequence: SequenceGraph | null + /** present when typeInfo.id === 'state' */ + state: StateGraph | null + /** present when typeInfo.id === 'class' */ + classGraph: ClassGraph | null + /** present when typeInfo.id === 'er' */ + er: ErGraph | null + /** present when typeInfo.id === 'pie' */ + pie: PieGraph | null + /** present when typeInfo.id === 'gantt' */ + gantt: GanttGraph | null + /** present for line-item chart types (journey, timeline, kanban, …) */ + lineItems: LineItemsGraph | null +} + +export function parse(code: string): ParseResult { + const lines = scanLines(code) + const { headerIndex } = classifyLines(lines) + const typeInfo = headerIndex >= 0 ? detectDiagramType(lines[headerIndex].text) : null + let flowchart: FlowGraph | null = null + let sequence: SequenceGraph | null = null + let state: StateGraph | null = null + let classGraph: ClassGraph | null = null + let er: ErGraph | null = null + let pie: PieGraph | null = null + let gantt: GanttGraph | null = null + let lineItems: LineItemsGraph | null = null + try { + if (typeInfo?.id === 'flowchart') flowchart = buildFlowGraph(lines, headerIndex) + else if (typeInfo?.id === 'sequence') sequence = buildSequenceGraph(lines, headerIndex) + else if (typeInfo?.id === 'state') state = buildStateGraph(lines, headerIndex) + else if (typeInfo?.id === 'class') classGraph = buildClassGraph(lines, headerIndex) + else if (typeInfo?.id === 'er') er = buildErGraph(lines, headerIndex) + else if (typeInfo?.id === 'pie') pie = buildPieGraph(lines, headerIndex) + else if (typeInfo?.id === 'gantt') gantt = buildGanttGraph(lines, headerIndex) + else if (typeInfo && LINE_ITEM_CONFIGS[typeInfo.id]) lineItems = buildLineItemsGraph(lines, headerIndex, typeInfo.id) + } catch { + // a failed projection degrades that diagram to render-only + } + return { code, lines, headerIndex, typeInfo, flowchart, sequence, state, classGraph, er, pie, gantt, lineItems } +} + +export interface Transaction { + edits: TextEdit[] + inverse: TextEdit[] + origin: Origin + selectionBefore: string[] + selectionAfter: string[] + time: number +} + +export interface ChangeEvent { + code: string + edits: TextEdit[] + origin: Origin + result: ParseResult +} + +export interface SelectionEvent { + entityIds: string[] + spans: Span[] + origin: Origin +} + +type EventMap = { + change: ChangeEvent + selectionChange: SelectionEvent + diagnostics: Diagnostic[] +} + +type Listener = (payload: T) => void + +export interface EditorOptions { + code?: string + /** coalesce window for merging consecutive code-typing undo entries, ms */ + coalesceMs?: number +} + +/** + * Headless bidirectional Mermaid editor. + * Text is the single source of truth; every mutation funnels through text edits. + */ +export class MermaidWysiwygEditor { + private _code: string + private _result: ParseResult + private _selection: string[] = [] + private undoStack: Transaction[] = [] + private redoStack: Transaction[] = [] + private listeners: { [K in keyof EventMap]: Set> } = { + change: new Set(), + selectionChange: new Set(), + diagnostics: new Set(), + } + private coalesceMs: number + private _diagnostics: Diagnostic[] = [] + + constructor(options: EditorOptions = {}) { + this._code = options.code ?? 'flowchart TD\n A[Start] --> B[End]' + this.coalesceMs = options.coalesceMs ?? 750 + this._result = parse(this._code) + } + + get code(): string { + return this._code + } + + get result(): ParseResult { + return this._result + } + + get selection(): string[] { + return this._selection + } + + get diagnostics(): Diagnostic[] { + return this._diagnostics + } + + get canUndo(): boolean { + return this.undoStack.length > 0 + } + + get canRedo(): boolean { + return this.redoStack.length > 0 + } + + on(event: K, fn: Listener): () => void { + this.listeners[event].add(fn as never) + return () => this.listeners[event].delete(fn as never) + } + + private emit(event: K, payload: EventMap[K]) { + for (const fn of this.listeners[event]) fn(payload) + } + + setDiagnostics(diags: Diagnostic[]) { + this._diagnostics = diags + this.emit('diagnostics', diags) + } + + /** Apply raw text edits (the only mutation primitive). */ + applyEdits(edits: TextEdit[], origin: Origin = 'api'): void { + if (edits.length === 0) return + const { text, inverse } = applyEdits(this._code, edits) + if (text === this._code) return + const txn: Transaction = { + edits, + inverse, + origin, + selectionBefore: [...this._selection], + selectionAfter: [...this._selection], + time: Date.now(), + } + this.commit(text, txn, origin) + } + + /** Replace the whole document; computes a minimal diff for history. */ + setCode(newCode: string, origin: Origin = 'code'): void { + const diff = diffText(this._code, newCode) + if (!diff) return + const { text, inverse } = applyEdits(this._code, [diff]) + const now = Date.now() + // coalesce rapid typing into one undo entry + const last = this.undoStack[this.undoStack.length - 1] + if ( + origin === 'code' && + last && + last.origin === 'code' && + now - last.time < this.coalesceMs && + this.redoStack.length === 0 + ) { + // extend previous txn so one undo step reverts the whole typing burst: + // recompute forward (beforeLast → new) and backward (new → beforeLast). + const beforeLast = applyEdits(this._code, last.inverse).text + const forward = diffText(beforeLast, text) + const back = diffText(text, beforeLast) + last.edits = forward ? [forward] : [] + last.inverse = back ? [back] : [] + last.time = now + this._code = text + this._result = parse(text) + this.pruneSelection() + this.emit('change', { code: text, edits: [diff], origin, result: this._result }) + return + } + const txn: Transaction = { + edits: [diff], + inverse, + origin, + selectionBefore: [...this._selection], + selectionAfter: [...this._selection], + time: now, + } + this.commit(text, txn, origin) + } + + private commit(text: string, txn: Transaction, origin: Origin) { + this._code = text + this._result = parse(text) + this.undoStack.push(txn) + this.redoStack = [] + this.pruneSelection() + txn.selectionAfter = [...this._selection] + this.emit('change', { code: text, edits: txn.edits, origin, result: this._result }) + } + + undo(): boolean { + const txn = this.undoStack.pop() + if (!txn) return false + // txn.inverse maps after→before; txn.edits stays valid for redo once we're back at "before" + const { text } = applyEdits(this._code, txn.inverse) + this.redoStack.push(txn) + this._code = text + this._result = parse(text) + this._selection = txn.selectionBefore.filter((id) => this.entityExists(id)) + this.emit('change', { code: text, edits: txn.inverse, origin: 'history', result: this._result }) + this.emit('selectionChange', { entityIds: this._selection, spans: this.selectionSpans(), origin: 'history' }) + return true + } + + redo(): boolean { + const txn = this.redoStack.pop() + if (!txn) return false + const { text } = applyEdits(this._code, txn.edits) + this.undoStack.push(txn) + this._code = text + this._result = parse(text) + this._selection = txn.selectionAfter.filter((id) => this.entityExists(id)) + this.emit('change', { code: text, edits: txn.edits, origin: 'history', result: this._result }) + this.emit('selectionChange', { entityIds: this._selection, spans: this.selectionSpans(), origin: 'history' }) + return true + } + + /** Dispatch a semantic op (flowchart, sequence, or state). Returns created entity ids. */ + dispatch(op: EditorOp, origin: Origin = 'canvas'): OpResult | null { + const { flowchart, sequence, state, classGraph, er, pie, gantt, lineItems, lines } = this._result + let compiled: OpResult | null = null + if (op.type.startsWith('seq.')) { + if (!sequence) return null + compiled = compileSequenceOp({ code: this._code, lines, graph: sequence }, op as SequenceOp) + } else if (op.type.startsWith('st.')) { + if (!state) return null + compiled = compileStateOp({ code: this._code, lines, graph: state }, op as StateOp) + } else if (op.type.startsWith('cl.')) { + if (!classGraph) return null + compiled = compileClassOp({ code: this._code, lines, graph: classGraph }, op as ClassOp) + } else if (op.type.startsWith('er.')) { + if (!er) return null + compiled = compileErOp({ code: this._code, lines, graph: er }, op as ErOp) + } else if (op.type.startsWith('pie.')) { + if (!pie) return null + compiled = compilePieOp({ code: this._code, lines, graph: pie }, op as PieOp) + } else if (op.type.startsWith('gantt.')) { + if (!gantt) return null + compiled = compileGanttOp({ code: this._code, lines, graph: gantt }, op as GanttOp) + } else if (op.type.startsWith('li.')) { + if (!lineItems) return null + compiled = compileLineItemOp({ code: this._code, lines, graph: lineItems }, op as LineItemOp) + } else { + if (!flowchart) return null + compiled = compileFlowchartOp({ code: this._code, lines, graph: flowchart }, op as FlowchartOp) + } + if (!compiled || compiled.edits.length === 0) { + return compiled + } + this.applyEdits(compiled.edits, origin) + if (compiled.created?.length) { + this.setSelection(compiled.created, origin) + } + return compiled + } + + /** Delete a mixed selection of entities, sequentially and safely. */ + deleteEntities(entityIds: string[], origin: Origin = 'canvas'): void { + // delete connections/events first (deleting an endpoint may already remove them) + const order = (id: string) => + id.startsWith('edge:') || + id.startsWith('event:') || + id.startsWith('trans:') || + id.startsWith('rel:') || + id.startsWith('erel:') + ? 0 + : 1 + for (const id of [...entityIds].sort((a, b) => order(a) - order(b))) { + if (!this.entityExists(id)) continue + if (id.startsWith('edge:')) this.dispatch({ type: 'deleteEdge', edgeId: id }, origin) + else if (id.startsWith('node:')) this.dispatch({ type: 'deleteNode', id: id.slice(5) }, origin) + else if (id.startsWith('event:')) this.dispatch({ type: 'seq.deleteEvent', eventId: id }, origin) + else if (id.startsWith('participant:')) { + this.dispatch({ type: 'seq.deleteParticipant', id: id.slice(12) }, origin) + } else if (id.startsWith('trans:')) this.dispatch({ type: 'st.deleteTransition', transId: id }, origin) + else if (id.startsWith('state:')) this.dispatch({ type: 'st.deleteState', id: id.slice(6) }, origin) + else if (id.startsWith('rel:')) this.dispatch({ type: 'cl.deleteRelation', relId: id }, origin) + else if (id.startsWith('class:')) this.dispatch({ type: 'cl.deleteClass', id: id.slice(6) }, origin) + else if (id.startsWith('erel:')) this.dispatch({ type: 'er.deleteRelation', relId: id }, origin) + else if (id.startsWith('entity:')) this.dispatch({ type: 'er.deleteEntity', id: id.slice(7) }, origin) + else if (id.startsWith('slice:')) this.dispatch({ type: 'pie.deleteSlice', sliceId: id }, origin) + else if (id.startsWith('task:')) this.dispatch({ type: 'gantt.deleteTask', taskId: id }, origin) + else if (id.startsWith('item:')) this.dispatch({ type: 'li.deleteItem', itemId: id }, origin) + } + } + + entityExists(id: string): boolean { + const fg = this._result.flowchart + if (fg) { + if (id.startsWith('node:')) return fg.nodeById.has(id.slice(5)) + if (id.startsWith('edge:')) return fg.edges.some((e) => e.entityId === id) + if (id.startsWith('subgraph:')) return fg.subgraphs.some((s) => s.entityId === id) + } + const sg = this._result.sequence + if (sg) { + if (id.startsWith('participant:')) return sg.participantById.has(id.slice(12)) + if (id.startsWith('event:')) return sg.events.some((e) => e.entityId === id) + } + const st = this._result.state + if (st) { + if (id.startsWith('state:')) return st.stateById.has(id.slice(6)) + if (id.startsWith('trans:')) return st.transitions.some((t) => t.entityId === id) + } + const cg = this._result.classGraph + if (cg) { + if (id.startsWith('class:')) return cg.classById.has(id.slice(6)) + if (id.startsWith('rel:')) return cg.relations.some((r) => r.entityId === id) + } + const er = this._result.er + if (er) { + if (id.startsWith('entity:')) return er.entityById.has(id.slice(7)) + if (id.startsWith('erel:')) return er.relations.some((r) => r.entityId === id) + } + const pie = this._result.pie + if (pie && id.startsWith('slice:')) return pie.slices.some((s) => s.entityId === id) + const gantt = this._result.gantt + if (gantt && id.startsWith('task:')) return gantt.tasks.some((t) => t.entityId === id) + const li = this._result.lineItems + if (li && id.startsWith('item:')) return li.items.some((i) => i.entityId === id) + return false + } + + setSelection(entityIds: string[], origin: Origin = 'api'): void { + const filtered = entityIds.filter((id) => this.entityExists(id)) + const same = filtered.length === this._selection.length && filtered.every((id, i) => id === this._selection[i]) + if (same) return + this._selection = filtered + this.emit('selectionChange', { entityIds: filtered, spans: this.selectionSpans(), origin }) + } + + clearSelection(origin: Origin = 'api'): void { + this.setSelection([], origin) + } + + private pruneSelection() { + this._selection = this._selection.filter((id) => this.entityExists(id)) + } + + selectionSpans(): Span[] { + return this._selection.flatMap((id) => this.entityRanges(id)) + } + + entityRanges(id: string): Span[] { + const { flowchart, sequence, state, classGraph, er, pie, gantt, lineItems, lines } = this._result + if (flowchart) return entitySpans(flowchart, lines, id) + if (sequence) return sequenceEntitySpans(sequence, lines, id) + if (state) return stateEntitySpans(state, lines, id) + if (classGraph) return classEntitySpans(classGraph, lines, id) + if (er) return erEntitySpans(er, lines, id) + if (pie) return pieEntitySpans(pie, lines, id) + if (gantt) return ganttEntitySpans(gantt, lines, id) + if (lineItems) return lineItemSpans(lineItems, lines, id) + return [] + } + + /** Entity at a text offset (for code-cursor → canvas selection sync). */ + entityAt(offset: number): string | null { + const { flowchart, sequence, state, classGraph, er, pie, gantt, lineItems, lines } = this._result + if (flowchart) return entityAtOffset(flowchart, offset) + if (sequence) return sequenceEntityAt(sequence, lines, offset) + if (state) return stateEntityAt(state, lines, offset) + if (classGraph) return classEntityAt(classGraph, lines, offset) + if (er) return erEntityAt(er, lines, offset) + if (pie) return pieEntityAt(pie, lines, offset) + if (gantt) return ganttEntityAt(gantt, lines, offset) + if (lineItems) return lineItemAt(lineItems, lines, offset) + return null + } +} diff --git a/packages/core/src/er/graph.ts b/packages/core/src/er/graph.ts new file mode 100644 index 0000000..2243867 --- /dev/null +++ b/packages/core/src/er/graph.ts @@ -0,0 +1,173 @@ +import type { LineInfo, Span } from '../types' +import { parseErStatement, type ErEntityDeclStmt, type ErRelationStmt, type ErStmt } from './parse' + +export interface ErEntity { + /** entity id: `entity:` */ + entityId: string + id: string + label: string + decl: ErEntityDeclStmt | null + block: { open: number; close: number } | null + attributes: Array<{ lineIndex: number; text: string; textSpan: Span }> + refLines: number[] + firstRefLine: number +} + +export interface ErRelation { + /** entity id: `erel:->#` */ + entityId: string + source: string + target: string + lineIndex: number + stmt: ErRelationStmt + label: string + order: number +} + +export interface ErGraph { + headerLine: number + entities: ErEntity[] + entityById: Map + relations: ErRelation[] + statements: Map + lastContentLine: number +} + +export function buildErGraph(lines: LineInfo[], headerIndex: number): ErGraph { + const entities = new Map() + const relations: ErRelation[] = [] + const statements = new Map() + const occurrence = new Map() + let lastContentLine = headerIndex + let blockOwner: string | null = null + + const touch = (id: string, lineIndex: number): ErEntity => { + let e = entities.get(id) + if (!e) { + e = { + entityId: `entity:${id}`, + id, + label: id, + decl: null, + block: null, + attributes: [], + refLines: [], + firstRefLine: lineIndex, + } + entities.set(id, e) + } + return e + } + + for (const line of lines) { + if (line.kind !== 'statement' || line.index === headerIndex) { + if (line.index === headerIndex) lastContentLine = line.index + continue + } + const stmt = parseErStatement(line, blockOwner !== null) + statements.set(line.index, stmt) + lastContentLine = line.index + + switch (stmt.kind) { + case 'entityDecl': { + const e = touch(stmt.id, stmt.lineIndex) + e.decl = stmt + if (stmt.alias) e.label = stmt.alias + e.refLines.push(stmt.lineIndex) + if (stmt.opensBlock) { + e.block = { open: stmt.lineIndex, close: -1 } + blockOwner = stmt.id + } + break + } + case 'blockClose': { + if (blockOwner) { + const e = entities.get(blockOwner) + if (e?.block) e.block.close = stmt.lineIndex + blockOwner = null + } + break + } + case 'attribute': { + if (blockOwner) { + const e = touch(blockOwner, stmt.lineIndex) + e.attributes.push({ lineIndex: stmt.lineIndex, text: stmt.text, textSpan: stmt.textSpan }) + } + break + } + case 'relation': { + const src = touch(stmt.source, stmt.lineIndex) + const tgt = touch(stmt.target, stmt.lineIndex) + src.refLines.push(stmt.lineIndex) + tgt.refLines.push(stmt.lineIndex) + const key = `${stmt.source}->${stmt.target}` + const occ = occurrence.get(key) ?? 0 + occurrence.set(key, occ + 1) + relations.push({ + entityId: `erel:${key}#${occ}`, + source: stmt.source, + target: stmt.target, + lineIndex: stmt.lineIndex, + stmt, + label: stmt.label, + order: relations.length, + }) + break + } + default: + break + } + } + + return { + headerLine: headerIndex, + entities: [...entities.values()], + entityById: entities, + relations, + statements, + lastContentLine, + } +} + +export function erEntitySpans(graph: ErGraph, lines: LineInfo[], entityId: string): Span[] { + if (entityId.startsWith('entity:')) { + const e = graph.entityById.get(entityId.slice(7)) + if (!e) return [] + const spans: Span[] = [] + if (e.decl) { + const line = lines[e.decl.lineIndex] + spans.push({ start: line.start + line.indent.length, end: line.end }) + } + for (const r of graph.relations) { + if (r.source === e.id) spans.push(r.stmt.sourceSpan) + if (r.target === e.id) spans.push(r.stmt.targetSpan) + } + return spans + } + if (entityId.startsWith('erel:')) { + const r = graph.relations.find((rel) => rel.entityId === entityId) + if (!r) return [] + const line = lines[r.lineIndex] + return [{ start: line.start + line.indent.length, end: line.end }] + } + return [] +} + +export function erEntityAt(graph: ErGraph, lines: LineInfo[], offset: number): string | null { + for (const r of graph.relations) { + const line = lines[r.lineIndex] + if (offset >= line.start && offset <= line.end) { + if (offset >= r.stmt.sourceSpan.start && offset <= r.stmt.sourceSpan.end) return `entity:${r.source}` + if (offset >= r.stmt.targetSpan.start && offset <= r.stmt.targetSpan.end) return `entity:${r.target}` + return r.entityId + } + } + for (const e of graph.entities) { + const toCheck = [...(e.decl ? [e.decl.lineIndex] : []), ...e.attributes.map((a) => a.lineIndex)] + for (const li of toCheck) { + const line = lines[li] + if (offset >= line.start && offset <= line.end) return e.entityId + } + } + return null +} diff --git a/packages/core/src/er/ops.ts b/packages/core/src/er/ops.ts new file mode 100644 index 0000000..94f7918 --- /dev/null +++ b/packages/core/src/er/ops.ts @@ -0,0 +1,203 @@ +import type { LineInfo, TextEdit } from '../types' +import type { ErGraph, ErRelation } from './graph' + +export type ErCardinality = 'zero-or-one' | 'exactly-one' | 'zero-or-more' | 'one-or-more' + +const LEFT_GLYPHS: Record = { + 'zero-or-one': '|o', + 'exactly-one': '||', + 'zero-or-more': '}o', + 'one-or-more': '}|', +} +const RIGHT_GLYPHS: Record = { + 'zero-or-one': 'o|', + 'exactly-one': '||', + 'zero-or-more': 'o{', + 'one-or-more': '|{', +} + +export function cardinalityFromGlyph(glyph: string): ErCardinality { + for (const [k, v] of Object.entries(LEFT_GLYPHS)) if (v === glyph) return k as ErCardinality + for (const [k, v] of Object.entries(RIGHT_GLYPHS)) if (v === glyph) return k as ErCardinality + return 'exactly-one' +} + +export type ErOp = + | { type: 'er.addEntity'; name?: string } + | { type: 'er.connect'; source: string; target: string; label?: string } + | { type: 'er.renameEntity'; id: string; name: string } + | { type: 'er.setCardinality'; relId: string; side: 'left' | 'right'; card: ErCardinality } + | { type: 'er.setIdentifying'; relId: string; identifying: boolean } + | { type: 'er.setRelationLabel'; relId: string; label: string } + | { type: 'er.addAttribute'; id: string; text?: string } + | { type: 'er.setAttributeText'; id: string; attrLine: number; text: string } + | { type: 'er.deleteEntity'; id: string } + | { type: 'er.deleteRelation'; relId: string } + +export interface ErOpContext { + code: string + lines: LineInfo[] + graph: ErGraph +} + +export interface ErOpResult { + edits: TextEdit[] + created?: string[] +} + +function bodyIndent(ctx: ErOpContext): string { + for (const line of ctx.lines) { + if (line.kind === 'statement' && line.index !== ctx.graph.headerLine && line.text.trim() !== '') { + return line.indent + } + } + return ' ' +} + +function insertAfterLine(ctx: ErOpContext, lineIndex: number, texts: string[]): TextEdit { + const line = ctx.lines[lineIndex] + return { start: line.end, end: line.end, text: texts.map((t) => `\n${t}`).join('') } +} + +function deleteLine(ctx: ErOpContext, lineIndex: number): TextEdit { + const line = ctx.lines[lineIndex] + if (line.end < ctx.code.length) return { start: line.start, end: line.end + 1, text: '' } + if (line.start > 0) return { start: line.start - 1, end: line.end, text: '' } + return { start: line.start, end: line.end, text: '' } +} + +function findRelation(graph: ErGraph, relId: string): ErRelation | null { + return graph.relations.find((r) => r.entityId === relId) ?? null +} + +export function compileErOp(ctx: ErOpContext, op: ErOp): ErOpResult | null { + const { graph, lines } = ctx + + switch (op.type) { + case 'er.addEntity': { + const existing = new Set(graph.entities.map((e) => e.id)) + let id = op.name?.replace(/[^\w-]/g, '') || '' + if (!id || existing.has(id)) { + let i = 1 + while (existing.has(`ENTITY${i}`)) i++ + id = `ENTITY${i}` + } + const indent = bodyIndent(ctx) + return { + edits: [insertAfterLine(ctx, graph.lastContentLine, [`${indent}${id} {`, `${indent} string id`, `${indent}}`])], + created: [`entity:${id}`], + } + } + + case 'er.connect': { + if (!graph.entityById.has(op.source) || !graph.entityById.has(op.target)) return null + let after = graph.lastContentLine + let found = -1 + for (const id of [op.source, op.target]) { + const e = graph.entityById.get(id) + if (!e) continue + for (const li of e.refLines) found = Math.max(found, e.block && li === e.block.open ? e.block.close : li) + } + if (found >= 0) after = found + const indent = lines[after].kind === 'statement' ? lines[after].indent : bodyIndent(ctx) + const label = (op.label ?? 'relates').replace(/[:{}]/g, '').trim() || 'relates' + const occ = graph.relations.filter((r) => r.source === op.source && r.target === op.target).length + return { + edits: [insertAfterLine(ctx, after, [`${indent}${op.source} ||--o{ ${op.target} : ${label}`])], + created: [`erel:${op.source}->${op.target}#${occ}`], + } + } + + case 'er.renameEntity': { + const e = graph.entityById.get(op.id) + if (!e) return null + const name = op.name.trim().replace(/[^\w-]/g, '') + if (!name || graph.entityById.has(name)) return null + const edits: TextEdit[] = [] + if (e.decl) edits.push({ start: e.decl.idSpan.start, end: e.decl.idSpan.end, text: name }) + for (const r of graph.relations) { + if (r.source === op.id) edits.push({ start: r.stmt.sourceSpan.start, end: r.stmt.sourceSpan.end, text: name }) + if (r.target === op.id) edits.push({ start: r.stmt.targetSpan.start, end: r.stmt.targetSpan.end, text: name }) + } + return { edits } + } + + case 'er.setCardinality': { + const r = findRelation(graph, op.relId) + if (!r) return null + if (op.side === 'left') { + return { + edits: [{ start: r.stmt.leftCardSpan.start, end: r.stmt.leftCardSpan.end, text: LEFT_GLYPHS[op.card] }], + } + } + return { + edits: [{ start: r.stmt.rightCardSpan.start, end: r.stmt.rightCardSpan.end, text: RIGHT_GLYPHS[op.card] }], + } + } + + case 'er.setIdentifying': { + const r = findRelation(graph, op.relId) + if (!r) return null + return { + edits: [{ start: r.stmt.lineSpan.start, end: r.stmt.lineSpan.end, text: op.identifying ? '--' : '..' }], + } + } + + case 'er.setRelationLabel': { + const r = findRelation(graph, op.relId) + if (!r) return null + const label = op.label.replace(/[:{}]/g, '').trim() || 'relates' + return { edits: [{ start: r.stmt.labelSpan.start, end: r.stmt.labelSpan.end, text: ` ${label}` }] } + } + + case 'er.addAttribute': { + const e = graph.entityById.get(op.id) + if (!e) return null + const text = (op.text ?? 'string attribute').replace(/[{}]/g, '').trim() + if (e.block && e.block.close >= 0) { + const openLine = lines[e.block.open] + const indent = e.attributes.length + ? lines[e.attributes[e.attributes.length - 1].lineIndex].indent + : `${openLine.indent} ` + const anchor = e.attributes.length ? e.attributes[e.attributes.length - 1].lineIndex : e.block.open + return { edits: [insertAfterLine(ctx, anchor, [`${indent}${text}`])] } + } + // no block yet — create one at the entity's first reference + const line = lines[e.firstRefLine] + return { + edits: [insertAfterLine(ctx, e.firstRefLine, [`${line.indent}${e.id} {`, `${line.indent} ${text}`, `${line.indent}}`])], + } + } + + case 'er.setAttributeText': { + const e = graph.entityById.get(op.id) + if (!e) return null + const attr = e.attributes.find((a) => a.lineIndex === op.attrLine) + if (!attr) return null + const text = op.text.replace(/[{}]/g, '').trim() + if (!text) return { edits: [deleteLine(ctx, attr.lineIndex)] } + const line = lines[attr.lineIndex] + return { edits: [{ start: line.start, end: line.end, text: `${line.indent}${text}` }] } + } + + case 'er.deleteEntity': { + const e = graph.entityById.get(op.id) + if (!e) return null + const doomed = new Set() + if (e.decl) doomed.add(e.decl.lineIndex) + if (e.block && e.block.close >= 0) { + for (let li = e.block.open; li <= e.block.close; li++) doomed.add(li) + } + for (const r of graph.relations) { + if (r.source === e.id || r.target === e.id) doomed.add(r.lineIndex) + } + return { edits: [...doomed].map((li) => deleteLine(ctx, li)) } + } + + case 'er.deleteRelation': { + const r = findRelation(graph, op.relId) + if (!r) return null + return { edits: [deleteLine(ctx, r.lineIndex)] } + } + } +} diff --git a/packages/core/src/er/parse.ts b/packages/core/src/er/parse.ts new file mode 100644 index 0000000..ec71300 --- /dev/null +++ b/packages/core/src/er/parse.ts @@ -0,0 +1,111 @@ +import type { LineInfo, Span } from '../types' + +/** left-side cardinality glyphs → meaning */ +export const ER_CARDS_LEFT = ['|o', '||', '}o', '}|'] as const +export const ER_CARDS_RIGHT = ['o|', '||', 'o{', '|{'] as const + +export interface ErRelationStmt { + kind: 'relation' + lineIndex: number + source: string + sourceSpan: Span + target: string + targetSpan: Span + leftCard: string + leftCardSpan: Span + rightCard: string + rightCardSpan: Span + /** '--' identifying, '..' non-identifying */ + line: '--' | '..' + lineSpan: Span + label: string + labelSpan: Span +} + +export interface ErEntityDeclStmt { + kind: 'entityDecl' + lineIndex: number + id: string + idSpan: Span + alias: string | null + opensBlock: boolean +} + +export interface ErAttributeStmt { + kind: 'attribute' + lineIndex: number + text: string + textSpan: Span +} + +export interface ErSimpleStmt { + kind: 'blockClose' | 'passthrough' | 'unknown' + lineIndex: number + parts: string[] +} + +export type ErStmt = ErRelationStmt | ErEntityDeclStmt | ErAttributeStmt | ErSimpleStmt + +const ID = String.raw`[\w-]+` +const RELATION_RE = new RegExp( + String.raw`^(${ID})\s*([|}o]{2})(--|\.\.)([o|{]{2})\s*(${ID})\s*:\s*(.*)$`, +) +const DECL_RE = new RegExp(String.raw`^(${ID})(\[[^\]]*\])?\s*(\{)?\s*$`) + +function span(line: LineInfo, relStart: number, relEnd: number): Span { + return { start: line.start + relStart, end: line.start + relEnd } +} + +export function parseErStatement(line: LineInfo, inBlock: boolean): ErStmt { + const raw = line.text + const t = raw.trim() + const lineIndex = line.index + const indent = raw.length - raw.trimStart().length + + if (t === '}') return { kind: 'blockClose', lineIndex, parts: [] } + if (inBlock) { + return { kind: 'attribute', lineIndex, text: t, textSpan: span(line, indent, raw.length) } + } + if (/^(direction|style|classDef|class)\b/.test(t)) return { kind: 'passthrough', lineIndex, parts: [] } + + const rel = RELATION_RE.exec(t) + if (rel) { + const [, source, leftCard, lineStyle, rightCard, target] = rel + const srcStart = indent + const leftStart = raw.indexOf(leftCard, srcStart + source.length) + const lineStart = leftStart + leftCard.length + const rightStart = lineStart + lineStyle.length + const tgtStart = raw.indexOf(target, rightStart + rightCard.length) + const colon = raw.indexOf(':', tgtStart + target.length) + return { + kind: 'relation', + lineIndex, + source, + sourceSpan: span(line, srcStart, srcStart + source.length), + target, + targetSpan: span(line, tgtStart, tgtStart + target.length), + leftCard, + leftCardSpan: span(line, leftStart, leftStart + leftCard.length), + rightCard, + rightCardSpan: span(line, rightStart, rightStart + rightCard.length), + line: lineStyle as '--' | '..', + lineSpan: span(line, lineStart, lineStart + lineStyle.length), + label: raw.slice(colon + 1).trim(), + labelSpan: span(line, colon + 1, raw.length), + } + } + + const decl = DECL_RE.exec(t) + if (decl) { + return { + kind: 'entityDecl', + lineIndex, + id: decl[1], + idSpan: span(line, indent, indent + decl[1].length), + alias: decl[2] ? decl[2].slice(1, -1) : null, + opensBlock: !!decl[3], + } + } + + return { kind: 'unknown', lineIndex, parts: [] } +} diff --git a/packages/core/src/flowchart/graph.ts b/packages/core/src/flowchart/graph.ts new file mode 100644 index 0000000..ffad47b --- /dev/null +++ b/packages/core/src/flowchart/graph.ts @@ -0,0 +1,214 @@ +import type { LineInfo, Span } from '../types' +import { + parseFlowStatement, + parseFlowHeader, + type ChainStmt, + type EdgeSeg, + type FlowStmt, + type NodeRef, + type ShapeId, +} from './parse' + +export interface NodeRefSite { + lineIndex: number + ref: NodeRef +} + +export interface FlowNode { + /** entity id: `node:` */ + entityId: string + id: string + /** display label (falls back to id) */ + label: string + shape: ShapeId + refs: NodeRefSite[] + /** the ref whose shape/label "wins" (last shaped ref) */ + primary: NodeRefSite | null + classes: string[] + subgraph: string | null +} + +export interface FlowEdge { + /** entity id: `edge:->#` */ + entityId: string + source: string + target: string + lineIndex: number + /** index into chain.edges */ + segIndex: number + seg: EdgeSeg + sourceRef: NodeRef + targetRef: NodeRef + label: string | null + /** order in document (== mermaid render order) */ + order: number +} + +export interface FlowSubgraph { + entityId: string + id: string + title: string | null + titleSpan: Span | null + openLine: number + endLine: number + parent: string | null +} + +export interface FlowGraph { + keyword: string + direction: string | null + directionSpan: Span | null + headerLine: number + nodes: FlowNode[] + edges: FlowEdge[] + subgraphs: FlowSubgraph[] + nodeById: Map + statements: Map + /** index of last non-blank content line */ + lastContentLine: number +} + +export function buildFlowGraph(lines: LineInfo[], headerIndex: number): FlowGraph { + const header = parseFlowHeader(lines[headerIndex]) + const nodes = new Map() + const edges: FlowEdge[] = [] + const subgraphs: FlowSubgraph[] = [] + const statements = new Map() + const sgStack: FlowSubgraph[] = [] + const edgeOccurrence = new Map() + let lastContentLine = headerIndex + + const getNode = (ref: NodeRef, lineIndex: number): FlowNode => { + let n = nodes.get(ref.id) + if (!n) { + n = { + entityId: `node:${ref.id}`, + id: ref.id, + label: ref.id, + shape: 'rect', + refs: [], + primary: null, + classes: [], + subgraph: sgStack.length ? sgStack[sgStack.length - 1].id : null, + } + nodes.set(ref.id, n) + } + n.refs.push({ lineIndex, ref }) + if (ref.shape) { + n.primary = { lineIndex, ref } + n.shape = ref.shape + if (ref.label !== null) n.label = ref.label + } + for (const c of ref.classNames) if (!n.classes.includes(c)) n.classes.push(c) + return n + } + + for (const line of lines) { + if (line.index === headerIndex) { + lastContentLine = line.index + continue + } + if (line.kind !== 'statement') continue + const stmt = parseFlowStatement(line) + statements.set(line.index, stmt) + lastContentLine = line.index + + if (stmt.kind === 'subgraphOpen') { + const sg: FlowSubgraph = { + entityId: `subgraph:${stmt.id}`, + id: stmt.id, + title: stmt.title, + titleSpan: stmt.titleSpan, + openLine: stmt.lineIndex, + endLine: -1, + parent: sgStack.length ? sgStack[sgStack.length - 1].id : null, + } + subgraphs.push(sg) + sgStack.push(sg) + } else if (stmt.kind === 'end') { + const sg = sgStack.pop() + if (sg) sg.endLine = stmt.lineIndex + } else if (stmt.kind === 'chain') { + for (const group of stmt.groups) { + for (const ref of group) getNode(ref, stmt.lineIndex) + } + stmt.edges.forEach((seg, i) => { + for (const a of stmt.groups[i]) { + for (const b of stmt.groups[i + 1]) { + const key = `${a.id}->${b.id}` + const occ = edgeOccurrence.get(key) ?? 0 + edgeOccurrence.set(key, occ + 1) + edges.push({ + entityId: `edge:${key}#${occ}`, + source: a.id, + target: b.id, + lineIndex: stmt.lineIndex, + segIndex: i, + seg, + sourceRef: a, + targetRef: b, + label: seg.label, + order: edges.length, + }) + } + } + }) + } else if (stmt.kind === 'classAssign') { + const cls = /\s([A-Za-z0-9_-]+)\s*;?\s*$/.exec(line.text)?.[1] + if (cls) { + for (const id of stmt.ids) { + const n = nodes.get(id) + if (n && !n.classes.includes(cls)) n.classes.push(cls) + } + } + } + } + + return { + keyword: header.keyword, + direction: header.direction, + directionSpan: header.directionSpan, + headerLine: headerIndex, + nodes: [...nodes.values()], + edges, + subgraphs, + nodeById: nodes, + statements, + lastContentLine, + } +} + +/** All spans in the document that "belong" to an entity (for code highlighting). */ +export function entitySpans(graph: FlowGraph, lines: LineInfo[], entityId: string): Span[] { + if (entityId.startsWith('node:')) { + const node = graph.nodeById.get(entityId.slice(5)) + if (!node) return [] + return node.refs.map((r) => r.ref.span) + } + if (entityId.startsWith('edge:')) { + const edge = graph.edges.find((e) => e.entityId === entityId) + if (!edge) return [] + return [{ start: edge.sourceRef.span.start, end: edge.targetRef.span.end }] + } + if (entityId.startsWith('subgraph:')) { + const sg = graph.subgraphs.find((s) => s.entityId === entityId) + if (!sg) return [] + const line = lines[sg.openLine] + return [{ start: line.start + line.indent.length, end: line.end }] + } + return [] +} + +/** Find the entity whose span contains the given text offset (nodes win over edges). */ +export function entityAtOffset(graph: FlowGraph, offset: number): string | null { + for (const node of graph.nodes) { + for (const r of node.refs) { + if (offset >= r.ref.span.start && offset <= r.ref.span.end) return node.entityId + } + } + for (const edge of graph.edges) { + if (offset >= edge.seg.span.start && offset <= edge.seg.span.end) return edge.entityId + if (offset >= edge.sourceRef.span.start && offset <= edge.targetRef.span.end) return edge.entityId + } + return null +} diff --git a/packages/core/src/flowchart/ops.ts b/packages/core/src/flowchart/ops.ts new file mode 100644 index 0000000..c313833 --- /dev/null +++ b/packages/core/src/flowchart/ops.ts @@ -0,0 +1,498 @@ +import type { LineInfo, TextEdit } from '../types' +import type { FlowGraph, FlowEdge, FlowNode } from './graph' +import { SHAPE_DELIMS, type ChainStmt, type EdgeArrow, type EdgeLine, type NodeRef, type ShapeId } from './parse' + +export type FlowchartOp = + | { type: 'addNode'; shape?: ShapeId; label?: string; id?: string } + | { type: 'connect'; source: string; target: string; line?: EdgeLine; arrowEnd?: EdgeArrow; label?: string } + | { type: 'renameNode'; id: string; label: string } + | { type: 'setNodeShape'; id: string; shape: ShapeId } + | { type: 'setEdgeLabel'; edgeId: string; label: string } + | { type: 'setEdgeStyle'; edgeId: string; line?: EdgeLine; arrowEnd?: EdgeArrow } + | { type: 'setDirection'; direction: string } + | { type: 'deleteNode'; id: string } + | { type: 'deleteEdge'; edgeId: string } + | { type: 'renameSubgraph'; id: string; title: string } + | { type: 'setNodeColor'; id: string; prop: 'fill' | 'stroke' | 'color'; value: string | null } + | { type: 'setEdgeColor'; edgeId: string; value: string | null } + | { type: 'reverseEdge'; edgeId: string } + | { type: 'duplicateNode'; id: string } + +export interface OpResult { + edits: TextEdit[] + /** entity ids created by this op (e.g. the new node) */ + created?: string[] +} + +export interface FlowOpContext { + code: string + lines: LineInfo[] + graph: FlowGraph +} + +// ---------- helpers ---------- + +const NEEDS_QUOTE = /[[\]{}()|<>\\/&%#;]|^\s|\s$/ + +function printLabel(label: string, forceQuote: boolean): string { + const clean = label.replace(/"/g, "'") + if (forceQuote || NEEDS_QUOTE.test(clean)) return `"${clean}"` + return clean +} + +function edgeOpString(line: EdgeLine, arrowEnd: EdgeArrow): string { + const end = arrowEnd === 'arrow' ? '>' : arrowEnd === 'cross' ? 'x' : arrowEnd === 'circle' ? 'o' : '' + switch (line) { + case 'thick': + return end ? `==${end}` : '===' + case 'dotted': + return end ? `-.-${end}` : '-.-' + case 'invisible': + return '~~~' + default: + return end ? `--${end}` : '---' + } +} + +function bodyIndent(ctx: FlowOpContext): string { + for (const line of ctx.lines) { + if (line.kind === 'statement' && line.index !== ctx.graph.headerLine && line.text.trim() !== '') { + return line.indent + } + } + return ' ' +} + +function insertLinesAfter(ctx: FlowOpContext, lineIndex: number, texts: string[]): TextEdit { + const line = ctx.lines[lineIndex] + return { start: line.end, end: line.end, text: texts.map((t) => `\n${t}`).join('') } +} + +function deleteLineEdit(ctx: FlowOpContext, lineIndex: number): TextEdit { + const line = ctx.lines[lineIndex] + if (line.end < ctx.code.length) return { start: line.start, end: line.end + 1, text: '' } + if (line.start > 0) return { start: line.start - 1, end: line.end, text: '' } + return { start: line.start, end: line.end, text: '' } +} + +function replaceLineEdit(ctx: FlowOpContext, lineIndex: number, texts: string[]): TextEdit { + if (texts.length === 0) return deleteLineEdit(ctx, lineIndex) + const line = ctx.lines[lineIndex] + return { start: line.start, end: line.end, text: texts.join('\n') } +} + +function slice(ctx: FlowOpContext, span: { start: number; end: number }): string { + return ctx.code.slice(span.start, span.end) +} + +function generateNodeId(graph: FlowGraph, preferred?: string): string { + const existing = new Set(graph.nodes.map((n) => n.id)) + if (preferred && !existing.has(preferred)) return preferred + const allLetters = graph.nodes.length > 0 && graph.nodes.every((n) => /^[A-Z]$/.test(n.id)) + if (allLetters) { + for (let i = 0; i < 26; i++) { + const c = String.fromCharCode(65 + i) + if (!existing.has(c)) return c + } + } + let i = 1 + while (existing.has(`n${i}`)) i++ + return `n${i}` +} + +function lastRefLine(graph: FlowGraph, ids: string[]): number { + let last = graph.lastContentLine + let found = -1 + for (const id of ids) { + const n = graph.nodeById.get(id) + if (!n) continue + for (const r of n.refs) found = Math.max(found, r.lineIndex) + } + return found === -1 ? last : found +} + +/** + * Rebuild a chain statement without a node and/or without one specific edge. + * Returns replacement line texts ([] = drop the line). Preserves the exact + * source text of surviving refs and edge segments. + */ +function rebuildChain( + ctx: FlowOpContext, + lineIndex: number, + chain: ChainStmt, + opts: { dropNodeId?: string; dropEdge?: { segIndex: number; source: string; target: string } }, +): string[] { + const line = ctx.lines[lineIndex] + const indent = line.indent + + interface Expanded { + a: NodeRef + b: NodeRef + segIndex: number + } + const expanded: Expanded[] = [] + chain.edges.forEach((_, i) => { + for (const a of chain.groups[i]) { + for (const b of chain.groups[i + 1]) { + expanded.push({ a, b, segIndex: i }) + } + } + }) + + const kept = expanded.filter((e) => { + if (opts.dropNodeId && (e.a.id === opts.dropNodeId || e.b.id === opts.dropNodeId)) return false + if ( + opts.dropEdge && + e.segIndex === opts.dropEdge.segIndex && + e.a.id === opts.dropEdge.source && + e.b.id === opts.dropEdge.target + ) { + return false + } + return true + }) + + if (kept.length === expanded.length && !opts.dropNodeId) return [line.text] + + const survivors = new Set() + for (const e of kept) { + survivors.add(e.a) + survivors.add(e.b) + } + + // re-chain greedily: A-->B-->C stays one line where the refs connect + const out: string[] = [] + let current = '' + let lastB: NodeRef | null = null + for (const e of kept) { + const segText = slice(ctx, chain.edges[e.segIndex].span) + if (lastB === e.a && current) { + current += ` ${segText} ${slice(ctx, e.b.span)}` + } else { + if (current) out.push(indent + current) + current = `${slice(ctx, e.a.span)} ${segText} ${slice(ctx, e.b.span)}` + } + lastB = e.b + } + if (current) out.push(indent + current) + + // preserve standalone declarations for refs that lost all their edges on this + // line but carry information (a shape/label) or are defined nowhere else + for (const group of chain.groups) { + for (const ref of group) { + if (ref.id === opts.dropNodeId) continue + if (survivors.has(ref)) continue + const node = ctx.graph.nodeById.get(ref.id) + if (!node) continue + const shapedElsewhere = node.refs.some((r) => r.ref !== ref && r.ref.shape) + const referencedElsewhere = node.refs.some((r) => r.lineIndex !== lineIndex || (r.ref !== ref && survivors.has(r.ref))) + if (ref.shape && !shapedElsewhere) { + out.push(indent + slice(ctx, ref.span)) + } else if (!referencedElsewhere && !ref.shape) { + out.push(indent + slice(ctx, ref.span)) + } + } + } + + return out +} + +function findEdge(graph: FlowGraph, edgeId: string): FlowEdge | null { + return graph.edges.find((e) => e.entityId === edgeId) ?? null +} + +/** + * `linkStyle ` addresses edges by render order, so structural ops that + * change edge order must renumber (or drop) the affected statements. + * `map` returns the new index for an old one, or null to delete the line. + */ +function renumberLinkStyles(ctx: FlowOpContext, map: (index: number) => number | null): TextEdit[] { + const edits: TextEdit[] = [] + for (const [lineIndex, stmt] of ctx.graph.statements) { + if (stmt.kind !== 'linkStyle') continue + const line = ctx.lines[lineIndex] + const m = /^(\s*linkStyle\s+)(\d+)\b/.exec(line.text) + if (!m) continue + const oldIndex = Number(m[2]) + const newIndex = map(oldIndex) + if (newIndex === null) { + edits.push(deleteLineEdit(ctx, lineIndex)) + } else if (newIndex !== oldIndex) { + const numStart = line.start + m[1].length + edits.push({ start: numStart, end: numStart + m[2].length, text: String(newIndex) }) + } + } + return edits +} + +function nodeOf(graph: FlowGraph, id: string): FlowNode | null { + return graph.nodeById.get(id) ?? null +} + +// ---------- compiler ---------- + +export function compileFlowchartOp(ctx: FlowOpContext, op: FlowchartOp): OpResult | null { + const { graph } = ctx + + switch (op.type) { + case 'addNode': { + const id = generateNodeId(graph, op.id) + const shape = op.shape ?? 'rect' + const label = op.label ?? id + const { open, close } = SHAPE_DELIMS[shape] + const text = `${bodyIndent(ctx)}${id}${open}${printLabel(label, false)}${close}` + const edit = insertLinesAfter(ctx, graph.lastContentLine, [text]) + return { edits: [edit], created: [`node:${id}`] } + } + + case 'connect': { + const src = nodeOf(graph, op.source) + const tgt = nodeOf(graph, op.target) + if (!src || !tgt) return null + const opStr = edgeOpString(op.line ?? 'solid', op.arrowEnd ?? 'arrow') + const label = op.label ? `|${op.label.replace(/\|/g, '')}|` : '' + const after = lastRefLine(graph, [op.source, op.target]) + const indent = ctx.lines[after].kind === 'statement' ? ctx.lines[after].indent : bodyIndent(ctx) + const text = `${indent}${op.source} ${opStr}${label} ${op.target}` + const occ = graph.edges.filter((e) => e.source === op.source && e.target === op.target).length + // the new edge takes the order index of the first edge on a later line + const newOrder = graph.edges.filter((e) => e.lineIndex <= after).length + return { + edits: [ + insertLinesAfter(ctx, after, [text]), + ...renumberLinkStyles(ctx, (i) => (i >= newOrder ? i + 1 : i)), + ], + created: [`edge:${op.source}->${op.target}#${occ}`], + } + } + + case 'renameNode': { + const node = nodeOf(graph, op.id) + if (!node) return null + if (node.primary && node.primary.ref.labelSpan) { + const ref = node.primary.ref + const span = ref.labelSpan! + if (ref.quoted) { + return { edits: [{ start: span.start, end: span.end, text: op.label.replace(/"/g, "'") }] } + } + return { edits: [{ start: span.start, end: span.end, text: printLabel(op.label, false) }] } + } + // implicit node: attach a rect declaration at its first reference + const first = node.refs[0] + if (!first) return null + const at = first.ref.idSpan.end + return { edits: [{ start: at, end: at, text: `[${printLabel(op.label, false)}]` }] } + } + + case 'setNodeShape': { + const node = nodeOf(graph, op.id) + if (!node) return null + const { open, close } = SHAPE_DELIMS[op.shape] + if (node.primary && node.primary.ref.labelSpan) { + const ref = node.primary.ref + const oldDelims = SHAPE_DELIMS[ref.shape!] + const q = ref.quoted ? 1 : 0 + const openStart = ref.idSpan.end + const openEnd = ref.labelSpan!.start - q + const closeStart = ref.labelSpan!.end + q + const closeEnd = closeStart + oldDelims.close.length + return { + edits: [ + { start: openStart, end: openEnd, text: open }, + { start: closeStart, end: closeEnd, text: close }, + ], + } + } + const first = node.refs[0] + if (!first) return null + const at = first.ref.idSpan.end + return { edits: [{ start: at, end: at, text: `${open}${printLabel(node.label, false)}${close}` }] } + } + + case 'setEdgeLabel': { + const edge = findEdge(graph, op.edgeId) + if (!edge) return null + const label = op.label.replace(/\|/g, '') + if (edge.seg.labelSpan) { + if (label === '') { + // remove the label entirely by rewriting the operator + const opStr = edgeOpString(edge.seg.line, edge.seg.arrowEnd) + return { edits: [{ start: edge.seg.span.start, end: edge.seg.span.end, text: opStr }] } + } + return { edits: [{ start: edge.seg.labelSpan.start, end: edge.seg.labelSpan.end, text: label }] } + } + if (label === '') return { edits: [] } + return { edits: [{ start: edge.seg.span.end, end: edge.seg.span.end, text: `|${label}|` }] } + } + + case 'setEdgeStyle': { + const edge = findEdge(graph, op.edgeId) + if (!edge) return null + const line = op.line ?? edge.seg.line + const arrowEnd = op.arrowEnd ?? edge.seg.arrowEnd + const opStr = edgeOpString(line, arrowEnd) + const label = edge.seg.label !== null ? `|${edge.seg.label.replace(/\|/g, '')}|` : '' + return { edits: [{ start: edge.seg.span.start, end: edge.seg.span.end, text: `${opStr}${label}` }] } + } + + case 'setDirection': { + if (graph.directionSpan) { + return { edits: [{ start: graph.directionSpan.start, end: graph.directionSpan.end, text: op.direction }] } + } + const header = ctx.lines[graph.headerLine] + const kwEnd = header.start + header.indent.length + graph.keyword.length + return { edits: [{ start: kwEnd, end: kwEnd, text: ` ${op.direction}` }] } + } + + case 'deleteEdge': { + const edge = findEdge(graph, op.edgeId) + if (!edge) return null + const stmt = graph.statements.get(edge.lineIndex) + if (!stmt || stmt.kind !== 'chain') return null + const texts = rebuildChain(ctx, edge.lineIndex, stmt, { + dropEdge: { segIndex: edge.segIndex, source: edge.source, target: edge.target }, + }) + const removed = edge.order + return { + edits: [ + replaceLineEdit(ctx, edge.lineIndex, texts), + ...renumberLinkStyles(ctx, (i) => (i === removed ? null : i > removed ? i - 1 : i)), + ], + } + } + + case 'deleteNode': { + const node = nodeOf(graph, op.id) + if (!node) return null + const edits: TextEdit[] = [] + const chainLines = new Set() + for (const r of node.refs) chainLines.add(r.lineIndex) + for (const lineIndex of chainLines) { + const stmt = graph.statements.get(lineIndex) + if (!stmt || stmt.kind !== 'chain') continue + const texts = rebuildChain(ctx, lineIndex, stmt, { dropNodeId: op.id }) + edits.push(replaceLineEdit(ctx, lineIndex, texts)) + } + // clean up class/style/click statements that reference the node + for (const [lineIndex, stmt] of graph.statements) { + if (stmt.kind === 'classAssign' && stmt.ids.includes(op.id) && stmt.idsSpan) { + const remaining = stmt.ids.filter((i) => i !== op.id) + if (remaining.length === 0) edits.push(deleteLineEdit(ctx, lineIndex)) + else edits.push({ start: stmt.idsSpan.start, end: stmt.idsSpan.end, text: remaining.join(',') }) + } else if ((stmt.kind === 'style' || stmt.kind === 'click') && stmt.ids[0] === op.id) { + edits.push(deleteLineEdit(ctx, lineIndex)) + } + } + const removedOrders = graph.edges + .filter((e) => e.source === op.id || e.target === op.id) + .map((e) => e.order) + .sort((a, b) => a - b) + edits.push( + ...renumberLinkStyles(ctx, (i) => { + if (removedOrders.includes(i)) return null + return i - removedOrders.filter((r) => r < i).length + }), + ) + return { edits } + } + + case 'duplicateNode': { + const node = nodeOf(graph, op.id) + if (!node) return null + const newId = generateNodeId(graph) + const { open, close } = SHAPE_DELIMS[node.shape] + const classes = node.classes.length ? `:::${node.classes[0]}` : '' + const anchor = node.primary?.lineIndex ?? node.refs[0]?.lineIndex ?? graph.lastContentLine + const indent = ctx.lines[anchor].kind === 'statement' ? ctx.lines[anchor].indent : bodyIndent(ctx) + const text = `${indent}${newId}${open}${printLabel(node.label, false)}${close}${classes}` + return { + edits: [insertLinesAfter(ctx, anchor, [text])], + created: [`node:${newId}`], + } + } + + case 'reverseEdge': { + const edge = findEdge(graph, op.edgeId) + if (!edge) return null + const stmt = graph.statements.get(edge.lineIndex) + // only safe on a simple `A --> B` statement — in chains/fans, swapping + // the two refs would silently rewire the neighbors + if (!stmt || stmt.kind !== 'chain' || stmt.groups.length !== 2) return null + if (stmt.groups[0].length !== 1 || stmt.groups[1].length !== 1) return null + const srcRaw = slice(ctx, edge.sourceRef.span) + const tgtRaw = slice(ctx, edge.targetRef.span) + return { + edits: [ + { start: edge.sourceRef.span.start, end: edge.sourceRef.span.end, text: tgtRaw }, + { start: edge.targetRef.span.start, end: edge.targetRef.span.end, text: srcRaw }, + ], + } + } + + case 'setNodeColor': { + const node = nodeOf(graph, op.id) + if (!node) return null + // manage a `style k:v,k:v` statement for this node + let styleLine = -1 + for (const [lineIndex, stmt] of graph.statements) { + if (stmt.kind === 'style' && stmt.ids[0] === op.id) { + styleLine = lineIndex + break + } + } + const props = new Map() + if (styleLine >= 0) { + const rest = ctx.lines[styleLine].text.trim().replace(new RegExp(`^style\\s+${op.id}\\s*`), '') + for (const pair of rest.split(',')) { + const i = pair.indexOf(':') + if (i > 0) props.set(pair.slice(0, i).trim(), pair.slice(i + 1).trim()) + } + } + if (op.value === null) props.delete(op.prop) + else props.set(op.prop, op.value) + // a readable stroke needs some width; keep mermaid's default otherwise + if (props.has('stroke') && !props.has('stroke-width')) props.set('stroke-width', '2px') + if (!props.has('stroke')) props.delete('stroke-width') + + const text = `${styleLine >= 0 ? ctx.lines[styleLine].indent : bodyIndent(ctx)}style ${op.id} ${[...props].map(([k, v]) => `${k}:${v}`).join(',')}` + if (styleLine >= 0) { + return { edits: [props.size === 0 ? deleteLineEdit(ctx, styleLine) : replaceLineEdit(ctx, styleLine, [text])] } + } + if (props.size === 0) return { edits: [] } + return { edits: [insertLinesAfter(ctx, graph.lastContentLine, [text])] } + } + + case 'setEdgeColor': { + const edge = findEdge(graph, op.edgeId) + if (!edge) return null + // linkStyle addresses edges by render index (== document order) + let linkLine = -1 + for (const [lineIndex, stmt] of graph.statements) { + if (stmt.kind !== 'linkStyle') continue + const m = /^linkStyle\s+(\d+)\b/.exec(ctx.lines[lineIndex].text.trim()) + if (m && Number(m[1]) === edge.order) { + linkLine = lineIndex + break + } + } + if (op.value === null) { + if (linkLine === -1) return { edits: [] } + return { edits: [deleteLineEdit(ctx, linkLine)] } + } + const indent = linkLine >= 0 ? ctx.lines[linkLine].indent : bodyIndent(ctx) + const text = `${indent}linkStyle ${edge.order} stroke:${op.value},stroke-width:2px` + if (linkLine >= 0) return { edits: [replaceLineEdit(ctx, linkLine, [text])] } + return { edits: [insertLinesAfter(ctx, graph.lastContentLine, [text])] } + } + + case 'renameSubgraph': { + const sg = graph.subgraphs.find((s) => s.id === op.id) + if (!sg) return null + if (sg.titleSpan) { + return { edits: [{ start: sg.titleSpan.start, end: sg.titleSpan.end, text: op.title.replace(/[\]"]/g, '') }] } + } + const line = ctx.lines[sg.openLine] + return { edits: [{ start: line.end, end: line.end, text: ` [${op.title.replace(/[\]"]/g, '')}]` }] } + } + } +} diff --git a/packages/core/src/flowchart/parse.ts b/packages/core/src/flowchart/parse.ts new file mode 100644 index 0000000..39c18fd --- /dev/null +++ b/packages/core/src/flowchart/parse.ts @@ -0,0 +1,448 @@ +import type { LineInfo, Span } from '../types' + +/** Node shapes expressible with bracket syntax. */ +export type ShapeId = + | 'rect' + | 'round' + | 'stadium' + | 'subroutine' + | 'cylinder' + | 'circle' + | 'doublecircle' + | 'diamond' + | 'hexagon' + | 'lean_r' + | 'lean_l' + | 'trap_t' + | 'trap_b' + | 'odd' + +interface ShapeDef { + open: string + close: string[] +} + +/** Ordered longest-open-first so matching is unambiguous. */ +const SHAPE_OPENS: ShapeDef[] = [ + { open: '(((', close: [')))'] }, + { open: '([', close: ['])'] }, + { open: '[[', close: [']]'] }, + { open: '[(', close: [')]'] }, + { open: '[/', close: ['/]', '\\]'] }, + { open: '[\\', close: ['\\]', '/]'] }, + { open: '((', close: ['))'] }, + { open: '{{', close: ['}}'] }, + { open: '{', close: ['}'] }, + { open: '(', close: [')'] }, + { open: '[', close: [']'] }, + { open: '>', close: [']'] }, +] + +const SHAPE_BY_DELIMS: Record = { + '(((|)))': 'doublecircle', + '((|))': 'circle', + '([|])': 'stadium', + '[[|]]': 'subroutine', + '[(|)]': 'cylinder', + '[/|/]': 'lean_r', + '[/|\\]': 'trap_b', + '[\\|\\]': 'lean_l', + '[\\|/]': 'trap_t', + '{{|}}': 'hexagon', + '{|}': 'diamond', + '(|)': 'round', + '[|]': 'rect', + '>|]': 'odd', +} + +export const SHAPE_DELIMS: Record = { + rect: { open: '[', close: ']' }, + round: { open: '(', close: ')' }, + stadium: { open: '([', close: '])' }, + subroutine: { open: '[[', close: ']]' }, + cylinder: { open: '[(', close: ')]' }, + circle: { open: '((', close: '))' }, + doublecircle: { open: '(((', close: ')))' }, + diamond: { open: '{', close: '}' }, + hexagon: { open: '{{', close: '}}' }, + lean_r: { open: '[/', close: '/]' }, + lean_l: { open: '[\\', close: '\\]' }, + trap_t: { open: '[\\', close: '/]' }, + trap_b: { open: '[/', close: '\\]' }, + odd: { open: '>', close: ']' }, +} + +export interface NodeRef { + id: string + /** full ref span (id + shape + attrs + :::classes), absolute offsets */ + span: Span + idSpan: Span + shape: ShapeId | null + /** raw label text (between delimiters, excluding quotes) */ + label: string | null + /** span of the label text (inside quotes if quoted) */ + labelSpan: Span | null + quoted: boolean + /** raw @{...} attribute blob, if present */ + attrsRaw: string | null + classNames: string[] +} + +export type EdgeLine = 'solid' | 'thick' | 'dotted' | 'invisible' +export type EdgeArrow = 'arrow' | 'open' | 'circle' | 'cross' + +export interface EdgeSeg { + /** span covering operator plus any label form, absolute offsets */ + span: Span + raw: string + line: EdgeLine + arrowEnd: EdgeArrow + arrowStart: EdgeArrow + label: string | null + labelSpan: Span | null +} + +export interface ChainStmt { + kind: 'chain' + lineIndex: number + /** groups of `&`-joined node refs; edges[i] connects groups[i] → groups[i+1] */ + groups: NodeRef[][] + edges: EdgeSeg[] +} + +export interface SubgraphOpenStmt { + kind: 'subgraphOpen' + lineIndex: number + id: string + title: string | null + titleSpan: Span | null +} + +export interface SimpleStmt { + kind: 'end' | 'direction' | 'classDef' | 'classAssign' | 'style' | 'linkStyle' | 'click' | 'unknown' + lineIndex: number + /** for 'classAssign': node id list; for 'style'/'click': [targetId]; for 'direction': [dir] */ + ids: string[] + /** span of the id-list portion (classAssign) or full trimmed statement */ + idsSpan: Span | null +} + +export type FlowStmt = ChainStmt | SubgraphOpenStmt | SimpleStmt + +const ID_CHAR = /[A-Za-z0-9_.]/ +const DIRECTIONS = ['TB', 'TD', 'BT', 'RL', 'LR'] + +class LineParser { + text: string + pos = 0 + end: number + base: number + + constructor(line: LineInfo) { + this.text = line.text + this.base = line.start + // trim trailing semicolons/whitespace (mermaid allows optional `;`) + let e = this.text.length + while (e > 0 && /[;\s]/.test(this.text[e - 1])) e-- + this.end = e + // skip indent + let p = 0 + while (p < e && /[ \t]/.test(this.text[p])) p++ + this.pos = p + } + + skipWs() { + while (this.pos < this.end && /[ \t]/.test(this.text[this.pos])) this.pos++ + } + + atEnd() { + return this.pos >= this.end + } + + abs(rel: number): number { + return this.base + rel + } + + parseNodeRef(): NodeRef | null { + const start = this.pos + const idStart = this.pos + while (this.pos < this.end && ID_CHAR.test(this.text[this.pos])) this.pos++ + if (this.pos === idStart) return null + const id = this.text.slice(idStart, this.pos) + const idSpan: Span = { start: this.abs(idStart), end: this.abs(this.pos) } + + let shape: ShapeId | null = null + let label: string | null = null + let labelSpan: Span | null = null + let quoted = false + + for (const def of SHAPE_OPENS) { + if (this.text.startsWith(def.open, this.pos)) { + const openEnd = this.pos + def.open.length + let closeStr: string | null = null + let labelStart: number + let labelEnd: number + let afterLabel: number + if (this.text[openEnd] === '"') { + const q = this.text.indexOf('"', openEnd + 1) + if (q === -1 || q >= this.end) return null + labelStart = openEnd + 1 + labelEnd = q + afterLabel = q + 1 + quoted = true + for (const c of def.close) { + if (this.text.startsWith(c, afterLabel)) { + closeStr = c + break + } + } + if (!closeStr) return null + this.pos = afterLabel + closeStr.length + } else { + let best = -1 + for (const c of def.close) { + const i = this.text.indexOf(c, openEnd) + if (i !== -1 && i < this.end && (best === -1 || i < best)) { + best = i + closeStr = c + } + } + if (best === -1 || !closeStr) return null + labelStart = openEnd + labelEnd = best + this.pos = best + closeStr.length + } + shape = SHAPE_BY_DELIMS[`${def.open}|${closeStr}`] ?? 'rect' + label = this.text.slice(labelStart, labelEnd) + labelSpan = { start: this.abs(labelStart), end: this.abs(labelEnd) } + break + } + } + + // v11 `@{ shape: ..., label: ... }` attribute object — preserved raw + let attrsRaw: string | null = null + if (this.text.startsWith('@{', this.pos)) { + const close = this.text.indexOf('}', this.pos + 2) + if (close === -1 || close >= this.end + 1) return null + attrsRaw = this.text.slice(this.pos, close + 1) + this.pos = close + 1 + } + + const classNames: string[] = [] + while (this.text.startsWith(':::', this.pos)) { + let p = this.pos + 3 + const cStart = p + while (p < this.end && /[A-Za-z0-9_-]/.test(this.text[p])) p++ + if (p === cStart) return null + classNames.push(this.text.slice(cStart, p)) + this.pos = p + } + + return { + id, + span: { start: this.abs(start), end: this.abs(this.pos) }, + idSpan, + shape, + label, + labelSpan, + quoted, + attrsRaw, + classNames, + } + } + + parseGroup(): NodeRef[] | null { + const first = this.parseNodeRef() + if (!first) return null + const refs = [first] + while (true) { + const save = this.pos + this.skipWs() + if (this.text[this.pos] === '&') { + this.pos++ + this.skipWs() + const next = this.parseNodeRef() + if (!next) return null + refs.push(next) + } else { + this.pos = save + break + } + } + return refs + } + + parseEdge(): EdgeSeg | null { + const start = this.pos + const rest = this.text.slice(this.pos, this.end) + const m = /^(?:(x|o|<)(?=[-=.~]))?(-{2,}|={2,}|-\.+-+|-\.+|~{3,})(>|x|o)?/.exec(rest) + if (!m) return null + let raw = m[0] + this.pos += raw.length + const startDec: string | null = m[1] ?? null + let endDec: string | null = m[3] ?? null + const body = m[2] + + let label: string | null = null + let labelSpan: Span | null = null + + // mid-label form: `-- text -->`, `== text ==>`, `-. text .->` + if (!endDec && /^(-{2}|={2}|-\.+-*)$/.test(body) && this.text[this.pos] === ' ') { + const closeRe = + body[0] === '=' + ? /\s(={2,}(>|x|o)?)/ + : body.includes('.') + ? /\s(\.*-*\.+->|\.+-+(>|x|o)?|\.->)/ + : /\s(-{2,}(>|x|o)?)/ + const tail = this.text.slice(this.pos, this.end) + const cm = closeRe.exec(tail) + if (cm && cm.index >= 0) { + const lStart = this.pos + 1 + const lEnd = this.pos + cm.index + if (lEnd > lStart - 1) { + label = this.text.slice(lStart, lEnd) + labelSpan = { start: this.abs(lStart), end: this.abs(lEnd) } + const closeRaw = cm[1] + this.pos = this.pos + cm.index + cm[0].length + endDec = /[>xo]$/.test(closeRaw) ? closeRaw[closeRaw.length - 1] : null + } + } + } + + // pipe label form: `-->|text|` + const savePos = this.pos + this.skipWs() + if (this.text[this.pos] === '|') { + const close = this.text.indexOf('|', this.pos + 1) + if (close !== -1 && close < this.end) { + label = this.text.slice(this.pos + 1, close) + labelSpan = { start: this.abs(this.pos + 1), end: this.abs(close) } + this.pos = close + 1 + } else { + this.pos = savePos + } + } else { + this.pos = savePos + } + + raw = this.text.slice(start, this.pos) + const line: EdgeLine = + body[0] === '=' ? 'thick' : body.includes('.') ? 'dotted' : body[0] === '~' ? 'invisible' : 'solid' + const dec = (d: string | null): EdgeArrow => + d === '>' ? 'arrow' : d === 'x' ? 'cross' : d === 'o' ? 'circle' : 'open' + + return { + span: { start: this.abs(start), end: this.abs(this.pos) }, + raw, + line, + arrowEnd: dec(endDec), + arrowStart: startDec ? (startDec === '<' ? 'arrow' : dec(startDec)) : 'open', + label, + labelSpan, + } + } +} + +export function parseFlowStatement(line: LineInfo): FlowStmt { + const t = line.text.trim() + const lineIndex = line.index + + if (t === 'end') return { kind: 'end', lineIndex, ids: [], idsSpan: null } + + const dirM = /^direction\s+(TB|TD|BT|RL|LR)\s*;?\s*$/.exec(t) + if (dirM) return { kind: 'direction', lineIndex, ids: [dirM[1]], idsSpan: null } + + const sgM = /^subgraph\s+(.*)$/.exec(t) + if (sgM) { + const p = new LineParser(line) + p.pos = line.text.indexOf('subgraph') + 'subgraph'.length + p.skipWs() + const idStart = p.pos + while (p.pos < p.end && /[^\s[]/.test(line.text[p.pos])) p.pos++ + const id = line.text.slice(idStart, p.pos) + let title: string | null = null + let titleSpan: Span | null = null + p.skipWs() + if (line.text[p.pos] === '[') { + const close = line.text.lastIndexOf(']') + if (close > p.pos) { + let s = p.pos + 1 + let e = close + if (line.text[s] === '"' && line.text[e - 1] === '"') { + s++ + e-- + } + title = line.text.slice(s, e) + titleSpan = { start: line.start + s, end: line.start + e } + } + } + return { kind: 'subgraphOpen', lineIndex, id, title, titleSpan } + } + + if (/^classDef\b/.test(t)) return { kind: 'classDef', lineIndex, ids: [], idsSpan: null } + if (/^linkStyle\b/.test(t)) return { kind: 'linkStyle', lineIndex, ids: [], idsSpan: null } + + const classM = /^class\s+([A-Za-z0-9_.,\s]+?)\s+([A-Za-z0-9_-]+)\s*;?\s*$/.exec(t) + if (classM) { + const ids = classM[1].split(',').map((s) => s.trim()).filter(Boolean) + const listStart = line.text.indexOf(classM[1]) + return { + kind: 'classAssign', + lineIndex, + ids, + idsSpan: { start: line.start + listStart, end: line.start + listStart + classM[1].length }, + } + } + + const styleM = /^style\s+([A-Za-z0-9_.]+)\b/.exec(t) + if (styleM) return { kind: 'style', lineIndex, ids: [styleM[1]], idsSpan: null } + + const clickM = /^click\s+([A-Za-z0-9_.]+)\b/.exec(t) + if (clickM) return { kind: 'click', lineIndex, ids: [clickM[1]], idsSpan: null } + + // chain (nodes and edges) + const p = new LineParser(line) + const groups: NodeRef[][] = [] + const edges: EdgeSeg[] = [] + const first = p.parseGroup() + if (first) { + groups.push(first) + let ok = true + while (true) { + p.skipWs() + if (p.atEnd()) break + const edge = p.parseEdge() + if (!edge) { + ok = false + break + } + p.skipWs() + const g = p.parseGroup() + if (!g) { + ok = false + break + } + edges.push(edge) + groups.push(g) + } + if (ok) return { kind: 'chain', lineIndex, groups, edges } + } + + return { kind: 'unknown', lineIndex, ids: [], idsSpan: null } +} + +/** Parse the flowchart header, returning direction info. */ +export function parseFlowHeader(line: LineInfo): { keyword: string; direction: string | null; directionSpan: Span | null } { + const m = /^(\s*)(flowchart|graph)(\s+)?(TB|TD|BT|RL|LR)?/.exec(line.text) + if (!m) return { keyword: 'flowchart', direction: null, directionSpan: null } + const keyword = m[2] + const direction = m[4] ?? null + let directionSpan: Span | null = null + if (direction) { + const dirStart = line.start + m[1].length + keyword.length + (m[3]?.length ?? 0) + directionSpan = { start: dirStart, end: dirStart + direction.length } + } + return { keyword, direction, directionSpan } +} + +export { DIRECTIONS } diff --git a/packages/core/src/gantt/index.ts b/packages/core/src/gantt/index.ts new file mode 100644 index 0000000..216790c --- /dev/null +++ b/packages/core/src/gantt/index.ts @@ -0,0 +1,141 @@ +import type { LineInfo, Span, TextEdit } from '../types' + +export interface GanttTask { + /** entity id: `task:` */ + entityId: string + name: string + /** raw metadata after the colon (ids, flags, dates, durations) */ + meta: string + lineIndex: number + nameSpan: Span + metaSpan: Span + section: string | null +} + +export interface GanttGraph { + headerLine: number + tasks: GanttTask[] + sections: Array<{ name: string; lineIndex: number }> + lastContentLine: number +} + +const KEYWORD_RE = /^(dateFormat|axisFormat|tickInterval|excludes|includes|todayMarker|title|weekday|inclusiveEndDates|topAxis)\b/ +const SECTION_RE = /^section\s+(.+)$/ +const TASK_RE = /^(.+?)\s*:\s*(.+)$/ + +export function buildGanttGraph(lines: LineInfo[], headerIndex: number): GanttGraph { + const tasks: GanttTask[] = [] + const sections: GanttGraph['sections'] = [] + let currentSection: string | null = null + let lastContentLine = headerIndex + + for (const line of lines) { + if (line.kind !== 'statement' || line.index === headerIndex) { + if (line.index === headerIndex) lastContentLine = line.index + continue + } + lastContentLine = line.index + const t = line.text.trim() + if (KEYWORD_RE.test(t)) continue + const sec = SECTION_RE.exec(t) + if (sec) { + currentSection = sec[1].trim() + sections.push({ name: currentSection, lineIndex: line.index }) + continue + } + const task = TASK_RE.exec(t) + if (task) { + const nameStart = line.start + line.indent.length + const colon = line.text.indexOf(':', line.indent.length + task[1].length - 1) + const metaStart = line.start + colon + 1 + tasks.push({ + entityId: `task:${line.index}`, + name: task[1].trim(), + meta: task[2].trim(), + lineIndex: line.index, + nameSpan: { start: nameStart, end: nameStart + task[1].trimEnd().length }, + metaSpan: { start: metaStart, end: line.end }, + section: currentSection, + }) + } + } + return { headerLine: headerIndex, tasks, sections, lastContentLine } +} + +export type GanttOp = + | { type: 'gantt.setTaskName'; taskId: string; name: string } + | { type: 'gantt.setTaskMeta'; taskId: string; meta: string } + | { type: 'gantt.addTask'; section?: string; name?: string; meta?: string } + | { type: 'gantt.deleteTask'; taskId: string } + +export interface GanttOpContext { + code: string + lines: LineInfo[] + graph: GanttGraph +} + +export function compileGanttOp( + ctx: GanttOpContext, + op: GanttOp, +): { edits: TextEdit[]; created?: string[] } | null { + const { graph, lines } = ctx + const find = (id: string) => graph.tasks.find((t) => t.entityId === id) ?? null + + switch (op.type) { + case 'gantt.setTaskName': { + const t = find(op.taskId) + if (!t) return null + const name = op.name.trim().replace(/[:#]/g, '') + if (!name) return null + return { edits: [{ start: t.nameSpan.start, end: t.nameSpan.end, text: name }] } + } + case 'gantt.setTaskMeta': { + const t = find(op.taskId) + if (!t) return null + const meta = op.meta.trim().replace(/:/g, '') + if (!meta) return null + return { edits: [{ start: t.metaSpan.start, end: t.metaSpan.end, text: ` ${meta}` }] } + } + case 'gantt.addTask': { + // append after the last task of the requested (or last) section + const inSection = op.section + ? graph.tasks.filter((t) => t.section === op.section) + : graph.tasks + const anchorIndex = inSection.length + ? inSection[inSection.length - 1].lineIndex + : graph.sections.length + ? graph.sections[graph.sections.length - 1].lineIndex + : graph.lastContentLine + const anchor = lines[anchorIndex] + const indent = inSection.length ? anchor.indent : `${anchor.indent} ` + const name = (op.name ?? 'New task').replace(/[:#]/g, '') + const meta = (op.meta ?? '1d').replace(/:/g, '') + return { + edits: [{ start: anchor.end, end: anchor.end, text: `\n${indent}${name} :${meta}` }], + created: [`task:${anchorIndex + 1}`], + } + } + case 'gantt.deleteTask': { + const t = find(op.taskId) + if (!t) return null + const line = lines[t.lineIndex] + if (line.end < ctx.code.length) return { edits: [{ start: line.start, end: line.end + 1, text: '' }] } + return { edits: [{ start: Math.max(0, line.start - 1), end: line.end, text: '' }] } + } + } +} + +export function ganttEntitySpans(graph: GanttGraph, lines: LineInfo[], entityId: string): Span[] { + const t = graph.tasks.find((tk) => tk.entityId === entityId) + if (!t) return [] + const line = lines[t.lineIndex] + return [{ start: line.start + line.indent.length, end: line.end }] +} + +export function ganttEntityAt(graph: GanttGraph, lines: LineInfo[], offset: number): string | null { + for (const t of graph.tasks) { + const line = lines[t.lineIndex] + if (offset >= line.start && offset <= line.end) return t.entityId + } + return null +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..20b6438 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,108 @@ +export * from './types' +export { bindTextPane, type TextPaneAdapter, type TextPaneBinding } from './textpane' +export { DIAGRAM_TYPES, detectDiagramType } from './registry' +export { scanLines, classifyLines, applyEdits, diffText, mapOffset } from './text' +export { + parseFlowStatement, + parseFlowHeader, + SHAPE_DELIMS, + type ShapeId, + type NodeRef, + type EdgeSeg, + type EdgeLine, + type EdgeArrow, + type ChainStmt, + type FlowStmt, +} from './flowchart/parse' +export { + buildFlowGraph, + entitySpans, + entityAtOffset, + type FlowGraph, + type FlowNode, + type FlowEdge, + type FlowSubgraph, +} from './flowchart/graph' +export { compileFlowchartOp, type FlowchartOp, type OpResult } from './flowchart/ops' +export { + parseSequenceStatement, + PARTICIPANT_TYPES, + MESSAGE_OPS, + type ParticipantType, + type MessageOp, + type NotePlacement, + type SeqStmt, + type SeqMessageStmt, + type SeqNoteStmt, + type SeqParticipantStmt, +} from './sequence/parse' +export { + buildSequenceGraph, + sequenceEntitySpans, + sequenceEntityAt, + type SequenceGraph, + type SeqParticipant, + type SeqEvent, +} from './sequence/graph' +export { compileSequenceOp, type SequenceOp, type FragmentKind } from './sequence/ops' +export { parseStateStatement, type StStmt, type StTransitionStmt, type StDeclStmt } from './state/parse' +export { + buildStateGraph, + stateEntitySpans, + stateEntityAt, + type StateGraph, + type StState, + type StTransition, +} from './state/graph' +export { compileStateOp, type StateOp, type StateType } from './state/ops' +export { parseClassStatement, RELATION_OPS, type RelationOp, type ClStmt } from './class/parse' +export { + buildClassGraph, + classEntitySpans, + classEntityAt, + type ClassGraph, + type ClClass, + type ClRelation, + type ClMember, +} from './class/graph' +export { compileClassOp, type ClassOp } from './class/ops' +export { parseErStatement, type ErStmt } from './er/parse' +export { buildErGraph, erEntitySpans, erEntityAt, type ErGraph, type ErEntity, type ErRelation } from './er/graph' +export { compileErOp, cardinalityFromGlyph, type ErOp, type ErCardinality } from './er/ops' +export { + buildPieGraph, + compilePieOp, + pieEntitySpans, + pieEntityAt, + type PieGraph, + type PieSlice, + type PieOp, +} from './pie/index' +export { + MermaidWysiwygEditor, + parse, + type ParseResult, + type ChangeEvent, + type SelectionEvent, + type EditorOptions, + type EditorOp, +} from './editor' +export { + buildGanttGraph, + compileGanttOp, + ganttEntitySpans, + ganttEntityAt, + type GanttGraph, + type GanttTask, + type GanttOp, +} from './gantt/index' +export { + buildLineItemsGraph, + compileLineItemOp, + lineItemSpans, + lineItemAt, + LINE_ITEM_CONFIGS, + type LineItemsGraph, + type LineItem, + type LineItemOp, +} from './lineitems/index' diff --git a/packages/core/src/lineitems/index.ts b/packages/core/src/lineitems/index.ts new file mode 100644 index 0000000..720e29d --- /dev/null +++ b/packages/core/src/lineitems/index.ts @@ -0,0 +1,209 @@ +import type { LineInfo, Span, TextEdit } from '../types' + +/** + * Generic line-item editing for the long tail of chart-style diagram types. + * Every statement line becomes an editable item; a per-type extractor derives + * the text used to find the item's rendered element on the canvas. + */ + +export interface LineItem { + /** entity id: `item:` */ + entityId: string + /** trimmed source line — the unit of editing */ + text: string + /** rendered label used to correlate against SVG text (null = code-only) */ + matchText: string | null + lineIndex: number +} + +export interface LineItemsGraph { + typeId: string + headerLine: number + items: LineItem[] + lastContentLine: number + /** template inserted by `li.addItem` */ + addTemplate: string +} + +const firstQuoted = (t: string): string | null => /"([^"]+)"/.exec(t)?.[1] ?? null +const firstBracketed = (t: string): string | null => { + const m = /\[([^\]]+)\]|\(\(([^)]+)\)\)|\(([^)]+)\)|\{\{([^}]+)\}\}/.exec(t) + return m ? (m[1] ?? m[2] ?? m[3] ?? m[4])?.replace(/^"|"$/g, '') ?? null : null +} +const beforeColon = (t: string): string | null => { + const i = t.indexOf(':') + return i > 0 ? t.slice(0, i).trim() : null +} +const afterKeyword = (t: string, re: RegExp): string | null => { + const m = re.exec(t) + return m ? t.slice(m[0].length).trim() || null : null +} + +interface LineTypeConfig { + match(trimmed: string): string | null + addTemplate: string +} + +export const LINE_ITEM_CONFIGS: Record = { + journey: { + match: (t) => + afterKeyword(t, /^(title|section)\s+/) ?? beforeColon(t), + addTemplate: 'New task: 3: Me', + }, + timeline: { + match: (t) => { + const kw = afterKeyword(t, /^title\s+/) + if (kw) return kw + const parts = t.split(':').map((s) => s.trim()).filter(Boolean) + return parts.length ? parts[parts.length - 1] : null + }, + addTemplate: '2030 : New event', + }, + quadrant: { + match: (t) => (/^(title|x-axis|y-axis|quadrant-\d)/.test(t) ? null : beforeColon(t)), + addTemplate: 'New point: [0.5, 0.5]', + }, + kanban: { + match: (t) => firstBracketed(t) ?? (/^\S/.test(t) ? t : null), + addTemplate: ' [New card]', + }, + mindmap: { + match: (t) => firstBracketed(t) ?? (t.replace(/^::icon.*$/, '') || null), + addTemplate: ' New idea', + }, + treemap: { + match: (t) => firstQuoted(t), + addTemplate: '"New item": 10', + }, + packet: { + match: (t) => firstQuoted(t), + addTemplate: '96-127: "New Field"', + }, + sankey: { + match: (t) => t.split(',')[0]?.trim().replace(/^"|"$/g, '') || null, + addTemplate: 'Source,Target,10', + }, + radar: { + match: (t) => firstQuoted(t), + addTemplate: 'curve new["New"]{50, 50, 50}', + }, + gitgraph: { + match: (t) => + firstQuoted(t) ?? afterKeyword(t, /^(branch|checkout|merge)\s+/), + addTemplate: 'commit', + }, + xychart: { + match: (t) => (/^title\b/.test(t) ? firstQuoted(t) : null), + addTemplate: 'bar [10, 20, 30]', + }, + architecture: { + match: (t) => firstBracketed(t), + addTemplate: 'service new1(server)[New Service]', + }, + requirement: { + match: (t) => /^(requirement|element|functionalRequirement|interface|performanceRequirement|physicalRequirement|designConstraint)\s+(\w+)/.exec(t)?.[2] ?? null, + addTemplate: 'element new_element {', + }, + c4: { + match: (t) => firstQuoted(t), + addTemplate: 'Person(newPerson, "New Person", "Description")', + }, + block: { + match: (t) => firstBracketed(t) ?? (/^[\w]+$/.test(t) ? t : null), + addTemplate: 'newBlock["New Block"]', + }, +} + +export function buildLineItemsGraph( + lines: LineInfo[], + headerIndex: number, + typeId: string, +): LineItemsGraph | null { + const config = LINE_ITEM_CONFIGS[typeId] + if (!config) return null + const items: LineItem[] = [] + let lastContentLine = headerIndex + for (const line of lines) { + if (line.kind !== 'statement' || line.index === headerIndex) { + if (line.index === headerIndex) lastContentLine = line.index + continue + } + lastContentLine = line.index + const t = line.text.trim() + if (t === '}' || t === 'end') continue + items.push({ + entityId: `item:${line.index}`, + text: t, + matchText: config.match(t), + lineIndex: line.index, + }) + } + return { typeId, headerLine: headerIndex, items, lastContentLine, addTemplate: config.addTemplate } +} + +export type LineItemOp = + | { type: 'li.setLine'; itemId: string; text: string } + | { type: 'li.addItem'; text?: string } + | { type: 'li.deleteItem'; itemId: string } + +export interface LiOpContext { + code: string + lines: LineInfo[] + graph: LineItemsGraph +} + +export function compileLineItemOp( + ctx: LiOpContext, + op: LineItemOp, +): { edits: TextEdit[]; created?: string[] } | null { + const { graph, lines } = ctx + const find = (id: string) => graph.items.find((i) => i.entityId === id) ?? null + + switch (op.type) { + case 'li.setLine': { + const item = find(op.itemId) + if (!item) return null + const text = op.text.replace(/\n/g, ' ').trim() + const line = lines[item.lineIndex] + if (!text) { + if (line.end < ctx.code.length) return { edits: [{ start: line.start, end: line.end + 1, text: '' }] } + return { edits: [{ start: Math.max(0, line.start - 1), end: line.end, text: '' }] } + } + return { edits: [{ start: line.start, end: line.end, text: `${line.indent}${text}` }] } + } + case 'li.addItem': { + const anchorIndex = graph.items.length + ? graph.items[graph.items.length - 1].lineIndex + : graph.lastContentLine + const anchor = lines[anchorIndex] + const indent = graph.items.length ? anchor.indent : ' ' + const text = (op.text ?? graph.addTemplate).replace(/\n/g, ' ') + return { + edits: [{ start: anchor.end, end: anchor.end, text: `\n${indent}${text.trim()}` }], + created: [`item:${anchorIndex + 1}`], + } + } + case 'li.deleteItem': { + const item = find(op.itemId) + if (!item) return null + const line = lines[item.lineIndex] + if (line.end < ctx.code.length) return { edits: [{ start: line.start, end: line.end + 1, text: '' }] } + return { edits: [{ start: Math.max(0, line.start - 1), end: line.end, text: '' }] } + } + } +} + +export function lineItemSpans(graph: LineItemsGraph, lines: LineInfo[], entityId: string): Span[] { + const item = graph.items.find((i) => i.entityId === entityId) + if (!item) return [] + const line = lines[item.lineIndex] + return [{ start: line.start + line.indent.length, end: line.end }] +} + +export function lineItemAt(graph: LineItemsGraph, lines: LineInfo[], offset: number): string | null { + for (const item of graph.items) { + const line = lines[item.lineIndex] + if (offset >= line.start && offset <= line.end) return item.entityId + } + return null +} diff --git a/packages/core/src/pie/index.ts b/packages/core/src/pie/index.ts new file mode 100644 index 0000000..6b0c179 --- /dev/null +++ b/packages/core/src/pie/index.ts @@ -0,0 +1,113 @@ +import type { LineInfo, Span, TextEdit } from '../types' + +export interface PieSlice { + /** entity id: `slice:` */ + entityId: string + label: string + value: number + lineIndex: number + labelSpan: Span + valueSpan: Span +} + +export interface PieGraph { + headerLine: number + showData: boolean + slices: PieSlice[] + lastContentLine: number +} + +const SLICE_RE = /^"([^"]*)"\s*:\s*([\d.]+)\s*$/ + +export function buildPieGraph(lines: LineInfo[], headerIndex: number): PieGraph { + const slices: PieSlice[] = [] + let lastContentLine = headerIndex + for (const line of lines) { + if (line.kind !== 'statement' || line.index === headerIndex) { + if (line.index === headerIndex) lastContentLine = line.index + continue + } + lastContentLine = line.index + const m = SLICE_RE.exec(line.text.trim()) + if (!m) continue + const labelStart = line.start + line.text.indexOf('"') + 1 + const valueStart = line.start + line.text.lastIndexOf(m[2]) + slices.push({ + entityId: `slice:${line.index}`, + label: m[1], + value: Number(m[2]), + lineIndex: line.index, + labelSpan: { start: labelStart, end: labelStart + m[1].length }, + valueSpan: { start: valueStart, end: valueStart + m[2].length }, + }) + } + return { + headerLine: headerIndex, + showData: /\bshowData\b/.test(lines[headerIndex]?.text ?? ''), + slices, + lastContentLine, + } +} + +export type PieOp = + | { type: 'pie.setValue'; sliceId: string; value: number } + | { type: 'pie.setLabel'; sliceId: string; label: string } + | { type: 'pie.addSlice'; label?: string; value?: number } + | { type: 'pie.deleteSlice'; sliceId: string } + +export interface PieOpContext { + code: string + lines: LineInfo[] + graph: PieGraph +} + +export function compilePieOp(ctx: PieOpContext, op: PieOp): { edits: TextEdit[]; created?: string[] } | null { + const { graph, lines } = ctx + const find = (id: string) => graph.slices.find((s) => s.entityId === id) ?? null + + switch (op.type) { + case 'pie.setValue': { + const s = find(op.sliceId) + if (!s || !Number.isFinite(op.value) || op.value < 0) return null + return { edits: [{ start: s.valueSpan.start, end: s.valueSpan.end, text: String(op.value) }] } + } + case 'pie.setLabel': { + const s = find(op.sliceId) + if (!s) return null + return { edits: [{ start: s.labelSpan.start, end: s.labelSpan.end, text: op.label.replace(/"/g, "'") }] } + } + case 'pie.addSlice': { + const anchorIndex = graph.slices.length ? graph.slices[graph.slices.length - 1].lineIndex : graph.lastContentLine + const anchor = lines[anchorIndex] + const indent = graph.slices.length ? anchor.indent : ' ' + const label = (op.label ?? 'New slice').replace(/"/g, "'") + const text = `${indent}"${label}" : ${op.value ?? 10}` + return { + edits: [{ start: anchor.end, end: anchor.end, text: `\n${text}` }], + created: [`slice:${anchorIndex + 1}`], + } + } + case 'pie.deleteSlice': { + const s = find(op.sliceId) + if (!s) return null + const line = lines[s.lineIndex] + if (line.end < ctx.code.length) return { edits: [{ start: line.start, end: line.end + 1, text: '' }] } + return { edits: [{ start: Math.max(0, line.start - 1), end: line.end, text: '' }] } + } + } +} + +export function pieEntitySpans(graph: PieGraph, lines: LineInfo[], entityId: string): Span[] { + const s = graph.slices.find((sl) => sl.entityId === entityId) + if (!s) return [] + const line = lines[s.lineIndex] + return [{ start: line.start + line.indent.length, end: line.end }] +} + +export function pieEntityAt(graph: PieGraph, lines: LineInfo[], offset: number): string | null { + for (const s of graph.slices) { + const line = lines[s.lineIndex] + if (offset >= line.start && offset <= line.end) return s.entityId + } + return null +} diff --git a/packages/core/src/registry.ts b/packages/core/src/registry.ts new file mode 100644 index 0000000..4e6d711 --- /dev/null +++ b/packages/core/src/registry.ts @@ -0,0 +1,44 @@ +import type { DiagramTypeInfo } from './types' + +const DOCS = 'https://mermaid.js.org' + +/** + * Every diagram type documented at https://mermaid.js.org/intro/. + * `capability` describes what this library can do beyond rendering: + * - 'edit' → full structural WYSIWYG (semantic graph + ops) + * - 'render' → renders through mermaid, generic CST (statement selection, + * comments/frontmatter handling, lossless round-trip) only. + */ +export const DIAGRAM_TYPES: DiagramTypeInfo[] = [ + { id: 'flowchart', name: 'Flowchart', match: /^(flowchart|graph)\b/, capability: 'edit', docsUrl: `${DOCS}/syntax/flowchart.html` }, + { id: 'sequence', name: 'Sequence Diagram', match: /^sequenceDiagram\b/, capability: 'edit', docsUrl: `${DOCS}/syntax/sequenceDiagram.html` }, + { id: 'class', name: 'Class Diagram', match: /^classDiagram(-v2)?\b/, capability: 'edit', docsUrl: `${DOCS}/syntax/classDiagram.html` }, + { id: 'state', name: 'State Diagram', match: /^stateDiagram(-v2)?\b/, capability: 'edit', docsUrl: `${DOCS}/syntax/stateDiagram.html` }, + { id: 'er', name: 'Entity Relationship', match: /^erDiagram\b/, capability: 'edit', docsUrl: `${DOCS}/syntax/entityRelationshipDiagram.html` }, + { id: 'journey', name: 'User Journey', match: /^journey\b/, capability: 'edit', docsUrl: `${DOCS}/syntax/userJourney.html` }, + { id: 'gantt', name: 'Gantt', match: /^gantt\b/, capability: 'edit', docsUrl: `${DOCS}/syntax/gantt.html` }, + { id: 'pie', name: 'Pie Chart', match: /^pie\b/, capability: 'edit', docsUrl: `${DOCS}/syntax/pie.html` }, + { id: 'quadrant', name: 'Quadrant Chart', match: /^quadrantChart\b/, capability: 'edit', docsUrl: `${DOCS}/syntax/quadrantChart.html` }, + { id: 'requirement', name: 'Requirement Diagram', match: /^requirement(Diagram)?\b/, capability: 'edit', docsUrl: `${DOCS}/syntax/requirementDiagram.html` }, + { id: 'gitgraph', name: 'Gitgraph', match: /^gitGraph\b/, capability: 'edit', docsUrl: `${DOCS}/syntax/gitgraph.html` }, + { id: 'c4', name: 'C4 Diagram', match: /^C4(Context|Container|Component|Dynamic|Deployment)\b/, capability: 'edit', docsUrl: `${DOCS}/syntax/c4.html` }, + { id: 'mindmap', name: 'Mindmap', match: /^mindmap\b/, capability: 'edit', docsUrl: `${DOCS}/syntax/mindmap.html` }, + { id: 'timeline', name: 'Timeline', match: /^timeline\b/, capability: 'edit', docsUrl: `${DOCS}/syntax/timeline.html` }, + { id: 'zenuml', name: 'ZenUML', match: /^zenuml\b/, capability: 'render', docsUrl: `${DOCS}/syntax/zenuml.html`, requiresPlugin: '@mermaid-js/mermaid-zenuml' }, + { id: 'sankey', name: 'Sankey', match: /^sankey(-beta)?\b/, capability: 'edit', docsUrl: `${DOCS}/syntax/sankey.html` }, + { id: 'xychart', name: 'XY Chart', match: /^xychart(-beta)?\b/, capability: 'edit', docsUrl: `${DOCS}/syntax/xyChart.html` }, + { id: 'block', name: 'Block Diagram', match: /^block(-beta)?\b/, capability: 'edit', docsUrl: `${DOCS}/syntax/block.html` }, + { id: 'packet', name: 'Packet', match: /^packet(-beta)?\b/, capability: 'edit', docsUrl: `${DOCS}/syntax/packet.html` }, + { id: 'kanban', name: 'Kanban', match: /^kanban\b/, capability: 'edit', docsUrl: `${DOCS}/syntax/kanban.html` }, + { id: 'architecture', name: 'Architecture', match: /^architecture(-beta)?\b/, capability: 'edit', docsUrl: `${DOCS}/syntax/architecture.html` }, + { id: 'radar', name: 'Radar', match: /^radar(-beta)?\b/, capability: 'edit', docsUrl: `${DOCS}/syntax/radar.html` }, + { id: 'treemap', name: 'Treemap', match: /^treemap(-beta)?\b/, capability: 'edit', docsUrl: `${DOCS}/syntax/treemap.html` }, +] + +export function detectDiagramType(headerLine: string): DiagramTypeInfo | null { + const trimmed = headerLine.trim() + for (const t of DIAGRAM_TYPES) { + if (t.match.test(trimmed)) return t + } + return null +} diff --git a/packages/core/src/sequence/graph.ts b/packages/core/src/sequence/graph.ts new file mode 100644 index 0000000..b82e758 --- /dev/null +++ b/packages/core/src/sequence/graph.ts @@ -0,0 +1,165 @@ +import type { LineInfo, Span } from '../types' +import { + parseSequenceStatement, + type ParticipantType, + type SeqMessageStmt, + type SeqNoteStmt, + type SeqParticipantStmt, + type SeqStmt, +} from './parse' + +export interface SeqParticipant { + /** entity id: `participant:` */ + entityId: string + id: string + /** display label (alias if declared) */ + label: string + ptype: ParticipantType + /** declaration statement, if explicitly declared */ + decl: SeqParticipantStmt | null + /** left-to-right order as mermaid will render it (first mention) */ + order: number +} + +export type SeqEvent = + | { kind: 'message'; entityId: string; lineIndex: number; stmt: SeqMessageStmt } + | { kind: 'note'; entityId: string; lineIndex: number; stmt: SeqNoteStmt } + +export interface SequenceGraph { + headerLine: number + participants: SeqParticipant[] + participantById: Map + /** messages and notes, in document order (== render order) */ + events: SeqEvent[] + autonumber: { enabled: boolean; lineIndex: number | null } + statements: Map + lastContentLine: number + /** line index of the last participant/actor declaration (for insertion), or headerLine */ + lastDeclLine: number +} + +export function buildSequenceGraph(lines: LineInfo[], headerIndex: number): SequenceGraph { + const participants = new Map() + const events: SeqEvent[] = [] + const statements = new Map() + let autonumber: SequenceGraph['autonumber'] = { enabled: false, lineIndex: null } + let lastContentLine = headerIndex + let lastDeclLine = headerIndex + + const touch = (id: string): SeqParticipant => { + let p = participants.get(id) + if (!p) { + p = { + entityId: `participant:${id}`, + id, + label: id, + ptype: 'participant', + decl: null, + order: participants.size, + } + participants.set(id, p) + } + return p + } + + for (const line of lines) { + if (line.kind !== 'statement' || line.index === headerIndex) { + if (line.index === headerIndex) lastContentLine = line.index + continue + } + const stmt = parseSequenceStatement(line) + statements.set(line.index, stmt) + lastContentLine = line.index + + switch (stmt.kind) { + case 'participant': { + const p = touch(stmt.id) + p.decl = stmt + p.ptype = stmt.ptype + if (stmt.alias) p.label = stmt.alias + lastDeclLine = stmt.lineIndex + break + } + case 'message': { + touch(stmt.source) + touch(stmt.target) + events.push({ kind: 'message', entityId: `event:${stmt.lineIndex}`, lineIndex: stmt.lineIndex, stmt }) + break + } + case 'note': { + for (const t of stmt.targets) touch(t) + events.push({ kind: 'note', entityId: `event:${stmt.lineIndex}`, lineIndex: stmt.lineIndex, stmt }) + break + } + case 'autonumber': + autonumber = { enabled: true, lineIndex: stmt.lineIndex } + break + default: + break + } + } + + return { + headerLine: headerIndex, + participants: [...participants.values()], + participantById: participants, + events, + autonumber, + statements, + lastContentLine, + lastDeclLine, + } +} + +/** Spans belonging to an entity, for code highlighting. */ +export function sequenceEntitySpans(graph: SequenceGraph, lines: LineInfo[], entityId: string): Span[] { + if (entityId.startsWith('participant:')) { + const p = graph.participantById.get(entityId.slice(12)) + if (!p) return [] + const spans: Span[] = [] + if (p.decl) { + const line = lines[p.decl.lineIndex] + spans.push({ start: line.start + line.indent.length, end: line.end }) + } + for (const ev of graph.events) { + if (ev.kind === 'message') { + if (ev.stmt.source === p.id) spans.push(ev.stmt.sourceSpan) + if (ev.stmt.target === p.id) spans.push(ev.stmt.targetSpan) + } else if (ev.stmt.targets.includes(p.id)) { + spans.push(ev.stmt.targetsSpan) + } + } + return spans + } + if (entityId.startsWith('event:')) { + const ev = graph.events.find((e) => e.entityId === entityId) + if (!ev) return [] + const line = lines[ev.lineIndex] + return [{ start: line.start + line.indent.length, end: line.end }] + } + return [] +} + +export function sequenceEntityAt(graph: SequenceGraph, lines: LineInfo[], offset: number): string | null { + for (const ev of graph.events) { + const line = lines[ev.lineIndex] + if (offset >= line.start && offset <= line.end) { + if (ev.kind === 'message') { + if (offset >= ev.stmt.sourceSpan.start && offset <= ev.stmt.sourceSpan.end) { + return `participant:${ev.stmt.source}` + } + if (offset >= ev.stmt.targetSpan.start && offset <= ev.stmt.targetSpan.end) { + return `participant:${ev.stmt.target}` + } + } + return ev.entityId + } + } + for (const p of graph.participants) { + if (p.decl) { + const line = lines[p.decl.lineIndex] + if (offset >= line.start && offset <= line.end) return p.entityId + } + } + return null +} diff --git a/packages/core/src/sequence/ops.ts b/packages/core/src/sequence/ops.ts new file mode 100644 index 0000000..4c599c2 --- /dev/null +++ b/packages/core/src/sequence/ops.ts @@ -0,0 +1,286 @@ +import type { LineInfo, TextEdit } from '../types' +import type { SequenceGraph, SeqEvent } from './graph' +import { type MessageOp, type NotePlacement, type ParticipantType } from './parse' + +export type FragmentKind = 'loop' | 'alt' | 'opt' | 'par' | 'critical' | 'break' | 'rect' + +const FRAGMENT_HEADERS: Record = { + loop: 'loop Repeat', + alt: 'alt Condition', + opt: 'opt Optional', + par: 'par Parallel', + critical: 'critical Required', + break: 'break Break', + rect: 'rect rgb(107, 114, 148, 0.25)', +} + +export type SequenceOp = + | { type: 'seq.addParticipant'; ptype?: ParticipantType; name?: string } + | { type: 'seq.renameParticipant'; id: string; name: string } + | { type: 'seq.setParticipantType'; id: string; ptype: ParticipantType } + | { type: 'seq.deleteParticipant'; id: string } + | { + type: 'seq.addMessage' + source: string + target: string + text?: string + op?: MessageOp + /** insert after this event (entity id); omit = append after last event */ + afterEvent?: string | null + } + | { type: 'seq.addNote'; participant: string; placement: NotePlacement; text?: string; afterEvent?: string | null } + | { type: 'seq.setMessageOp'; eventId: string; op: MessageOp } + | { type: 'seq.reverseMessage'; eventId: string } + | { type: 'seq.setEventText'; eventId: string; text: string } + | { type: 'seq.deleteEvent'; eventId: string } + | { type: 'seq.moveEvent'; eventId: string; afterEvent: string | null } + | { type: 'seq.wrapInFragment'; eventId: string; kind: FragmentKind } + | { type: 'seq.toggleAutonumber' } + +export interface SeqOpContext { + code: string + lines: LineInfo[] + graph: SequenceGraph +} + +export interface SeqOpResult { + edits: TextEdit[] + created?: string[] +} + +function findEvent(graph: SequenceGraph, eventId: string): SeqEvent | null { + return graph.events.find((e) => e.entityId === eventId) ?? null +} + +function bodyIndent(ctx: SeqOpContext): string { + for (const line of ctx.lines) { + if (line.kind === 'statement' && line.index !== ctx.graph.headerLine && line.text.trim() !== '') { + return line.indent + } + } + return ' ' +} + +function insertAfterLine(ctx: SeqOpContext, lineIndex: number, texts: string[]): TextEdit { + const line = ctx.lines[lineIndex] + return { start: line.end, end: line.end, text: texts.map((t) => `\n${t}`).join('') } +} + +function deleteLine(ctx: SeqOpContext, lineIndex: number): TextEdit { + const line = ctx.lines[lineIndex] + if (line.end < ctx.code.length) return { start: line.start, end: line.end + 1, text: '' } + if (line.start > 0) return { start: line.start - 1, end: line.end, text: '' } + return { start: line.start, end: line.end, text: '' } +} + +/** Line to insert an event after, honoring `afterEvent` (null/undefined = append). */ +function eventAnchor(ctx: SeqOpContext, afterEvent: string | null | undefined): { lineIndex: number; indent: string } { + const { graph } = ctx + if (afterEvent) { + const ev = findEvent(graph, afterEvent) + if (ev) return { lineIndex: ev.lineIndex, indent: ctx.lines[ev.lineIndex].indent } + } + if (afterEvent === null && graph.events.length > 0) { + // insert before the FIRST event + const first = graph.events[0] + return { lineIndex: first.lineIndex - 1, indent: ctx.lines[first.lineIndex].indent } + } + const last = graph.events[graph.events.length - 1] + if (last) return { lineIndex: last.lineIndex, indent: ctx.lines[last.lineIndex].indent } + return { lineIndex: graph.lastContentLine, indent: bodyIndent(ctx) } +} + +function attrsForType(ptype: ParticipantType): { keyword: 'participant' | 'actor'; attrs: string | null } { + if (ptype === 'actor') return { keyword: 'actor', attrs: null } + if (ptype === 'participant') return { keyword: 'participant', attrs: null } + return { keyword: 'participant', attrs: `@{ "type" : "${ptype}" }` } +} + +function generateParticipantName(graph: SequenceGraph, preferred?: string): string { + const existing = new Set(graph.participants.map((p) => p.id)) + if (preferred && !existing.has(preferred)) return preferred + let i = 1 + while (existing.has(`P${i}`)) i++ + return `P${i}` +} + +export function compileSequenceOp(ctx: SeqOpContext, op: SequenceOp): SeqOpResult | null { + const { graph, lines } = ctx + + switch (op.type) { + case 'seq.addParticipant': { + const name = generateParticipantName(graph, op.name) + const { keyword, attrs } = attrsForType(op.ptype ?? 'participant') + const text = `${bodyIndent(ctx)}${keyword} ${name}${attrs ? attrs : ''}` + return { + edits: [insertAfterLine(ctx, graph.lastDeclLine, [text])], + created: [`participant:${name}`], + } + } + + case 'seq.renameParticipant': { + const p = graph.participantById.get(op.id) + if (!p) return null + const name = op.name.trim().replace(/[:;]/g, '') + if (!name) return null + if (p.decl?.alias && p.decl.aliasSpan) { + return { edits: [{ start: p.decl.aliasSpan.start, end: p.decl.aliasSpan.end, text: name }] } + } + // rewrite the id at every reference site + const edits: TextEdit[] = [] + if (p.decl) edits.push({ start: p.decl.idSpan.start, end: p.decl.idSpan.end, text: name }) + for (const ev of graph.events) { + if (ev.kind === 'message') { + if (ev.stmt.source === op.id) edits.push({ ...ev.stmt.sourceSpan, text: name } as TextEdit) + if (ev.stmt.target === op.id) edits.push({ ...ev.stmt.targetSpan, text: name } as TextEdit) + } else if (ev.stmt.targets.includes(op.id)) { + const newList = ev.stmt.targets.map((t) => (t === op.id ? name : t)).join(',') + edits.push({ start: ev.stmt.targetsSpan.start, end: ev.stmt.targetsSpan.end, text: newList }) + } + } + for (const [lineIndex, stmt] of graph.statements) { + if (stmt.kind === 'activation' && stmt.parts[1] === op.id) { + const line = lines[lineIndex] + const idStart = line.start + line.text.lastIndexOf(stmt.parts[1]) + edits.push({ start: idStart, end: idStart + stmt.parts[1].length, text: name }) + } + } + return { edits: edits.map((e) => ({ start: e.start, end: e.end, text: e.text })) } + } + + case 'seq.setParticipantType': { + const p = graph.participantById.get(op.id) + if (!p) return null + const { keyword, attrs } = attrsForType(op.ptype) + if (p.decl) { + const edits: TextEdit[] = [ + { start: p.decl.keywordSpan.start, end: p.decl.keywordSpan.end, text: keyword }, + ] + if (p.decl.attrsSpan) { + // replace or remove existing @{...} (including the space before it) + const start = p.decl.attrsSpan.start + const prevChar = ctx.code[start - 1] + const removeFrom = prevChar === ' ' && !attrs ? start - 1 : start + edits.push({ start: removeFrom, end: p.decl.attrsSpan.end, text: attrs ? attrs : '' }) + } else if (attrs) { + const at = p.decl.aliasSpan ? p.decl.aliasSpan.end : p.decl.idSpan.end + edits.push({ start: at, end: at, text: attrs }) + } + return { edits } + } + // implicit participant: create a declaration after the last decl / header + const text = `${bodyIndent(ctx)}${keyword} ${op.id}${attrs ? attrs : ''}` + return { edits: [insertAfterLine(ctx, graph.lastDeclLine, [text])] } + } + + case 'seq.deleteParticipant': { + const p = graph.participantById.get(op.id) + if (!p) return null + const doomed = new Set() + if (p.decl) doomed.add(p.decl.lineIndex) + const edits: TextEdit[] = [] + for (const ev of graph.events) { + if (ev.kind === 'message') { + if (ev.stmt.source === op.id || ev.stmt.target === op.id) doomed.add(ev.lineIndex) + } else if (ev.stmt.targets.includes(op.id)) { + if (ev.stmt.targets.length === 1) { + doomed.add(ev.lineIndex) + } else { + const newList = ev.stmt.targets.filter((t) => t !== op.id).join(',') + edits.push({ start: ev.stmt.targetsSpan.start, end: ev.stmt.targetsSpan.end, text: newList }) + } + } + } + for (const [lineIndex, stmt] of graph.statements) { + if (stmt.kind === 'activation' && stmt.parts[1] === op.id) doomed.add(lineIndex) + } + for (const lineIndex of doomed) edits.push(deleteLine(ctx, lineIndex)) + return { edits } + } + + case 'seq.addMessage': { + const anchor = eventAnchor(ctx, op.afterEvent) + const text = `${anchor.indent}${op.source}${op.op ?? '->>'}${op.target}: ${op.text ?? 'message'}` + const newLineIndex = anchor.lineIndex + 1 + return { + edits: [insertAfterLine(ctx, anchor.lineIndex, [text])], + created: [`event:${newLineIndex}`], + } + } + + case 'seq.addNote': { + const anchor = eventAnchor(ctx, op.afterEvent) + const text = `${anchor.indent}Note ${op.placement} ${op.participant}: ${op.text ?? 'note'}` + return { + edits: [insertAfterLine(ctx, anchor.lineIndex, [text])], + created: [`event:${anchor.lineIndex + 1}`], + } + } + + case 'seq.setMessageOp': { + const ev = findEvent(graph, op.eventId) + if (!ev || ev.kind !== 'message') return null + return { edits: [{ start: ev.stmt.opSpan.start, end: ev.stmt.opSpan.end, text: op.op }] } + } + + case 'seq.reverseMessage': { + const ev = findEvent(graph, op.eventId) + if (!ev || ev.kind !== 'message') return null + return { + edits: [ + { start: ev.stmt.sourceSpan.start, end: ev.stmt.sourceSpan.end, text: ev.stmt.target }, + { start: ev.stmt.targetSpan.start, end: ev.stmt.targetSpan.end, text: ev.stmt.source }, + ], + } + } + + case 'seq.setEventText': { + const ev = findEvent(graph, op.eventId) + if (!ev) return null + const text = ` ${op.text.trim().replace(/[;]/g, '')}` + const span = ev.kind === 'message' ? ev.stmt.textSpan : ev.stmt.textSpan + return { edits: [{ start: span.start, end: span.end, text }] } + } + + case 'seq.deleteEvent': { + const ev = findEvent(graph, op.eventId) + if (!ev) return null + return { edits: [deleteLine(ctx, ev.lineIndex)] } + } + + case 'seq.moveEvent': { + const ev = findEvent(graph, op.eventId) + if (!ev || op.afterEvent === op.eventId) return null + const anchor = eventAnchor(ctx, op.afterEvent) + // dropping right above/below its current position is a no-op + if (anchor.lineIndex === ev.lineIndex || anchor.lineIndex === ev.lineIndex - 1) return { edits: [] } + const line = ctx.lines[ev.lineIndex] + const moved = `${anchor.indent}${line.text.trim()}` + const newLineIndex = anchor.lineIndex < ev.lineIndex ? anchor.lineIndex + 1 : anchor.lineIndex + return { + edits: [deleteLine(ctx, ev.lineIndex), insertAfterLine(ctx, anchor.lineIndex, [moved])], + created: [`event:${newLineIndex}`], + } + } + + case 'seq.wrapInFragment': { + const ev = findEvent(graph, op.eventId) + if (!ev) return null + const line = lines[ev.lineIndex] + const indent = line.indent + const body = line.text.trim() + const text = `${indent}${FRAGMENT_HEADERS[op.kind]}\n${indent} ${body}\n${indent}end` + return { + edits: [{ start: line.start, end: line.end, text }], + created: [`event:${ev.lineIndex + 1}`], + } + } + + case 'seq.toggleAutonumber': { + if (graph.autonumber.enabled && graph.autonumber.lineIndex !== null) { + return { edits: [deleteLine(ctx, graph.autonumber.lineIndex)] } + } + return { edits: [insertAfterLine(ctx, graph.headerLine, [`${bodyIndent(ctx)}autonumber`])] } + } + } +} diff --git a/packages/core/src/sequence/parse.ts b/packages/core/src/sequence/parse.ts new file mode 100644 index 0000000..5d6a75c --- /dev/null +++ b/packages/core/src/sequence/parse.ts @@ -0,0 +1,198 @@ +import type { LineInfo, Span } from '../types' + +/** All participant types supported by mermaid (v11.7+ for the extended set). */ +export type ParticipantType = + | 'participant' + | 'actor' + | 'boundary' + | 'control' + | 'entity' + | 'database' + | 'collections' + | 'queue' + +export const PARTICIPANT_TYPES: ParticipantType[] = [ + 'participant', + 'actor', + 'boundary', + 'control', + 'entity', + 'database', + 'collections', + 'queue', +] + +/** The eight mermaid sequence message operators. */ +export type MessageOp = '->' | '-->' | '->>' | '-->>' | '-x' | '--x' | '-)' | '--)' + +export const MESSAGE_OPS: Array<{ op: MessageOp; label: string }> = [ + { op: '->', label: 'solid line' }, + { op: '->>', label: 'solid arrow' }, + { op: '-->', label: 'dotted line' }, + { op: '-->>', label: 'dotted arrow' }, + { op: '-x', label: 'solid cross' }, + { op: '--x', label: 'dotted cross' }, + { op: '-)', label: 'solid async' }, + { op: '--)', label: 'dotted async' }, +] + +export type NotePlacement = 'over' | 'left of' | 'right of' + +export interface SeqParticipantStmt { + kind: 'participant' + lineIndex: number + keyword: 'participant' | 'actor' + keywordSpan: Span + id: string + idSpan: Span + alias: string | null + aliasSpan: Span | null + /** raw `@{ ... }` blob, if present */ + attrsRaw: string | null + attrsSpan: Span | null + ptype: ParticipantType +} + +export interface SeqMessageStmt { + kind: 'message' + lineIndex: number + source: string + sourceSpan: Span + op: MessageOp + opSpan: Span + /** `+` / `-` activation shorthand after the operator */ + activation: string | null + target: string + targetSpan: Span + text: string + textSpan: Span +} + +export interface SeqNoteStmt { + kind: 'note' + lineIndex: number + placement: NotePlacement + targets: string[] + targetsSpan: Span + text: string + textSpan: Span +} + +export interface SeqSimpleStmt { + kind: 'autonumber' | 'blockOpen' | 'blockMid' | 'end' | 'activation' | 'other' | 'unknown' + lineIndex: number + /** for 'activation': [keyword, id]; for blocks: [keyword] */ + parts: string[] +} + +export type SeqStmt = SeqParticipantStmt | SeqMessageStmt | SeqNoteStmt | SeqSimpleStmt + +const MESSAGE_RE = /^(.+?)(--?(?:>>|>|[x)]))([+-])?([^:+-][^:]*):(.*)$/ +const PARTICIPANT_RE = /^(participant|actor)\s+(.+?)(?:\s+as\s+(.+?))?\s*(@\{.*\})?\s*;?\s*$/ +const NOTE_RE = /^[Nn]ote\s+(over|left of|right of)\s+([^:]+):(.*)$/ +const BLOCK_OPEN_RE = /^(alt|opt|loop|par|critical|break|rect|box)\b/ +const BLOCK_MID_RE = /^(else|and|option)\b/ +const ACTIVATION_RE = /^(activate|deactivate)\s+(.+?)\s*;?\s*$/ +const OTHER_RE = /^(title|accTitle|accDescr|links?|properties|create|destroy)\b/ + +function span(line: LineInfo, relStart: number, relEnd: number): Span { + return { start: line.start + relStart, end: line.start + relEnd } +} + +function typeFromAttrs(attrsRaw: string | null): ParticipantType | null { + if (!attrsRaw) return null + const m = /["']?type["']?\s*:\s*["'](\w+)["']/.exec(attrsRaw) + if (m && (PARTICIPANT_TYPES as string[]).includes(m[1])) return m[1] as ParticipantType + return null +} + +export function parseSequenceStatement(line: LineInfo): SeqStmt { + const raw = line.text + const t = raw.trim() + const lineIndex = line.index + const indentLen = raw.length - raw.trimStart().length + + if (t === 'end') return { kind: 'end', lineIndex, parts: [] } + if (/^autonumber\b/.test(t)) return { kind: 'autonumber', lineIndex, parts: [] } + + const block = BLOCK_OPEN_RE.exec(t) + if (block) return { kind: 'blockOpen', lineIndex, parts: [block[1]] } + const mid = BLOCK_MID_RE.exec(t) + if (mid) return { kind: 'blockMid', lineIndex, parts: [mid[1]] } + + const act = ACTIVATION_RE.exec(t) + if (act) return { kind: 'activation', lineIndex, parts: [act[1], act[2]] } + + const part = PARTICIPANT_RE.exec(t) + if (part) { + const keyword = part[1] as 'participant' | 'actor' + const id = part[2] + const alias = part[3] ?? null + const attrsRaw = part[4] ?? null + const kwStart = indentLen + const idStart = raw.indexOf(id, kwStart + keyword.length) + const aliasStart = alias ? raw.indexOf(alias, idStart + id.length) : -1 + const attrsStart = attrsRaw ? raw.indexOf(attrsRaw, aliasStart === -1 ? idStart + id.length : aliasStart) : -1 + return { + kind: 'participant', + lineIndex, + keyword, + keywordSpan: span(line, kwStart, kwStart + keyword.length), + id, + idSpan: span(line, idStart, idStart + id.length), + alias, + aliasSpan: alias ? span(line, aliasStart, aliasStart + alias.length) : null, + attrsRaw, + attrsSpan: attrsRaw ? span(line, attrsStart, attrsStart + attrsRaw.length) : null, + ptype: typeFromAttrs(attrsRaw) ?? (keyword === 'actor' ? 'actor' : 'participant'), + } + } + + const note = NOTE_RE.exec(t) + if (note) { + const placement = note[1] as NotePlacement + const targetsRaw = note[2] + const targetsStart = indentLen + t.indexOf(targetsRaw, 5) + const colon = raw.indexOf(':', targetsStart + targetsRaw.length - 1) + const textStart = colon + 1 + return { + kind: 'note', + lineIndex, + placement, + targets: targetsRaw.split(',').map((s) => s.trim()).filter(Boolean), + targetsSpan: span(line, targetsStart, targetsStart + targetsRaw.trimEnd().length), + text: raw.slice(textStart).trim(), + textSpan: span(line, textStart, raw.length), + } + } + + const msg = MESSAGE_RE.exec(t) + if (msg) { + const [, srcRaw, op, activation, tgtRaw] = msg + const source = srcRaw.trim() + const target = tgtRaw.trim() + if (source && target) { + const srcStart = indentLen + srcRaw.indexOf(source) + const opStart = indentLen + srcRaw.length + const tgtRel = indentLen + srcRaw.length + op.length + (activation ? 1 : 0) + const tgtStart = tgtRel + tgtRaw.indexOf(target) + const colon = raw.indexOf(':', tgtStart + target.length) + return { + kind: 'message', + lineIndex, + source, + sourceSpan: span(line, srcStart, srcStart + source.length), + op: op as MessageOp, + opSpan: span(line, opStart, opStart + op.length), + activation: activation ?? null, + target, + targetSpan: span(line, tgtStart, tgtStart + target.length), + text: raw.slice(colon + 1).trim(), + textSpan: span(line, colon + 1, raw.length), + } + } + } + + if (OTHER_RE.test(t)) return { kind: 'other', lineIndex, parts: [] } + return { kind: 'unknown', lineIndex, parts: [] } +} diff --git a/packages/core/src/state/graph.ts b/packages/core/src/state/graph.ts new file mode 100644 index 0000000..9ff61fe --- /dev/null +++ b/packages/core/src/state/graph.ts @@ -0,0 +1,203 @@ +import type { LineInfo, Span } from '../types' +import { parseStateStatement, type StDeclStmt, type StStmt, type StTransitionStmt } from './parse' + +export interface StState { + /** entity id: `state:` (the `[*]` pseudo-states are excluded) */ + entityId: string + id: string + label: string + /** declaration that owns the label, if any */ + decl: StDeclStmt | null + /** every declaration line for this state */ + declLines: number[] + /** line indexes of transitions referencing this state */ + refLines: number[] + /** first line referencing the state (for label insertion) */ + firstRefLine: number + parent: string | null + isComposite: boolean + /** `<>` / `<>` / `<>` stereotype, if declared */ + stereotype: string | null + /** line of the `state X …` declaration carrying the stereotype, if any */ + typeDeclLine: number | null +} + +export interface StTransition { + /** entity id: `trans:->#` */ + entityId: string + source: string + target: string + lineIndex: number + stmt: StTransitionStmt + label: string | null + /** document order (== mermaid render order) */ + order: number +} + +export interface StateGraph { + headerLine: number + states: StState[] + stateById: Map + transitions: StTransition[] + statements: Map + direction: { value: string; lineIndex: number } | null + lastContentLine: number +} + +export function buildStateGraph(lines: LineInfo[], headerIndex: number): StateGraph { + const states = new Map() + const transitions: StTransition[] = [] + const statements = new Map() + const blockStack: string[] = [] + const occurrence = new Map() + let direction: StateGraph['direction'] = null + let lastContentLine = headerIndex + let inNoteBlock = false + + const touch = (id: string, lineIndex: number): StState | null => { + if (id === '[*]') return null + let s = states.get(id) + if (!s) { + s = { + entityId: `state:${id}`, + id, + label: id, + decl: null, + declLines: [], + refLines: [], + firstRefLine: lineIndex, + parent: blockStack.length ? blockStack[blockStack.length - 1] : null, + isComposite: false, + stereotype: null, + typeDeclLine: null, + } + states.set(id, s) + } + return s + } + + for (const line of lines) { + if (line.kind !== 'statement' || line.index === headerIndex) { + if (line.index === headerIndex) lastContentLine = line.index + continue + } + if (inNoteBlock) { + const maybeEnd = parseStateStatement(line) + if (maybeEnd.kind === 'noteBlockEnd') inNoteBlock = false + statements.set(line.index, { kind: 'unknown', lineIndex: line.index, parts: [] }) + lastContentLine = line.index + continue + } + const stmt = parseStateStatement(line) + statements.set(line.index, stmt) + lastContentLine = line.index + + switch (stmt.kind) { + case 'noteBlockOpen': + inNoteBlock = true + break + case 'decl': { + const s = touch(stmt.id, stmt.lineIndex) + if (s) { + s.declLines.push(stmt.lineIndex) + if (stmt.label !== null) { + s.decl = stmt + s.label = stmt.label + } + if (stmt.form === 'state') { + s.typeDeclLine = stmt.lineIndex + if (stmt.stereotype) s.stereotype = stmt.stereotype.replace(/[<>]/g, '') + } + if (stmt.opensBlock) { + s.isComposite = true + blockStack.push(stmt.id) + } + } + break + } + case 'blockClose': + blockStack.pop() + break + case 'transition': { + const src = touch(stmt.source, stmt.lineIndex) + const tgt = touch(stmt.target, stmt.lineIndex) + src?.refLines.push(stmt.lineIndex) + tgt?.refLines.push(stmt.lineIndex) + const key = `${stmt.source}->${stmt.target}` + const occ = occurrence.get(key) ?? 0 + occurrence.set(key, occ + 1) + transitions.push({ + entityId: `trans:${key}#${occ}`, + source: stmt.source, + target: stmt.target, + lineIndex: stmt.lineIndex, + stmt, + label: stmt.label, + order: transitions.length, + }) + break + } + case 'direction': + direction = { value: stmt.parts[0], lineIndex: stmt.lineIndex } + break + default: + break + } + } + + return { + headerLine: headerIndex, + states: [...states.values()], + stateById: states, + transitions, + statements, + direction, + lastContentLine, + } +} + +export function stateEntitySpans(graph: StateGraph, lines: LineInfo[], entityId: string): Span[] { + if (entityId.startsWith('state:')) { + const s = graph.stateById.get(entityId.slice(6)) + if (!s) return [] + const spans: Span[] = [] + for (const li of s.declLines) { + const line = lines[li] + spans.push({ start: line.start + line.indent.length, end: line.end }) + } + for (const t of graph.transitions) { + if (t.source === s.id) spans.push(t.stmt.sourceSpan) + if (t.target === s.id) spans.push(t.stmt.targetSpan) + } + return spans + } + if (entityId.startsWith('trans:')) { + const t = graph.transitions.find((tr) => tr.entityId === entityId) + if (!t) return [] + const line = lines[t.lineIndex] + return [{ start: line.start + line.indent.length, end: line.end }] + } + return [] +} + +export function stateEntityAt(graph: StateGraph, lines: LineInfo[], offset: number): string | null { + for (const t of graph.transitions) { + const line = lines[t.lineIndex] + if (offset >= line.start && offset <= line.end) { + if (offset >= t.stmt.sourceSpan.start && offset <= t.stmt.sourceSpan.end && t.source !== '[*]') { + return `state:${t.source}` + } + if (offset >= t.stmt.targetSpan.start && offset <= t.stmt.targetSpan.end && t.target !== '[*]') { + return `state:${t.target}` + } + return t.entityId + } + } + for (const s of graph.states) { + for (const li of s.declLines) { + const line = lines[li] + if (offset >= line.start && offset <= line.end) return s.entityId + } + } + return null +} diff --git a/packages/core/src/state/ops.ts b/packages/core/src/state/ops.ts new file mode 100644 index 0000000..4ec9bd7 --- /dev/null +++ b/packages/core/src/state/ops.ts @@ -0,0 +1,209 @@ +import type { LineInfo, TextEdit } from '../types' +import type { StateGraph, StTransition } from './graph' + +export type StateType = 'state' | 'choice' | 'fork' | 'join' + +export type StateOp = + | { type: 'st.addState'; label?: string; id?: string } + | { type: 'st.setStateType'; id: string; stype: StateType } + | { type: 'st.addStateNote'; id: string; side: 'left' | 'right'; text?: string } + | { type: 'st.reverseTransition'; transId: string } + | { type: 'st.moveToComposite'; id: string; name?: string } + | { type: 'st.connect'; source: string; target: string; label?: string } + | { type: 'st.setStateLabel'; id: string; label: string } + | { type: 'st.setTransitionLabel'; transId: string; label: string } + | { type: 'st.deleteState'; id: string } + | { type: 'st.deleteTransition'; transId: string } + | { type: 'st.setDirection'; direction: string } + +export interface StOpContext { + code: string + lines: LineInfo[] + graph: StateGraph +} + +export interface StOpResult { + edits: TextEdit[] + created?: string[] +} + +function bodyIndent(ctx: StOpContext): string { + for (const line of ctx.lines) { + if (line.kind === 'statement' && line.index !== ctx.graph.headerLine && line.text.trim() !== '') { + return line.indent + } + } + return ' ' +} + +function insertAfterLine(ctx: StOpContext, lineIndex: number, texts: string[]): TextEdit { + const line = ctx.lines[lineIndex] + return { start: line.end, end: line.end, text: texts.map((t) => `\n${t}`).join('') } +} + +function deleteLine(ctx: StOpContext, lineIndex: number): TextEdit { + const line = ctx.lines[lineIndex] + if (line.end < ctx.code.length) return { start: line.start, end: line.end + 1, text: '' } + if (line.start > 0) return { start: line.start - 1, end: line.end, text: '' } + return { start: line.start, end: line.end, text: '' } +} + +function findTransition(graph: StateGraph, transId: string): StTransition | null { + return graph.transitions.find((t) => t.entityId === transId) ?? null +} + +function generateStateId(graph: StateGraph, preferred?: string): string { + const existing = new Set(graph.states.map((s) => s.id)) + if (preferred && !existing.has(preferred)) return preferred + let i = 1 + while (existing.has(`s${i}`)) i++ + return `s${i}` +} + +function cleanLabel(label: string): string { + return label.trim().replace(/[:{}]/g, '') +} + +export function compileStateOp(ctx: StOpContext, op: StateOp): StOpResult | null { + const { graph, lines } = ctx + + switch (op.type) { + case 'st.addState': { + const id = generateStateId(graph, op.id) + const label = op.label ? cleanLabel(op.label) : null + const text = label ? `${bodyIndent(ctx)}${id}: ${label}` : `${bodyIndent(ctx)}state ${id}` + return { + edits: [insertAfterLine(ctx, graph.lastContentLine, [text])], + created: [`state:${id}`], + } + } + + case 'st.connect': { + if (op.source !== '[*]' && !graph.stateById.has(op.source)) return null + if (op.target !== '[*]' && !graph.stateById.has(op.target)) return null + // insert after the last line referencing either endpoint, keeping its indent + let after = graph.lastContentLine + let found = -1 + for (const id of [op.source, op.target]) { + const s = graph.stateById.get(id) + if (!s) continue + for (const li of [...s.declLines, ...s.refLines]) found = Math.max(found, li) + } + if (found >= 0) after = found + const indent = lines[after].kind === 'statement' ? lines[after].indent : bodyIndent(ctx) + const label = op.label ? `: ${cleanLabel(op.label)}` : '' + const occ = graph.transitions.filter((t) => t.source === op.source && t.target === op.target).length + return { + edits: [insertAfterLine(ctx, after, [`${indent}${op.source} --> ${op.target}${label}`])], + created: [`trans:${op.source}->${op.target}#${occ}`], + } + } + + case 'st.setStateType': { + const s = graph.stateById.get(op.id) + if (!s || s.isComposite) return null + const suffix = op.stype === 'state' ? '' : ` <<${op.stype}>>` + if (s.typeDeclLine !== null) { + const line = lines[s.typeDeclLine] + return { edits: [{ start: line.start, end: line.end, text: `${line.indent}state ${s.id}${suffix}` }] } + } + if (op.stype === 'state') return { edits: [] } + // stereotypes must be declared before first use + return { edits: [insertAfterLine(ctx, graph.headerLine, [`${bodyIndent(ctx)}state ${s.id}${suffix}`])] } + } + + case 'st.addStateNote': { + const s = graph.stateById.get(op.id) + if (!s) return null + const line = lines[s.firstRefLine] + const text = `${line.indent}note ${op.side} of ${s.id}: ${cleanLabel(op.text ?? 'note')}` + return { edits: [insertAfterLine(ctx, s.firstRefLine, [text])] } + } + + case 'st.reverseTransition': { + const t = findTransition(graph, op.transId) + if (!t) return null + return { + edits: [ + { start: t.stmt.sourceSpan.start, end: t.stmt.sourceSpan.end, text: t.target }, + { start: t.stmt.targetSpan.start, end: t.stmt.targetSpan.end, text: t.source }, + ], + } + } + + case 'st.moveToComposite': { + const s = graph.stateById.get(op.id) + if (!s || s.isComposite || s.parent) return null + const existing = new Set(graph.states.map((st) => st.id)) + let name = op.name?.replace(/[^\w-]/g, '') || '' + if (!name || existing.has(name)) { + let i = 1 + while (existing.has(`Composite${i}`)) i++ + name = `Composite${i}` + } + // membership is positional: declaring the state inside the block nests it + const indent = bodyIndent(ctx) + return { + edits: [ + insertAfterLine(ctx, graph.headerLine, [`${indent}state ${name} {`, `${indent} ${s.id}`, `${indent}}`]), + ], + created: [`state:${name}`], + } + } + + case 'st.setStateLabel': { + const s = graph.stateById.get(op.id) + if (!s) return null + const label = cleanLabel(op.label) + if (!label) return null + if (s.decl?.labelSpan) { + const text = s.decl.form === 'quoted' ? label.replace(/"/g, "'") : ` ${label}` + return { edits: [{ start: s.decl.labelSpan.start, end: s.decl.labelSpan.end, text }] } + } + // no description yet — declare one right after the first reference + const line = lines[s.firstRefLine] + return { edits: [insertAfterLine(ctx, s.firstRefLine, [`${line.indent}${s.id}: ${label}`])] } + } + + case 'st.setTransitionLabel': { + const t = findTransition(graph, op.transId) + if (!t) return null + const label = cleanLabel(op.label) + if (t.stmt.labelSpan) { + if (!label) { + // remove the `: label` tail entirely + return { edits: [{ start: t.stmt.targetSpan.end, end: lines[t.lineIndex].end, text: '' }] } + } + return { edits: [{ start: t.stmt.labelSpan.start, end: t.stmt.labelSpan.end, text: ` ${label}` }] } + } + if (!label) return { edits: [] } + return { edits: [{ start: t.stmt.targetSpan.end, end: t.stmt.targetSpan.end, text: `: ${label}` }] } + } + + case 'st.deleteTransition': { + const t = findTransition(graph, op.transId) + if (!t) return null + return { edits: [deleteLine(ctx, t.lineIndex)] } + } + + case 'st.deleteState': { + const s = graph.stateById.get(op.id) + if (!s) return null + // composite states own a brace block — deleting them wholesale is unsafe + if (s.isComposite) return null + const doomed = new Set(s.declLines) + for (const t of graph.transitions) { + if (t.source === s.id || t.target === s.id) doomed.add(t.lineIndex) + } + return { edits: [...doomed].map((li) => deleteLine(ctx, li)) } + } + + case 'st.setDirection': { + if (graph.direction) { + const line = lines[graph.direction.lineIndex] + return { edits: [{ start: line.start, end: line.end, text: `${line.indent}direction ${op.direction}` }] } + } + return { edits: [insertAfterLine(ctx, graph.headerLine, [`${bodyIndent(ctx)}direction ${op.direction}`])] } + } + } +} diff --git a/packages/core/src/state/parse.ts b/packages/core/src/state/parse.ts new file mode 100644 index 0000000..1cb976f --- /dev/null +++ b/packages/core/src/state/parse.ts @@ -0,0 +1,158 @@ +import type { LineInfo, Span } from '../types' + +export interface StTransitionStmt { + kind: 'transition' + lineIndex: number + /** `[*]` allowed on either side */ + source: string + sourceSpan: Span + target: string + targetSpan: Span + label: string | null + /** span of the label text after `:` */ + labelSpan: Span | null +} + +export interface StDeclStmt { + kind: 'decl' + lineIndex: number + id: string + idSpan: Span + /** display description, if declared */ + label: string | null + labelSpan: Span | null + /** `state "x" as id` | `state id` | `id: x` */ + form: 'quoted' | 'state' | 'desc' + opensBlock: boolean + stereotype: string | null +} + +export interface StSimpleStmt { + kind: + | 'blockClose' + | 'direction' + | 'divider' + | 'note' + | 'noteBlockOpen' + | 'noteBlockEnd' + | 'classDef' + | 'classAssign' + | 'style' + | 'unknown' + lineIndex: number + parts: string[] +} + +export type StStmt = StTransitionStmt | StDeclStmt | StSimpleStmt + +const ID = String.raw`(?:\[\*\]|[\w-]+)` +const TRANSITION_RE = new RegExp(String.raw`^(${ID})\s*-->\s*(${ID})\s*(?::(.*))?$`) +const QUOTED_DECL_RE = /^state\s+"([^"]*)"\s+as\s+([\w-]+)\s*(\{)?\s*$/ +const STATE_DECL_RE = /^state\s+([\w-]+)\s*(<<\w+>>)?\s*(\{)?\s*$/ +const DESC_DECL_RE = /^([\w-]+)\s*:\s*(.*)$/ +const NOTE_LINE_RE = /^note\s+(left|right)\s+of\s+[\w-]+\s*:/i +const NOTE_BLOCK_RE = /^note\s+(left|right)\s+of\s+[\w-]+\s*$/i + +function span(line: LineInfo, relStart: number, relEnd: number): Span { + return { start: line.start + relStart, end: line.start + relEnd } +} + +export function parseStateStatement(line: LineInfo): StStmt { + const raw = line.text + const t = raw.trim() + const lineIndex = line.index + const indent = raw.length - raw.trimStart().length + + if (t === '}') return { kind: 'blockClose', lineIndex, parts: [] } + if (t === '--') return { kind: 'divider', lineIndex, parts: [] } + const dir = /^direction\s+(TB|TD|BT|RL|LR)\s*$/.exec(t) + if (dir) return { kind: 'direction', lineIndex, parts: [dir[1]] } + if (/^end\s+note$/i.test(t)) return { kind: 'noteBlockEnd', lineIndex, parts: [] } + if (NOTE_LINE_RE.test(t)) return { kind: 'note', lineIndex, parts: [] } + if (NOTE_BLOCK_RE.test(t)) return { kind: 'noteBlockOpen', lineIndex, parts: [] } + if (/^classDef\b/.test(t)) return { kind: 'classDef', lineIndex, parts: [] } + if (/^style\b/.test(t)) return { kind: 'style', lineIndex, parts: [] } + const cls = /^class\s+([\w-,\s]+)\s+([\w-]+)\s*$/.exec(t) + if (cls) return { kind: 'classAssign', lineIndex, parts: cls[1].split(',').map((s) => s.trim()) } + + const trans = TRANSITION_RE.exec(t) + if (trans) { + const [, source, target, labelRaw] = trans + const srcStart = indent + t.indexOf(source) + const arrowStart = raw.indexOf('-->', srcStart + source.length) + const tgtStart = raw.indexOf(target, arrowStart + 3) + let label: string | null = null + let labelSpan: Span | null = null + if (labelRaw !== undefined) { + const colon = raw.indexOf(':', tgtStart + target.length) + label = raw.slice(colon + 1).trim() + labelSpan = span(line, colon + 1, raw.length) + } + return { + kind: 'transition', + lineIndex, + source, + sourceSpan: span(line, srcStart, srcStart + source.length), + target, + targetSpan: span(line, tgtStart, tgtStart + target.length), + label, + labelSpan, + } + } + + const quoted = QUOTED_DECL_RE.exec(t) + if (quoted) { + const label = quoted[1] + const id = quoted[2] + const labelStart = raw.indexOf('"') + 1 + const idStart = raw.indexOf(id, labelStart + label.length + 1) + return { + kind: 'decl', + lineIndex, + id, + idSpan: span(line, idStart, idStart + id.length), + label, + labelSpan: span(line, labelStart, labelStart + label.length), + form: 'quoted', + opensBlock: !!quoted[3], + stereotype: null, + } + } + + const stateDecl = STATE_DECL_RE.exec(t) + if (stateDecl) { + const id = stateDecl[1] + const idStart = raw.indexOf(id, indent + 5) + return { + kind: 'decl', + lineIndex, + id, + idSpan: span(line, idStart, idStart + id.length), + label: null, + labelSpan: null, + form: 'state', + opensBlock: !!stateDecl[3], + stereotype: stateDecl[2] ?? null, + } + } + + const desc = DESC_DECL_RE.exec(t) + if (desc) { + const id = desc[1] + const idStart = indent + const colon = raw.indexOf(':', idStart + id.length) + return { + kind: 'decl', + lineIndex, + id, + idSpan: span(line, idStart, idStart + id.length), + label: desc[2].trim(), + labelSpan: span(line, colon + 1, raw.length), + form: 'desc', + opensBlock: false, + stereotype: null, + } + } + + return { kind: 'unknown', lineIndex, parts: [] } +} diff --git a/packages/core/src/text.ts b/packages/core/src/text.ts new file mode 100644 index 0000000..ab6acc9 --- /dev/null +++ b/packages/core/src/text.ts @@ -0,0 +1,125 @@ +import type { LineInfo, LineKind, Span, TextEdit } from './types' + +/** Split code into lines with absolute offsets. Lossless: joining `text` with '\n' at recorded offsets reproduces input. */ +export function scanLines(code: string): LineInfo[] { + const lines: LineInfo[] = [] + let start = 0 + let index = 0 + while (start <= code.length) { + let nl = code.indexOf('\n', start) + if (nl === -1) nl = code.length + const text = code.slice(start, nl) + const indent = /^[ \t]*/.exec(text)![0] + lines.push({ index, kind: 'statement', text, start, end: nl, indent }) + index++ + if (nl === code.length) break + start = nl + 1 + } + return lines +} + +/** Classify lines generically (frontmatter, comments, directives, header). Mutates `kind` in place. */ +export function classifyLines(lines: LineInfo[]): { headerIndex: number } { + let headerIndex = -1 + let inFrontmatter = false + let frontmatterDone = false + let sawContent = false + + for (const line of lines) { + const t = line.text.trim() + if (!sawContent && !frontmatterDone && t === '---') { + if (!inFrontmatter) { + inFrontmatter = true + line.kind = 'frontmatter' + continue + } else { + inFrontmatter = false + frontmatterDone = true + line.kind = 'frontmatter' + continue + } + } + if (inFrontmatter) { + line.kind = 'frontmatter' + continue + } + if (t === '') { + line.kind = 'blank' + continue + } + if (t.startsWith('%%{')) { + line.kind = 'directive' + continue + } + if (t.startsWith('%%')) { + line.kind = 'comment' + continue + } + if (headerIndex === -1) { + line.kind = 'header' + headerIndex = line.index + sawContent = true + continue + } + line.kind = 'statement' + sawContent = true + } + return { headerIndex } +} + +/** Apply edits (non-overlapping) and return new text plus the inverse edits (for undo). */ +export function applyEdits(code: string, edits: TextEdit[]): { text: string; inverse: TextEdit[] } { + const sorted = [...edits].sort((a, b) => a.start - b.start) + for (let i = 1; i < sorted.length; i++) { + if (sorted[i].start < sorted[i - 1].end) { + throw new Error('Overlapping text edits') + } + } + let out = '' + let pos = 0 + const inverse: TextEdit[] = [] + let delta = 0 + for (const e of sorted) { + out += code.slice(pos, e.start) + out += e.text + inverse.push({ start: e.start + delta, end: e.start + delta + e.text.length, text: code.slice(e.start, e.end) }) + delta += e.text.length - (e.end - e.start) + pos = e.end + } + out += code.slice(pos) + return { text: out, inverse } +} + +/** Minimal single-edit diff between two versions (common prefix/suffix trim). */ +export function diffText(oldText: string, newText: string): TextEdit | null { + if (oldText === newText) return null + let p = 0 + const oLen = oldText.length + const nLen = newText.length + const min = Math.min(oLen, nLen) + while (p < min && oldText[p] === newText[p]) p++ + let so = oLen + let sn = nLen + while (so > p && sn > p && oldText[so - 1] === newText[sn - 1]) { + so-- + sn-- + } + return { start: p, end: so, text: newText.slice(p, sn) } +} + +export function spanContains(span: Span, offset: number): boolean { + return offset >= span.start && offset <= span.end +} + +/** Map a position through a set of edits (for keeping selections stable). */ +export function mapOffset(offset: number, edits: TextEdit[]): number { + let result = offset + for (const e of [...edits].sort((a, b) => a.start - b.start)) { + if (e.end <= offset) { + result += e.text.length - (e.end - e.start) + } else if (e.start < offset) { + result = e.start + e.text.length + } + } + return result +} diff --git a/packages/core/src/textpane.ts b/packages/core/src/textpane.ts new file mode 100644 index 0000000..4ee67f7 --- /dev/null +++ b/packages/core/src/textpane.ts @@ -0,0 +1,97 @@ +/** + * Editor-agnostic text pane binding. + * + * Any code editor (CodeMirror, Monaco, a plain textarea) can sync with the + * engine by implementing TextPaneAdapter and calling the notify* methods on + * the returned binding from its own events. @visimer/codemirror is + * the reference implementation. + */ + +import type { MermaidWysiwygEditor } from './editor' +import type { Span, TextEdit } from './types' + +export interface TextPaneAdapter { + /** current full document text of the pane */ + getText(): string + /** + * Apply the engine's minimal edits to the pane, all at once, against the + * current text (offsets are pre-clamped and sorted ascending). + */ + applyEdits(edits: TextEdit[]): void + /** replace the whole document; used as a resync safety net */ + setText(text: string): void + /** highlight the selected entities' source ranges (empty array clears) */ + setHighlights(spans: Span[]): void + /** scroll a document offset into view (canvas selections jump the pane) */ + revealPosition?(offset: number): void +} + +export interface TextPaneBinding { + /** + * true while the binding itself is mutating the pane; the notify* methods + * already no-op during this, so most adapters never need to check it + */ + readonly applying: boolean + /** call when the user edited the pane */ + notifyTextChange(): void + /** call when the caret moved without a document change */ + notifyCaretMove(offset: number): void + /** route the pane's undo/redo keys here; it is the same stack the canvas uses */ + undo(): void + redo(): void + dispose(): void +} + +/** Two-way sync between an engine and any text editor behind a TextPaneAdapter. */ +export function bindTextPane(editor: MermaidWysiwygEditor, adapter: TextPaneAdapter): TextPaneBinding { + let applying = false + + const validSpans = (spans: Span[]) => { + const len = adapter.getText().length + return spans.filter((s) => s.end > s.start && s.end <= len).sort((a, b) => a.start - b.start) + } + + const disposers = [ + editor.on('change', ({ origin, edits, code }) => { + if (origin !== 'code') { + applying = true + try { + const len = adapter.getText().length + adapter.applyEdits( + edits.map((e) => ({ + start: Math.min(e.start, len), + end: Math.min(e.end, len), + text: e.text, + })), + ) + if (adapter.getText() !== code) adapter.setText(code) + } finally { + applying = false + } + } + adapter.setHighlights(validSpans(editor.selectionSpans())) + }), + editor.on('selectionChange', ({ spans, origin }) => { + adapter.setHighlights(validSpans(spans)) + if (origin === 'canvas' && spans[0]) adapter.revealPosition?.(spans[0].start) + }), + ] + + return { + get applying() { + return applying + }, + notifyTextChange() { + if (applying) return + editor.setCode(adapter.getText(), 'code') + }, + notifyCaretMove(offset: number) { + if (applying) return + const entity = editor.entityAt(offset) + editor.setSelection(entity ? [entity] : [], 'code') + }, + undo: () => editor.undo(), + redo: () => editor.redo(), + dispose: () => disposers.forEach((d) => d()), + } +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts new file mode 100644 index 0000000..1a5372b --- /dev/null +++ b/packages/core/src/types.ts @@ -0,0 +1,56 @@ +export interface Span { + start: number + end: number +} + +export interface TextEdit { + start: number + end: number + text: string +} + +export type Origin = 'code' | 'canvas' | 'api' | 'history' | 'external' + +export type Capability = 'edit' | 'render' + +export interface DiagramTypeInfo { + /** stable id, e.g. 'flowchart' */ + id: string + /** display name, e.g. 'Flowchart' */ + name: string + /** matched against the trimmed header line */ + match: RegExp + /** what this library can do with the type today */ + capability: Capability + docsUrl: string + /** requires an external mermaid plugin to render */ + requiresPlugin?: string +} + +export type LineKind = + | 'blank' + | 'comment' + | 'directive' + | 'frontmatter' + | 'header' + | 'statement' + +export interface LineInfo { + index: number + kind: LineKind + /** raw text, no newline */ + text: string + /** absolute offset of line start */ + start: number + /** absolute offset of line end (before newline) */ + end: number + /** leading whitespace */ + indent: string +} + +export interface Diagnostic { + message: string + span: Span | null + severity: 'error' | 'warning' + source: 'core' | 'mermaid' +} diff --git a/packages/core/test/class.test.ts b/packages/core/test/class.test.ts new file mode 100644 index 0000000..0171a54 --- /dev/null +++ b/packages/core/test/class.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest' +import { MermaidWysiwygEditor, parse } from '../src' + +const CL = `classDiagram + Animal <|-- Duck + Animal <|-- Fish + Animal : +int age + Animal : +isMammal() + class Duck{ + +String beakColor + +swim() + } +` + +describe('class parsing', () => { + it('parses classes, relations, block and one-liner members', () => { + const r = parse(CL) + expect(r.typeInfo?.id).toBe('class') + const g = r.classGraph! + expect(g.classes.map((c) => c.id).sort()).toEqual(['Animal', 'Duck', 'Fish']) + expect(g.relations.map((rel) => `${rel.source}${rel.stmt.op}${rel.target}`)).toEqual([ + 'Animal<|--Duck', + 'Animal<|--Fish', + ]) + expect(g.classById.get('Animal')!.members.map((m) => m.text)).toEqual(['+int age', '+isMammal()']) + expect(g.classById.get('Duck')!.members.map((m) => m.text)).toEqual(['+String beakColor', '+swim()']) + expect(g.classById.get('Duck')!.block).toBeTruthy() + }) + + it('parses cardinalities, labels, and class labels', () => { + const code = `classDiagram + class Customer["Our customer"] + Customer "1" --> "*" Ticket : books +` + const g = parse(code).classGraph! + expect(g.classById.get('Customer')!.label).toBe('Our customer') + expect(g.relations[0].label).toBe('books') + expect(g.relations[0].stmt.op).toBe('-->') + }) +}) + +describe('class ops', () => { + it('addClass creates a block, connect adds a relation', () => { + const ed = new MermaidWysiwygEditor({ code: CL }) + const res = ed.dispatch({ type: 'cl.addClass' }) + expect(res?.created).toEqual(['class:Class1']) + expect(ed.code).toContain(' class Class1 {\n }') + ed.dispatch({ type: 'cl.connect', source: 'Duck', target: 'Class1', op: '*--' }) + expect(ed.code).toContain('Duck *-- Class1') + }) + + it('setRelationType and reverseRelation rewrite in place', () => { + const ed = new MermaidWysiwygEditor({ code: CL }) + const rel = ed.result.classGraph!.relations[0] + ed.dispatch({ type: 'cl.setRelationType', relId: rel.entityId, op: '..|>' }) + expect(ed.code).toContain('Animal ..|> Duck') + const rel2 = ed.result.classGraph!.relations[0] + ed.dispatch({ type: 'cl.reverseRelation', relId: rel2.entityId }) + expect(ed.code).toContain('Duck ..|> Animal') + }) + + it('addMember appends into the block or as a one-liner', () => { + const ed = new MermaidWysiwygEditor({ code: CL }) + ed.dispatch({ type: 'cl.addMember', id: 'Duck', text: '+quack()' }) + expect(ed.code).toContain(' +swim()\n +quack()\n }') + ed.dispatch({ type: 'cl.addMember', id: 'Fish', text: '-int sizeInFeet' }) + expect(ed.code).toContain('Animal <|-- Fish\n Fish : -int sizeInFeet') + }) + + it('renameClass rewrites decl, relations, one-liners', () => { + const ed = new MermaidWysiwygEditor({ code: CL }) + ed.dispatch({ type: 'cl.renameClass', id: 'Animal', name: 'Creature' }) + expect(ed.code).toContain('Creature <|-- Duck') + expect(ed.code).toContain('Creature : +int age') + expect(ed.code).not.toContain('Animal') + }) + + it('deleteClass removes block, members, and touching relations', () => { + const ed = new MermaidWysiwygEditor({ code: CL }) + ed.dispatch({ type: 'cl.deleteClass', id: 'Duck' }) + expect(ed.code).toBe(`classDiagram + Animal <|-- Fish + Animal : +int age + Animal : +isMammal() +`) + }) + + it('setAnnotation adds, rewrites, and removes <<...>> lines', () => { + const ed = new MermaidWysiwygEditor({ code: CL }) + ed.dispatch({ type: 'cl.setAnnotation', id: 'Animal', annotation: 'interface' }) + expect(ed.code).toContain('Animal <|-- Duck\n <> Animal') + ed.dispatch({ type: 'cl.setAnnotation', id: 'Animal', annotation: 'abstract' }) + expect(ed.code).toContain('<> Animal') + expect(ed.code).not.toContain('interface') + ed.dispatch({ type: 'cl.setAnnotation', id: 'Animal', annotation: null }) + expect(ed.code).not.toContain('<<') + }) + + it('addNoteFor appends a class note', () => { + const ed = new MermaidWysiwygEditor({ code: CL }) + ed.dispatch({ type: 'cl.addNoteFor', id: 'Duck', text: 'quacks loudly' }) + expect(ed.code).toContain('note for Duck "quacks loudly"') + }) + + it('setCardinality inserts, edits, and removes quoted cardinalities', () => { + const ed = new MermaidWysiwygEditor({ code: 'classDiagram\n Customer --> Ticket : books\n' }) + const rel = ed.result.classGraph!.relations[0] + ed.dispatch({ type: 'cl.setCardinality', relId: rel.entityId, side: 'source', value: '1' }) + expect(ed.code).toContain('Customer "1" --> Ticket : books') + const rel2 = ed.result.classGraph!.relations[0] + ed.dispatch({ type: 'cl.setCardinality', relId: rel2.entityId, side: 'target', value: '0..*' }) + expect(ed.code).toContain('Customer "1" --> "0..*" Ticket : books') + const rel3 = ed.result.classGraph!.relations[0] + ed.dispatch({ type: 'cl.setCardinality', relId: rel3.entityId, side: 'source', value: null }) + expect(ed.code).toContain('Customer --> "0..*" Ticket : books') + }) + + it('setRelationLabel adds and removes', () => { + const ed = new MermaidWysiwygEditor({ code: CL }) + const rel = ed.result.classGraph!.relations[1] + ed.dispatch({ type: 'cl.setRelationLabel', relId: rel.entityId, label: 'is a' }) + expect(ed.code).toContain('Animal <|-- Fish : is a') + }) +}) + +describe('state stereotype ops', () => { + it('setStateType declares and rewrites stereotypes', () => { + const ed = new MermaidWysiwygEditor({ code: 'stateDiagram-v2\n A --> B\n' }) + ed.dispatch({ type: 'st.setStateType', id: 'B', stype: 'choice' }) + expect(ed.code.split('\n')[1]).toBe(' state B <>') + ed.dispatch({ type: 'st.setStateType', id: 'B', stype: 'fork' }) + expect(ed.code.split('\n')[1]).toBe(' state B <>') + ed.dispatch({ type: 'st.setStateType', id: 'B', stype: 'state' }) + expect(ed.code.split('\n')[1]).toBe(' state B') + }) + + it('addStateNote and reverseTransition', () => { + const ed = new MermaidWysiwygEditor({ code: 'stateDiagram-v2\n A --> B: go\n' }) + ed.dispatch({ type: 'st.addStateNote', id: 'A', side: 'right', text: 'hi' }) + expect(ed.code).toContain('note right of A: hi') + const t = ed.result.state!.transitions[0] + ed.dispatch({ type: 'st.reverseTransition', transId: t.entityId }) + expect(ed.code).toContain('B --> A: go') + }) +}) diff --git a/packages/core/test/core.test.ts b/packages/core/test/core.test.ts new file mode 100644 index 0000000..37f68d5 --- /dev/null +++ b/packages/core/test/core.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, it } from 'vitest' +import { MermaidWysiwygEditor, parse, scanLines, DIAGRAM_TYPES, detectDiagramType } from '../src' + +const FLOW = `flowchart TD + %% happy path + A[Start] --> B{Valid?} + B -->|yes| C[Process] + C --> D[Done] +` + +describe('generic CST', () => { + it('is byte-lossless on scan', () => { + const samples = [FLOW, 'pie\n "a": 1', '---\ntitle: Hi\n---\nflowchart LR\n A --> B\n\n%% done'] + for (const code of samples) { + const lines = scanLines(code) + const rejoined = lines.map((l) => l.text).join('\n') + expect(rejoined).toBe(code) + } + }) + + it('detects all registered diagram types by header', () => { + const headers: Record = { + flowchart: 'flowchart LR', + sequence: 'sequenceDiagram', + class: 'classDiagram', + state: 'stateDiagram-v2', + er: 'erDiagram', + journey: 'journey', + gantt: 'gantt', + pie: 'pie showData', + quadrant: 'quadrantChart', + requirement: 'requirementDiagram', + gitgraph: 'gitGraph', + c4: 'C4Context', + mindmap: 'mindmap', + timeline: 'timeline', + zenuml: 'zenuml', + sankey: 'sankey-beta', + xychart: 'xychart-beta', + block: 'block-beta', + packet: 'packet-beta', + kanban: 'kanban', + architecture: 'architecture-beta', + radar: 'radar-beta', + treemap: 'treemap-beta', + } + expect(Object.keys(headers).sort()).toEqual(DIAGRAM_TYPES.map((t) => t.id).sort()) + for (const [id, header] of Object.entries(headers)) { + expect(detectDiagramType(header)?.id, header).toBe(id) + } + }) +}) + +describe('flowchart parsing', () => { + it('parses nodes, edges, labels, shapes', () => { + const r = parse(FLOW) + expect(r.typeInfo?.id).toBe('flowchart') + const g = r.flowchart! + expect(g.direction).toBe('TD') + expect(g.nodes.map((n) => n.id).sort()).toEqual(['A', 'B', 'C', 'D']) + expect(g.nodeById.get('B')!.shape).toBe('diamond') + expect(g.nodeById.get('B')!.label).toBe('Valid?') + expect(g.edges).toHaveLength(3) + expect(g.edges[1].label).toBe('yes') + }) + + it('parses chains, fan-out, class shorthand and quoted labels', () => { + const code = `flowchart LR + A & B --> C{{"Hex label"}}:::hot --> D([Stadium]) + E -. dotted .-> F + G == thick ==> H + classDef hot fill:#f96 +` + const g = parse(code).flowchart! + expect(g.edges.map((e) => `${e.source}->${e.target}`)).toEqual([ + 'A->C', + 'B->C', + 'C->D', + 'E->F', + 'G->H', + ]) + expect(g.nodeById.get('C')!.classes).toContain('hot') + expect(g.nodeById.get('C')!.label).toBe('Hex label') + expect(g.edges[3].seg.line).toBe('dotted') + expect(g.edges[3].label).toBe('dotted') + expect(g.edges[4].seg.line).toBe('thick') + }) + + it('treats unparseable lines as unknown without failing the document', () => { + const code = `flowchart LR + A --> B + this is @@ not $$ mermaid !!! + B --> C +` + const g = parse(code).flowchart! + expect(g.edges).toHaveLength(2) + }) + + it('tracks subgraphs', () => { + const code = `flowchart TB + subgraph api [API Layer] + A --> B + end + B --> C +` + const g = parse(code).flowchart! + expect(g.subgraphs).toHaveLength(1) + expect(g.subgraphs[0].title).toBe('API Layer') + expect(g.nodeById.get('A')!.subgraph).toBe('api') + expect(g.nodeById.get('C')!.subgraph).toBe(null) + }) +}) + +describe('ops → minimal text edits', () => { + it('connect inserts a single line after last reference', () => { + const ed = new MermaidWysiwygEditor({ code: FLOW }) + ed.dispatch({ type: 'connect', source: 'B', target: 'D', label: 'no' }) + expect(ed.code).toBe(`flowchart TD + %% happy path + A[Start] --> B{Valid?} + B -->|yes| C[Process] + C --> D[Done] + B -->|no| D +`) + }) + + it('addNode uses the document letter convention', () => { + const ed = new MermaidWysiwygEditor({ code: FLOW }) + const res = ed.dispatch({ type: 'addNode', label: 'Retry', shape: 'round' }) + expect(res?.created).toEqual(['node:E']) + expect(ed.code).toContain(' E(Retry)') + }) + + it('renameNode edits the label in place and quotes when needed', () => { + const ed = new MermaidWysiwygEditor({ code: FLOW }) + ed.dispatch({ type: 'renameNode', id: 'A', label: 'Begin here' }) + expect(ed.code).toContain('A[Begin here] --> B{Valid?}') + ed.dispatch({ type: 'renameNode', id: 'A', label: 'Begin (now)' }) + expect(ed.code).toContain('A["Begin (now)"] --> B{Valid?}') + }) + + it('renameNode declares label on implicit nodes', () => { + const ed = new MermaidWysiwygEditor({ code: 'flowchart LR\n A --> B\n' }) + ed.dispatch({ type: 'renameNode', id: 'B', label: 'The End' }) + expect(ed.code).toBe('flowchart LR\n A --> B[The End]\n') + }) + + it('setEdgeLabel adds and edits pipe labels', () => { + const ed = new MermaidWysiwygEditor({ code: 'flowchart LR\n A --> B\n' }) + const edgeId = ed.result.flowchart!.edges[0].entityId + ed.dispatch({ type: 'setEdgeLabel', edgeId, label: 'go' }) + expect(ed.code).toBe('flowchart LR\n A -->|go| B\n') + const edgeId2 = ed.result.flowchart!.edges[0].entityId + ed.dispatch({ type: 'setEdgeLabel', edgeId: edgeId2, label: 'stop' }) + expect(ed.code).toBe('flowchart LR\n A -->|stop| B\n') + }) + + it('deleteEdge preserves node declarations that live on the same line', () => { + const ed = new MermaidWysiwygEditor({ code: 'flowchart LR\n A[Start] --> B\n B --> C\n' }) + const edgeId = ed.result.flowchart!.edges[0].entityId + ed.dispatch({ type: 'deleteEdge', edgeId }) + expect(ed.code).toBe('flowchart LR\n A[Start]\n B --> C\n') + }) + + it('deleteNode removes its edges, splits chains, cleans class/style', () => { + const code = `flowchart LR + A[Start] --> B[Mid] --> C[End] + D --> B + class B,C important + style B fill:#f00 +` + const ed = new MermaidWysiwygEditor({ code }) + ed.dispatch({ type: 'deleteNode', id: 'B' }) + // A, C keep their declarations; D survives as an isolated node + expect(ed.code).toBe(`flowchart LR + A[Start] + C[End] + D + class C important +`) + }) + + it('setDirection edits the header token', () => { + const ed = new MermaidWysiwygEditor({ code: FLOW }) + ed.dispatch({ type: 'setDirection', direction: 'LR' }) + expect(ed.code.startsWith('flowchart LR\n')).toBe(true) + }) + + it('setNodeShape rewrites delimiters keeping the label', () => { + const ed = new MermaidWysiwygEditor({ code: FLOW }) + ed.dispatch({ type: 'setNodeShape', id: 'C', shape: 'stadium' }) + expect(ed.code).toContain('C([Process])') + }) + + it('setNodeColor manages a style statement (add, merge, clear)', () => { + const ed = new MermaidWysiwygEditor({ code: FLOW }) + ed.dispatch({ type: 'setNodeColor', id: 'A', prop: 'fill', value: '#ef4444' }) + expect(ed.code).toContain('style A fill:#ef4444') + ed.dispatch({ type: 'setNodeColor', id: 'A', prop: 'stroke', value: '#22c55e' }) + expect(ed.code).toContain('style A fill:#ef4444,stroke:#22c55e,stroke-width:2px') + ed.dispatch({ type: 'setNodeColor', id: 'A', prop: 'stroke', value: null }) + ed.dispatch({ type: 'setNodeColor', id: 'A', prop: 'fill', value: null }) + expect(ed.code).not.toContain('style A') + expect(ed.code.split('\n').filter((l) => l.trim() === '').length).toBeLessThan(2) + }) + + it('linkStyle indexes are renumbered when edge order changes', () => { + const code = `flowchart LR + A --> B + B --> C + C --> D + linkStyle 1 stroke:#f00,stroke-width:2px + linkStyle 2 stroke:#0f0,stroke-width:2px +` + // deleting edge 0 shifts both styles down + const ed = new MermaidWysiwygEditor({ code }) + ed.dispatch({ type: 'deleteEdge', edgeId: ed.result.flowchart!.edges[0].entityId }) + expect(ed.code).toContain('linkStyle 0 stroke:#f00') + expect(ed.code).toContain('linkStyle 1 stroke:#0f0') + // deleting the styled edge drops its linkStyle line + const ed2 = new MermaidWysiwygEditor({ code }) + ed2.dispatch({ type: 'deleteEdge', edgeId: ed2.result.flowchart!.edges[1].entityId }) + expect(ed2.code).not.toContain('#f00') + expect(ed2.code).toContain('linkStyle 1 stroke:#0f0') + // inserting an edge mid-order (after B's last ref, before C-->D) shifts later styles up + const ed3 = new MermaidWysiwygEditor({ code }) + ed3.dispatch({ type: 'connect', source: 'A', target: 'B' }) + expect(ed3.code).toContain('linkStyle 1 stroke:#f00') + expect(ed3.code).toContain('linkStyle 3 stroke:#0f0') + }) + + it('setEdgeColor manages a linkStyle statement by edge order', () => { + const ed = new MermaidWysiwygEditor({ code: FLOW }) + const second = ed.result.flowchart!.edges[1] + ed.dispatch({ type: 'setEdgeColor', edgeId: second.entityId, value: '#06b6d4' }) + expect(ed.code).toContain('linkStyle 1 stroke:#06b6d4,stroke-width:2px') + const again = ed.result.flowchart!.edges[1] + ed.dispatch({ type: 'setEdgeColor', edgeId: again.entityId, value: null }) + expect(ed.code).not.toContain('linkStyle') + }) +}) + +describe('editor state', () => { + it('undo/redo restores exact text across both surfaces', () => { + const ed = new MermaidWysiwygEditor({ code: FLOW }) + ed.dispatch({ type: 'connect', source: 'B', target: 'D' }) + ed.setCode(ed.code.replace('Start', 'Begin'), 'code') + const afterBoth = ed.code + ed.undo() + ed.undo() + expect(ed.code).toBe(FLOW) + ed.redo() + ed.redo() + expect(ed.code).toBe(afterBoth) + }) + + it('selection survives unrelated code edits and prunes on delete', () => { + const ed = new MermaidWysiwygEditor({ code: FLOW }) + ed.setSelection(['node:C']) + ed.setCode(ed.code.replace('Start', 'Begin'), 'code') + expect(ed.selection).toEqual(['node:C']) + ed.dispatch({ type: 'deleteNode', id: 'C' }) + expect(ed.selection).toEqual([]) + }) + + it('entityAt maps code offsets to entities', () => { + const ed = new MermaidWysiwygEditor({ code: FLOW }) + const offset = ed.code.indexOf('B{Valid?}') + 1 + expect(ed.entityAt(offset)).toBe('node:B') + }) +}) diff --git a/packages/core/test/er.test.ts b/packages/core/test/er.test.ts new file mode 100644 index 0000000..07ae10a --- /dev/null +++ b/packages/core/test/er.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest' +import { MermaidWysiwygEditor, parse } from '../src' + +const ER = `erDiagram + CUSTOMER ||--o{ ORDER : places + ORDER ||--|{ LINE-ITEM : contains + CUSTOMER { + string name + string custNumber PK + } +` + +describe('er parsing', () => { + it('parses relations with cardinalities and attribute blocks', () => { + const r = parse(ER) + expect(r.typeInfo?.id).toBe('er') + const g = r.er! + expect(g.entities.map((e) => e.id).sort()).toEqual(['CUSTOMER', 'LINE-ITEM', 'ORDER']) + expect(g.relations).toHaveLength(2) + expect(g.relations[0].stmt.leftCard).toBe('||') + expect(g.relations[0].stmt.rightCard).toBe('o{') + expect(g.relations[0].label).toBe('places') + expect(g.entityById.get('CUSTOMER')!.attributes.map((a) => a.text)).toEqual(['string name', 'string custNumber PK']) + }) +}) + +describe('pie', () => { + it('parses slices and compiles value/label/add/delete ops', () => { + const code = `pie showData + title Pets + "Dogs" : 42 + "Cats" : 15 +` + const ed = new MermaidWysiwygEditor({ code }) + expect(ed.result.typeInfo?.id).toBe('pie') + const g = ed.result.pie! + expect(g.showData).toBe(true) + expect(g.slices.map((s) => `${s.label}=${s.value}`)).toEqual(['Dogs=42', 'Cats=15']) + ed.dispatch({ type: 'pie.setValue', sliceId: g.slices[0].entityId, value: 50 }) + expect(ed.code).toContain('"Dogs" : 50') + const g2 = ed.result.pie! + ed.dispatch({ type: 'pie.setLabel', sliceId: g2.slices[1].entityId, label: 'Felines' }) + expect(ed.code).toContain('"Felines" : 15') + ed.dispatch({ type: 'pie.addSlice', label: 'Birds', value: 7 }) + expect(ed.code).toContain('"Birds" : 7') + const g3 = ed.result.pie! + ed.dispatch({ type: 'pie.deleteSlice', sliceId: g3.slices[2].entityId }) + expect(ed.code).not.toContain('Birds') + }) +}) + +describe('gantt', () => { + it('parses tasks/sections and compiles rename/meta/add/delete ops', () => { + const code = `gantt + title Plan + dateFormat YYYY-MM-DD + section Build + Design :d1, 2026-01-01, 5d + Implement :after d1, 10d + section Ship + Release :2026-01-20, 2d +` + const ed = new MermaidWysiwygEditor({ code }) + expect(ed.result.typeInfo?.id).toBe('gantt') + const g = ed.result.gantt! + expect(g.tasks.map((t) => `${t.section}/${t.name}`)).toEqual(['Build/Design', 'Build/Implement', 'Ship/Release']) + expect(g.tasks[0].meta).toBe('d1, 2026-01-01, 5d') + ed.dispatch({ type: 'gantt.setTaskName', taskId: g.tasks[0].entityId, name: 'Design spec' }) + expect(ed.code).toContain('Design spec :d1, 2026-01-01, 5d') + const g2 = ed.result.gantt! + ed.dispatch({ type: 'gantt.setTaskMeta', taskId: g2.tasks[2].entityId, meta: '2026-01-22, 3d' }) + expect(ed.code).toContain('Release : 2026-01-22, 3d') + ed.dispatch({ type: 'gantt.addTask', section: 'Build', name: 'Review', meta: '2d' }) + expect(ed.code).toContain('Implement :after d1, 10d\n Review :2d') + const g3 = ed.result.gantt! + ed.dispatch({ type: 'gantt.deleteTask', taskId: g3.tasks[0].entityId }) + expect(ed.code).not.toContain('Design spec') + }) +}) + +describe('er ops', () => { + it('addEntity, connect, addAttribute', () => { + const ed = new MermaidWysiwygEditor({ code: ER }) + const res = ed.dispatch({ type: 'er.addEntity' }) + expect(res?.created).toEqual(['entity:ENTITY1']) + expect(ed.code).toContain(' ENTITY1 {\n string id\n }') + ed.dispatch({ type: 'er.connect', source: 'ORDER', target: 'ENTITY1' }) + expect(ed.code).toContain('ORDER ||--o{ ENTITY1 : relates') + ed.dispatch({ type: 'er.addAttribute', id: 'ORDER', text: 'int orderNumber' }) + expect(ed.code).toContain('ORDER {\n int orderNumber\n }') + }) + + it('setCardinality and setIdentifying rewrite glyphs in place', () => { + const ed = new MermaidWysiwygEditor({ code: ER }) + const rel = ed.result.er!.relations[0] + ed.dispatch({ type: 'er.setCardinality', relId: rel.entityId, side: 'left', card: 'zero-or-more' }) + expect(ed.code).toContain('CUSTOMER }o--o{ ORDER : places') + const rel2 = ed.result.er!.relations[0] + ed.dispatch({ type: 'er.setIdentifying', relId: rel2.entityId, identifying: false }) + expect(ed.code).toContain('CUSTOMER }o..o{ ORDER : places') + }) + + it('setAttributeText rewrites or deletes an attribute row', () => { + const ed = new MermaidWysiwygEditor({ code: ER }) + const cust = ed.result.er!.entityById.get('CUSTOMER')! + ed.dispatch({ type: 'er.setAttributeText', id: 'CUSTOMER', attrLine: cust.attributes[1].lineIndex, text: 'int custNumber PK "unique"' }) + expect(ed.code).toContain(' int custNumber PK "unique"') + const cust2 = ed.result.er!.entityById.get('CUSTOMER')! + ed.dispatch({ type: 'er.setAttributeText', id: 'CUSTOMER', attrLine: cust2.attributes[0].lineIndex, text: '' }) + expect(ed.code).not.toContain('string name') + }) + + it('renameEntity rewrites decl and relations', () => { + const ed = new MermaidWysiwygEditor({ code: ER }) + ed.dispatch({ type: 'er.renameEntity', id: 'CUSTOMER', name: 'CLIENT' }) + expect(ed.code).toContain('CLIENT ||--o{ ORDER : places') + expect(ed.code).toContain('CLIENT {') + }) + + it('deleteEntity removes block and touching relations', () => { + const ed = new MermaidWysiwygEditor({ code: ER }) + ed.dispatch({ type: 'er.deleteEntity', id: 'CUSTOMER' }) + expect(ed.code).toBe(`erDiagram + ORDER ||--|{ LINE-ITEM : contains +`) + }) +}) diff --git a/packages/core/test/lineitems.test.ts b/packages/core/test/lineitems.test.ts new file mode 100644 index 0000000..ce7d3a9 --- /dev/null +++ b/packages/core/test/lineitems.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { MermaidWysiwygEditor, LINE_ITEM_CONFIGS, DIAGRAM_TYPES } from '../src' + +describe('line-item types', () => { + it('covers every registered type that lacks a dedicated graph', () => { + const dedicated = new Set(['flowchart', 'sequence', 'state', 'class', 'er', 'pie', 'gantt', 'zenuml']) + for (const t of DIAGRAM_TYPES) { + if (dedicated.has(t.id)) continue + expect(LINE_ITEM_CONFIGS[t.id], t.id).toBeTruthy() + expect(t.capability, t.id).toBe('edit') + } + }) + + it('journey: items with match text, edit/add/delete ops', () => { + const code = `journey + title My day + section Work + Make tea: 5: Me + Do work: 1: Me, Cat +` + const ed = new MermaidWysiwygEditor({ code }) + const g = ed.result.lineItems! + expect(g.typeId).toBe('journey') + expect(g.items.map((i) => i.matchText)).toEqual(['My day', 'Work', 'Make tea', 'Do work']) + ed.dispatch({ type: 'li.setLine', itemId: g.items[2].entityId, text: 'Brew coffee: 4: Me' }) + expect(ed.code).toContain(' Brew coffee: 4: Me') + ed.dispatch({ type: 'li.addItem' }) + expect(ed.code).toContain(' New task: 3: Me') + const g2 = ed.result.lineItems! + ed.dispatch({ type: 'li.deleteItem', itemId: g2.items[g2.items.length - 1].entityId }) + expect(ed.code).not.toContain('New task') + }) + + it('timeline and mindmap extract sensible labels', () => { + const tl = new MermaidWysiwygEditor({ code: 'timeline\n title History\n 2002 : LinkedIn\n : Google\n' }) + expect(tl.result.lineItems!.items.map((i) => i.matchText)).toEqual(['History', 'LinkedIn', 'Google']) + const mm = new MermaidWysiwygEditor({ code: 'mindmap\n root((Center))\n Ideas\n [Boxed]\n' }) + expect(mm.result.lineItems!.items.map((i) => i.matchText)).toEqual(['Center', 'Ideas', 'Boxed']) + }) + + it('kanban, packet, c4, gitgraph extraction', () => { + const kb = new MermaidWysiwygEditor({ code: 'kanban\n Todo\n [Write docs]\n id2[Ship it]\n' }) + expect(kb.result.lineItems!.items.map((i) => i.matchText)).toEqual(['Todo', 'Write docs', 'Ship it']) + const pk = new MermaidWysiwygEditor({ code: 'packet-beta\n0-15: "Source Port"\n' }) + expect(pk.result.lineItems!.items[0].matchText).toBe('Source Port') + const c4 = new MermaidWysiwygEditor({ code: 'C4Context\n Person(a, "Customer", "desc")\n' }) + expect(c4.result.lineItems!.items[0].matchText).toBe('Customer') + const gg = new MermaidWysiwygEditor({ code: 'gitGraph\n commit\n branch develop\n' }) + expect(gg.result.lineItems!.items.map((i) => i.matchText)).toEqual([null, 'develop']) + }) + + it('selection sync and empty-edit deletion', () => { + const ed = new MermaidWysiwygEditor({ code: 'timeline\n 2002 : LinkedIn\n 2004 : Facebook\n' }) + const g = ed.result.lineItems! + expect(ed.entityAt(ed.code.indexOf('Facebook'))).toBe(g.items[1].entityId) + ed.setSelection([g.items[0].entityId]) + expect(ed.selectionSpans()).toHaveLength(1) + ed.dispatch({ type: 'li.setLine', itemId: g.items[0].entityId, text: '' }) + expect(ed.code).toBe('timeline\n 2004 : Facebook\n') + }) +}) diff --git a/packages/core/test/sequence.test.ts b/packages/core/test/sequence.test.ts new file mode 100644 index 0000000..5655832 --- /dev/null +++ b/packages/core/test/sequence.test.ts @@ -0,0 +1,165 @@ +import { describe, expect, it } from 'vitest' +import { MermaidWysiwygEditor, parse } from '../src' + +const SEQ = `sequenceDiagram + participant Alice + actor Bob + Alice->>Bob: Hello Bob + Bob-->>Alice: I am good + Note over Alice: thinking + Bob-)Alice: async bye +` + +describe('sequence parsing', () => { + it('parses participants, messages, notes', () => { + const r = parse(SEQ) + expect(r.typeInfo?.id).toBe('sequence') + const g = r.sequence! + expect(g.participants.map((p) => p.id)).toEqual(['Alice', 'Bob']) + expect(g.participantById.get('Bob')!.ptype).toBe('actor') + expect(g.events).toHaveLength(4) + const [m1, m2, note, m3] = g.events + expect(m1.kind).toBe('message') + expect(m1.kind === 'message' && m1.stmt.op).toBe('->>') + expect(m2.kind === 'message' && m2.stmt.op).toBe('-->>') + expect(note.kind).toBe('note') + expect(note.kind === 'note' && note.stmt.placement).toBe('over') + expect(m3.kind === 'message' && m3.stmt.op).toBe('-)') + }) + + it('parses aliases, extended types, autonumber, implicit participants', () => { + const code = `sequenceDiagram + autonumber + participant A as Alice Smith + participant DB@{ "type" : "database" } + A->>DB: query + DB-->>C: forward +` + const g = parse(code).sequence! + expect(g.autonumber.enabled).toBe(true) + expect(g.participantById.get('A')!.label).toBe('Alice Smith') + expect(g.participantById.get('DB')!.ptype).toBe('database') + expect(g.participantById.get('C')).toBeTruthy() // implicit + }) +}) + +describe('sequence ops', () => { + it('setParticipantType swaps keyword and attrs', () => { + const ed = new MermaidWysiwygEditor({ code: SEQ }) + ed.dispatch({ type: 'seq.setParticipantType', id: 'Alice', ptype: 'actor' }) + expect(ed.code).toContain('actor Alice') + ed.dispatch({ type: 'seq.setParticipantType', id: 'Alice', ptype: 'database' }) + expect(ed.code).toContain('participant Alice@{ "type" : "database" }') + ed.dispatch({ type: 'seq.setParticipantType', id: 'Alice', ptype: 'participant' }) + expect(ed.code).toContain('participant Alice\n') + expect(ed.code).not.toContain('@{') + }) + + it('addMessage inserts after a given event (lifeline + insertion point)', () => { + const ed = new MermaidWysiwygEditor({ code: SEQ }) + const first = ed.result.sequence!.events[0] + ed.dispatch({ type: 'seq.addMessage', source: 'Alice', target: 'Alice', afterEvent: first.entityId, text: 'self check' }) + const lines = ed.code.split('\n') + expect(lines[4]).toBe(' Alice->>Alice: self check') + expect(lines[5]).toBe(' Bob-->>Alice: I am good') + }) + + it('addNote and toggleAutonumber', () => { + const ed = new MermaidWysiwygEditor({ code: SEQ }) + ed.dispatch({ type: 'seq.addNote', participant: 'Bob', placement: 'right of', text: 'hmm' }) + expect(ed.code).toContain('Note right of Bob: hmm') + ed.dispatch({ type: 'seq.toggleAutonumber' }) + expect(ed.code.split('\n')[1]).toBe(' autonumber') + ed.dispatch({ type: 'seq.toggleAutonumber' }) + expect(ed.code).not.toContain('autonumber') + }) + + it('setMessageOp and reverseMessage rewrite in place', () => { + const ed = new MermaidWysiwygEditor({ code: SEQ }) + const first = ed.result.sequence!.events[0] + ed.dispatch({ type: 'seq.setMessageOp', eventId: first.entityId, op: '--x' }) + expect(ed.code).toContain('Alice--xBob: Hello Bob') + const again = ed.result.sequence!.events[0] + ed.dispatch({ type: 'seq.reverseMessage', eventId: again.entityId }) + expect(ed.code).toContain('Bob--xAlice: Hello Bob') + }) + + it('setEventText edits message and note text', () => { + const ed = new MermaidWysiwygEditor({ code: SEQ }) + const note = ed.result.sequence!.events[2] + ed.dispatch({ type: 'seq.setEventText', eventId: note.entityId, text: 'pondering deeply' }) + expect(ed.code).toContain('Note over Alice: pondering deeply') + }) + + it('deleteParticipant removes decl, messages, notes, and self from shared notes', () => { + const code = `sequenceDiagram + participant A + participant B + A->>B: one + B->>A: two + Note over A,B: both + activate A + deactivate A +` + const ed = new MermaidWysiwygEditor({ code }) + ed.dispatch({ type: 'seq.deleteParticipant', id: 'A' }) + expect(ed.code).toBe(`sequenceDiagram + participant B + Note over B: both +`) + }) + + it('renameParticipant rewrites all reference sites', () => { + const ed = new MermaidWysiwygEditor({ code: SEQ }) + ed.dispatch({ type: 'seq.renameParticipant', id: 'Bob', name: 'Robert' }) + expect(ed.code).toContain('actor Robert') + expect(ed.code).toContain('Alice->>Robert: Hello Bob') + expect(ed.code).toContain('Robert-)Alice: async bye') + }) + + it('addParticipant declares after the last declaration', () => { + const ed = new MermaidWysiwygEditor({ code: SEQ }) + const res = ed.dispatch({ type: 'seq.addParticipant', ptype: 'queue' }) + expect(res?.created).toEqual(['participant:P1']) + const lines = ed.code.split('\n') + expect(lines[3]).toBe(' participant P1@{ "type" : "queue" }') + }) + + it('moveEvent reorders statements in both directions', () => { + const ed = new MermaidWysiwygEditor({ code: SEQ }) + // move the last message before the first event + const last = ed.result.sequence!.events[3] + ed.dispatch({ type: 'seq.moveEvent', eventId: last.entityId, afterEvent: null }) + expect(ed.code.split('\n')[3]).toBe(' Bob-)Alice: async bye') + expect(ed.code.split('\n')[4]).toBe(' Alice->>Bob: Hello Bob') + // move it back down after the note + const moved = ed.result.sequence!.events[0] + const note = ed.result.sequence!.events.find((e) => e.kind === 'note')! + ed.dispatch({ type: 'seq.moveEvent', eventId: moved.entityId, afterEvent: note.entityId }) + expect(ed.code).toBe(SEQ) + // dropping in place is a no-op + const first = ed.result.sequence!.events[0] + const res = ed.dispatch({ type: 'seq.moveEvent', eventId: first.entityId, afterEvent: null }) + expect(res?.edits).toEqual([]) + }) + + it('wrapInFragment wraps the event line in a block', () => { + const ed = new MermaidWysiwygEditor({ code: SEQ }) + const first = ed.result.sequence!.events[0] + ed.dispatch({ type: 'seq.wrapInFragment', eventId: first.entityId, kind: 'loop' }) + const lines = ed.code.split('\n') + expect(lines[3]).toBe(' loop Repeat') + expect(lines[4]).toBe(' Alice->>Bob: Hello Bob') + expect(lines[5]).toBe(' end') + // the wrapped message is still an editable event at its new line + expect(ed.selection).toEqual(['event:4']) + }) + + it('entity selection sync works for sequence', () => { + const ed = new MermaidWysiwygEditor({ code: SEQ }) + const offset = ed.code.indexOf('Hello Bob') + expect(ed.entityAt(offset)).toMatch(/^event:/) + ed.setSelection(['participant:Alice']) + expect(ed.selectionSpans().length).toBeGreaterThan(1) + }) +}) diff --git a/packages/core/test/state.test.ts b/packages/core/test/state.test.ts new file mode 100644 index 0000000..a344cf5 --- /dev/null +++ b/packages/core/test/state.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' +import { MermaidWysiwygEditor, parse } from '../src' + +const ST = `stateDiagram-v2 + [*] --> Still + Still --> [*] + Still --> Moving + Moving --> Still + Moving --> Crash: too fast + Crash --> [*] +` + +describe('state parsing', () => { + it('parses transitions, [*] pseudo-states, labels', () => { + const r = parse(ST) + expect(r.typeInfo?.id).toBe('state') + const g = r.state! + expect(g.states.map((s) => s.id).sort()).toEqual(['Crash', 'Moving', 'Still']) + expect(g.transitions).toHaveLength(6) + expect(g.transitions[0].source).toBe('[*]') + expect(g.transitions[4].label).toBe('too fast') + }) + + it('parses declarations, descriptions, composites, notes', () => { + const code = `stateDiagram-v2 + direction LR + state "On the move" as Moving + Idle: Waiting for input + state Config { + A --> B + } + note right of Idle + a block note + end note + Idle --> Moving +` + const g = parse(code).state! + expect(g.direction?.value).toBe('LR') + expect(g.stateById.get('Moving')!.label).toBe('On the move') + expect(g.stateById.get('Idle')!.label).toBe('Waiting for input') + expect(g.stateById.get('Config')!.isComposite).toBe(true) + expect(g.stateById.get('A')!.parent).toBe('Config') + // the note block body is preserved, not parsed as statements + expect(g.transitions.map((t) => `${t.source}->${t.target}`)).toEqual(['A->B', 'Idle->Moving']) + }) +}) + +describe('state ops', () => { + it('addState and connect', () => { + const ed = new MermaidWysiwygEditor({ code: ST }) + const res = ed.dispatch({ type: 'st.addState', label: 'Parked' }) + expect(res?.created).toEqual(['state:s1']) + expect(ed.code).toContain(' s1: Parked') + ed.dispatch({ type: 'st.connect', source: 'Still', target: 's1' }) + const lines = ed.code.trim().split('\n') + expect(lines[lines.length - 1]).toBe(' Still --> s1') + }) + + it('setStateLabel declares or edits a description', () => { + const ed = new MermaidWysiwygEditor({ code: ST }) + ed.dispatch({ type: 'st.setStateLabel', id: 'Still', label: 'Standing still' }) + expect(ed.code).toContain(' Still: Standing still') + ed.dispatch({ type: 'st.setStateLabel', id: 'Still', label: 'At rest' }) + expect(ed.code).toContain(' Still: At rest') + expect(ed.code).not.toContain('Standing still') + }) + + it('setTransitionLabel adds, edits, and removes', () => { + const ed = new MermaidWysiwygEditor({ code: ST }) + const t = ed.result.state!.transitions[2] // Still --> Moving + ed.dispatch({ type: 'st.setTransitionLabel', transId: t.entityId, label: 'accelerate' }) + expect(ed.code).toContain('Still --> Moving: accelerate') + const t2 = ed.result.state!.transitions[2] + ed.dispatch({ type: 'st.setTransitionLabel', transId: t2.entityId, label: '' }) + expect(ed.code).toContain('Still --> Moving\n') + }) + + it('deleteState removes declarations and touching transitions', () => { + const ed = new MermaidWysiwygEditor({ code: ST }) + ed.dispatch({ type: 'st.deleteState', id: 'Moving' }) + expect(ed.code).toBe(`stateDiagram-v2 + [*] --> Still + Still --> [*] + Crash --> [*] +`) + }) + + it('deleteState refuses composites (brace safety)', () => { + const code = `stateDiagram-v2 + state Config { + A --> B + } +` + const ed = new MermaidWysiwygEditor({ code }) + const res = ed.dispatch({ type: 'st.deleteState', id: 'Config' }) + expect(res).toBeNull() + expect(ed.code).toBe(code) + }) + + it('setDirection inserts or rewrites the direction statement', () => { + const ed = new MermaidWysiwygEditor({ code: ST }) + ed.dispatch({ type: 'st.setDirection', direction: 'LR' }) + expect(ed.code.split('\n')[1]).toBe(' direction LR') + ed.dispatch({ type: 'st.setDirection', direction: 'BT' }) + expect(ed.code.split('\n')[1]).toBe(' direction BT') + }) +}) diff --git a/packages/core/test/textpane.test.ts b/packages/core/test/textpane.test.ts new file mode 100644 index 0000000..171f9a6 --- /dev/null +++ b/packages/core/test/textpane.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from 'vitest' +import { MermaidWysiwygEditor, bindTextPane, type Span, type TextEdit } from '../src' + +/** minimal in-memory adapter, the same shape a Monaco or textarea binding would implement */ +function fakePane(initial: string) { + const pane = { + text: initial, + highlights: [] as Span[], + revealed: [] as number[], + applied: [] as TextEdit[][], + adapter: { + getText: () => pane.text, + applyEdits: (edits: TextEdit[]) => { + pane.applied.push(edits) + for (let i = edits.length - 1; i >= 0; i--) { + const e = edits[i] + pane.text = pane.text.slice(0, e.start) + e.text + pane.text.slice(e.end) + } + }, + setText: (text: string) => { + pane.text = text + }, + setHighlights: (spans: Span[]) => { + pane.highlights = spans + }, + revealPosition: (offset: number) => { + pane.revealed.push(offset) + }, + }, + } + return pane +} + +const CODE = 'flowchart TD\n A[Start] --> B[End]\n' + +describe('bindTextPane', () => { + it('applies engine changes to the pane as minimal edits', () => { + const editor = new MermaidWysiwygEditor({ code: CODE }) + const pane = fakePane(editor.code) + bindTextPane(editor, pane.adapter) + + editor.dispatch({ type: 'renameNode', id: 'A', label: 'Begin' }) + expect(pane.text).toBe(editor.code) + expect(pane.text).toContain('A[Begin]') + expect(pane.applied.length).toBe(1) + expect(pane.applied[0].length).toBe(1) + }) + + it('flows pane edits into the engine without echoing back', () => { + const editor = new MermaidWysiwygEditor({ code: CODE }) + const pane = fakePane(editor.code) + const binding = bindTextPane(editor, pane.adapter) + + pane.text = pane.text.replace('B[End]', 'B[Done]') + binding.notifyTextChange() + expect(editor.code).toBe(pane.text) + expect(pane.applied.length).toBe(0) + }) + + it('selects the entity under the caret', () => { + const editor = new MermaidWysiwygEditor({ code: CODE }) + const pane = fakePane(editor.code) + const binding = bindTextPane(editor, pane.adapter) + + binding.notifyCaretMove(editor.code.indexOf('A[Start]') + 1) + expect(editor.selection).toEqual(['node:A']) + expect(pane.highlights.length).toBeGreaterThan(0) + }) + + it('reveals canvas selections and routes undo to the engine', () => { + const editor = new MermaidWysiwygEditor({ code: CODE }) + const pane = fakePane(editor.code) + const binding = bindTextPane(editor, pane.adapter) + + editor.setSelection(['node:B'], 'canvas') + expect(pane.revealed.length).toBe(1) + + editor.dispatch({ type: 'renameNode', id: 'A', label: 'Begin' }) + binding.undo() + expect(editor.code).toBe(CODE) + expect(pane.text).toBe(CODE) + binding.redo() + expect(pane.text).toContain('A[Begin]') + }) + + it('hard-resyncs when the pane drifts', () => { + const editor = new MermaidWysiwygEditor({ code: CODE }) + const pane = fakePane('totally out of sync') + bindTextPane(editor, pane.adapter) + + editor.dispatch({ type: 'renameNode', id: 'A', label: 'Begin' }) + expect(pane.text).toBe(editor.code) + }) + + it('stops syncing after dispose', () => { + const editor = new MermaidWysiwygEditor({ code: CODE }) + const pane = fakePane(editor.code) + const binding = bindTextPane(editor, pane.adapter) + binding.dispose() + + editor.dispatch({ type: 'renameNode', id: 'A', label: 'Begin' }) + expect(pane.text).toBe(CODE) + }) +}) diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..685cb87 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022"] + }, + "include": ["src", "test"] +} diff --git a/packages/dom/README.md b/packages/dom/README.md new file mode 100644 index 0000000..ea25e93 --- /dev/null +++ b/packages/dom/README.md @@ -0,0 +1,18 @@ +# @visimer/dom + +Interactive WYSIWYG canvas for [Mermaid](https://mermaid.js.org): renders through your +`mermaid` instance for full fidelity, then overlays selection, drag-to-connect, +in-place text editing, entity popovers (shapes, arrow types, colors, cardinalities), +and keyboard editing, every gesture compiled to a minimal source edit by +[`@visimer/core`](https://npmjs.com/package/@visimer/core). + +```ts +import mermaid from 'mermaid' +import { MermaidWysiwygEditor } from '@visimer/core' +import { MermaidCanvasView } from '@visimer/dom' + +const editor = new MermaidWysiwygEditor({ code: 'flowchart TD\n A --> B' }) +new MermaidCanvasView({ editor, container: el, mermaid }) +``` + +Docs: [github.com/inkeep/visimer](https://github.com/inkeep/visimer) diff --git a/packages/dom/package.json b/packages/dom/package.json new file mode 100644 index 0000000..eb1521e --- /dev/null +++ b/packages/dom/package.json @@ -0,0 +1,57 @@ +{ + "name": "@visimer/dom", + "version": "0.1.0", + "description": "Interactive WYSIWYG canvas for Mermaid: renders through mermaid itself and overlays selection, drag-to-connect, inline label editing, and code sync.", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json", + "test": "vitest run", + "build": "tsup src/index.ts --format esm --dts --sourcemap --out-dir dist" + }, + "dependencies": { + "@visimer/core": "workspace:*" + }, + "devDependencies": { + "typescript": "^5.6.3", + "vitest": "^2.1.9", + "jsdom": "^25.0.1" + }, + "license": "MIT", + "files": [ + "dist", + "src" + ], + "publishConfig": { + "access": "public", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + } + }, + "author": "Inkeep", + "repository": { + "type": "git", + "url": "git+https://github.com/inkeep/visimer.git", + "directory": "packages/dom" + }, + "homepage": "https://github.com/inkeep/visimer#readme", + "bugs": "https://github.com/inkeep/visimer/issues", + "keywords": [ + "mermaid", + "wysiwyg", + "diagram", + "editor", + "flowchart", + "visual-editing" + ], + "sideEffects": false +} diff --git a/packages/dom/src/correlate.ts b/packages/dom/src/correlate.ts new file mode 100644 index 0000000..e18fc70 --- /dev/null +++ b/packages/dom/src/correlate.ts @@ -0,0 +1,529 @@ +import type { + ClassGraph, + ErGraph, + FlowGraph, + GanttGraph, + LineItemsGraph, + PieGraph, + SequenceGraph, + StateGraph, +} from '@visimer/core' + +export interface Correlation { + nodes: Map + edges: Map + edgeLabels: Map + /** entity ids that failed to correlate (degraded to view-only) */ + failed: string[] +} + +function esc(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** + * Correlate mermaid's rendered flowchart SVG with the semantic graph. + * Strategy: id conventions first (`flowchart--N`, `L___N`), + * then order matching as a fallback. Anything unmatched degrades to view-only. + */ +export function correlateFlowchart(svg: SVGSVGElement, graph: FlowGraph): Correlation { + const nodes = new Map() + const edges = new Map() + const edgeLabels = new Map() + const failed: string[] = [] + + const nodeEls = [...svg.querySelectorAll('g.node')] + const claimed = new Set() + + for (const node of graph.nodes) { + const re = new RegExp(`[-_]${esc(node.id)}[-_]\\d+$`) + const el = nodeEls.find((e) => !claimed.has(e) && re.test(e.id)) + if (el) { + nodes.set(node.entityId, el) + claimed.add(el) + el.setAttribute('data-mw-entity', node.entityId) + } + } + // fallback: order matching for any leftovers + const unmatchedNodes = graph.nodes.filter((n) => !nodes.has(n.entityId)) + const unclaimedEls = nodeEls.filter((e) => !claimed.has(e)) + if (unmatchedNodes.length === unclaimedEls.length) { + unmatchedNodes.forEach((n, i) => { + nodes.set(n.entityId, unclaimedEls[i]) + unclaimedEls[i].setAttribute('data-mw-entity', n.entityId) + }) + } else { + for (const n of unmatchedNodes) failed.push(n.entityId) + } + + const edgeEls = [...svg.querySelectorAll('.edgePaths > path, path.flowchart-link')] + const claimedEdges = new Set() + for (const edge of graph.edges) { + const re = new RegExp(`^L[-_]${esc(edge.source)}[-_]${esc(edge.target)}[-_]\\d+`) + const el = edgeEls.find((e) => !claimedEdges.has(e) && re.test(e.id)) + if (el) { + edges.set(edge.entityId, el) + claimedEdges.add(el) + el.setAttribute('data-mw-entity', edge.entityId) + } + } + const unmatchedEdges = graph.edges.filter((e) => !edges.has(e.entityId)) + const unclaimedEdgeEls = edgeEls.filter((e) => !claimedEdges.has(e)) + if (unmatchedEdges.length === unclaimedEdgeEls.length) { + unmatchedEdges.forEach((e, i) => { + edges.set(e.entityId, unclaimedEdgeEls[i]) + unclaimedEdgeEls[i].setAttribute('data-mw-entity', e.entityId) + }) + } else { + for (const e of unmatchedEdges) failed.push(e.entityId) + } + + // subgraphs render as clusters; their ids embed the subgraph id + for (const sg of graph.subgraphs) { + const cluster = [...svg.querySelectorAll('g.cluster')].find( + (el) => el.id === sg.id || el.id.includes(sg.id) || el.querySelector('.cluster-label')?.textContent?.trim() === (sg.title ?? sg.id), + ) + if (cluster && !cluster.hasAttribute('data-mw-entity')) { + cluster.setAttribute('data-mw-entity', sg.entityId) + nodes.set(sg.entityId, cluster) + } + } + + // edge labels render in edge order; mermaid emits one .edgeLabel per edge + const labelEls = [...svg.querySelectorAll('.edgeLabels > .edgeLabel')] + if (labelEls.length === graph.edges.length) { + graph.edges.forEach((e, i) => { + edgeLabels.set(e.entityId, labelEls[i]) + labelEls[i].setAttribute('data-mw-entity', e.entityId) + }) + } else { + const labeled = graph.edges.filter((e) => e.label !== null && e.label !== '') + if (labelEls.length === labeled.length) { + labeled.forEach((e, i) => { + edgeLabels.set(e.entityId, labelEls[i]) + labelEls[i].setAttribute('data-mw-entity', e.entityId) + }) + } + } + + return { nodes, edges, edgeLabels, failed } +} + +/** + * Correlate mermaid's state-diagram SVG (v2 renders through the same + * dagre wrapper as flowcharts). Returns the flowchart-shaped Correlation so + * the same canvas interactions apply; `[*]` pseudo-states stay view-only. + */ +export function correlateState(svg: SVGSVGElement, graph: StateGraph): Correlation { + const nodes = new Map() + const edges = new Map() + const edgeLabels = new Map() + const failed: string[] = [] + + const nodeEls = [...svg.querySelectorAll('g.node')].filter( + (el) => !/root_start|root_end/.test(el.id), + ) + const claimed = new Set() + for (const state of graph.states) { + const re = new RegExp(`[-_]${esc(state.id)}[-_]\\d+$`) + const el = nodeEls.find((e) => !claimed.has(e) && re.test(e.id)) + if (el) { + nodes.set(state.entityId, el) + claimed.add(el) + el.setAttribute('data-mw-entity', state.entityId) + } + } + const unmatched = graph.states.filter((s) => !nodes.has(s.entityId)) + const unclaimed = nodeEls.filter((e) => !claimed.has(e)) + if (unmatched.length === unclaimed.length) { + unmatched.forEach((s, i) => { + nodes.set(s.entityId, unclaimed[i]) + unclaimed[i].setAttribute('data-mw-entity', s.entityId) + }) + } else { + for (const s of unmatched) failed.push(s.entityId) + } + + // composite states render as clusters + for (const state of graph.states.filter((s) => s.isComposite)) { + const cluster = [...svg.querySelectorAll('g.cluster')].find( + (el) => el.id.includes(state.id) || el.querySelector('.cluster-label, .label-container + *')?.textContent?.trim() === state.label, + ) + if (cluster && !cluster.hasAttribute('data-mw-entity')) { + cluster.setAttribute('data-mw-entity', state.entityId) + nodes.set(state.entityId, cluster) + } + } + + // transitions render in document order + const edgeEls = [...svg.querySelectorAll('.edgePaths > path, path.transition')] + if (edgeEls.length === graph.transitions.length) { + graph.transitions.forEach((t, i) => { + edges.set(t.entityId, edgeEls[i]) + edgeEls[i].setAttribute('data-mw-entity', t.entityId) + }) + } else { + for (const t of graph.transitions) failed.push(t.entityId) + } + + const labelEls = [...svg.querySelectorAll('.edgeLabels > .edgeLabel')] + if (labelEls.length === graph.transitions.length) { + graph.transitions.forEach((t, i) => { + edgeLabels.set(t.entityId, labelEls[i]) + labelEls[i].setAttribute('data-mw-entity', t.entityId) + }) + } else { + const labeled = graph.transitions.filter((t) => t.label) + if (labelEls.length === labeled.length) { + labeled.forEach((t, i) => { + edgeLabels.set(t.entityId, labelEls[i]) + labelEls[i].setAttribute('data-mw-entity', t.entityId) + }) + } + } + + return { nodes, edges, edgeLabels, failed } +} + +/** Correlate mermaid's class-diagram SVG (dagre wrapper, like flowcharts). */ +export function correlateClass(svg: SVGSVGElement, graph: ClassGraph): Correlation { + const nodes = new Map() + const edges = new Map() + const edgeLabels = new Map() + const failed: string[] = [] + + const nodeEls = [...svg.querySelectorAll('g.node')] + const claimed = new Set() + for (const cls of graph.classes) { + const re = new RegExp(`[-_]${esc(cls.id)}[-_]\\d+$`) + const el = nodeEls.find((e) => !claimed.has(e) && re.test(e.id)) + if (el) { + nodes.set(cls.entityId, el) + claimed.add(el) + el.setAttribute('data-mw-entity', cls.entityId) + } + } + const unmatched = graph.classes.filter((c) => !nodes.has(c.entityId)) + const unclaimed = nodeEls.filter((e) => !claimed.has(e)) + if (unmatched.length === unclaimed.length) { + unmatched.forEach((c, i) => { + nodes.set(c.entityId, unclaimed[i]) + unclaimed[i].setAttribute('data-mw-entity', c.entityId) + }) + } else { + for (const c of unmatched) failed.push(c.entityId) + } + + // tag member label rows for double-click editing (mermaid displays + // attributes first, then methods, after the class-name label) + for (const cls of graph.classes) { + const el = nodes.get(cls.entityId) + if (!el) continue + const labels = [...el.querySelectorAll('.nodeLabel')] + const attrs = cls.members.filter((m) => !m.text.includes('(')) + const methods = cls.members.filter((m) => m.text.includes('(')) + const displayed = [...attrs, ...methods] + displayed.forEach((m, i) => { + const lab = labels[i + 1] + if (lab) { + lab.setAttribute('data-mw-member-class', cls.id) + lab.setAttribute('data-mw-member-line', String(m.lineIndex)) + } + }) + } + + const edgeEls = [...svg.querySelectorAll('.edgePaths > path, path.relation')] + if (edgeEls.length === graph.relations.length) { + graph.relations.forEach((r, i) => { + edges.set(r.entityId, edgeEls[i]) + edgeEls[i].setAttribute('data-mw-entity', r.entityId) + }) + } else { + for (const r of graph.relations) failed.push(r.entityId) + } + + const labelEls = [...svg.querySelectorAll('.edgeLabels > .edgeLabel')] + if (labelEls.length === graph.relations.length) { + graph.relations.forEach((r, i) => { + edgeLabels.set(r.entityId, labelEls[i]) + labelEls[i].setAttribute('data-mw-entity', r.entityId) + }) + } + + return { nodes, edges, edgeLabels, failed } +} + +/** Correlate mermaid's ER SVG: entity groups carry `entity-` prefixed ids. */ +export function correlateEr(svg: SVGSVGElement, graph: ErGraph): Correlation { + const nodes = new Map() + const edges = new Map() + const edgeLabels = new Map() + const failed: string[] = [] + + const nodeEls = [...svg.querySelectorAll('g[id*="entity"], g.node')] + const claimed = new Set() + for (const entity of graph.entities) { + const re = new RegExp(`entity-${esc(entity.id)}-|[-_]${esc(entity.id)}[-_]\\d+$`) + const el = nodeEls.find((e) => !claimed.has(e) && re.test(e.id)) + if (el) { + nodes.set(entity.entityId, el) + claimed.add(el) + el.setAttribute('data-mw-entity', entity.entityId) + } + } + const unmatched = graph.entities.filter((e) => !nodes.has(e.entityId)) + const unclaimed = nodeEls.filter((e) => !claimed.has(e)) + if (unmatched.length && unmatched.length === unclaimed.length) { + unmatched.forEach((e, i) => { + nodes.set(e.entityId, unclaimed[i]) + unclaimed[i].setAttribute('data-mw-entity', e.entityId) + }) + } else { + for (const e of unmatched) failed.push(e.entityId) + } + + // tag attribute rows (4 .nodeLabel cells per attribute, after the name label) + for (const entity of graph.entities) { + const el = nodes.get(entity.entityId) + if (!el || !entity.attributes.length) continue + const labels = [...el.querySelectorAll('.nodeLabel')] + entity.attributes.forEach((attr, i) => { + for (let cell = 0; cell < 4; cell++) { + const lab = labels[1 + i * 4 + cell] + if (lab) { + lab.setAttribute('data-mw-attr-entity', entity.id) + lab.setAttribute('data-mw-attr-line', String(attr.lineIndex)) + } + } + }) + } + + const edgeEls = [ + ...svg.querySelectorAll('path.relationshipLine, .er.relationshipLine, .edgePaths > path'), + ] + if (edgeEls.length === graph.relations.length) { + graph.relations.forEach((r, i) => { + edges.set(r.entityId, edgeEls[i]) + edgeEls[i].setAttribute('data-mw-entity', r.entityId) + }) + } else { + for (const r of graph.relations) failed.push(r.entityId) + } + + const labelEls = [ + ...svg.querySelectorAll('.relationshipLabel, .er.relationshipLabel, .edgeLabels > .edgeLabel'), + ] + if (labelEls.length === graph.relations.length) { + graph.relations.forEach((r, i) => { + edgeLabels.set(r.entityId, labelEls[i]) + labelEls[i].setAttribute('data-mw-entity', r.entityId) + }) + } + + return { nodes, edges, edgeLabels, failed } +} + +/** + * Correlate mermaid's pie SVG. Mermaid sorts slices by value before drawing, + * so index matching against the document fails — but the legend rows and the + * slice paths share d3's draw order, and legend text carries the label. + */ +export function correlatePie(svg: SVGSVGElement, graph: PieGraph): Correlation { + const nodes = new Map() + const edges = new Map() + const edgeLabels = new Map() + const failed: string[] = [] + + const paths = [...svg.querySelectorAll('.pieCircle')] + const legends = [...svg.querySelectorAll('g.legend')] + legends.forEach((legend, i) => { + const text = legend.querySelector('text')?.textContent?.trim() ?? '' + const slice = graph.slices.find((s) => s.label === text || text.startsWith(`${s.label} `)) + if (!slice) return + const path = paths[i] + if (path && !nodes.has(slice.entityId)) { + nodes.set(slice.entityId, path) + path.setAttribute('data-mw-entity', slice.entityId) + legend.setAttribute('data-mw-entity', slice.entityId) + } + }) + for (const s of graph.slices) if (!nodes.has(s.entityId)) failed.push(s.entityId) + + return { nodes, edges, edgeLabels, failed } +} + +/** Correlate mermaid's gantt SVG: task bars render in document order. */ +export function correlateGantt(svg: SVGSVGElement, graph: GanttGraph): Correlation { + const nodes = new Map() + const edges = new Map() + const edgeLabels = new Map() + const failed: string[] = [] + + const bars = [...svg.querySelectorAll('rect.task')] + const texts = [...svg.querySelectorAll('text.taskText')] + if (bars.length === graph.tasks.length) { + graph.tasks.forEach((t, i) => { + nodes.set(t.entityId, bars[i]) + bars[i].setAttribute('data-mw-entity', t.entityId) + texts[i]?.setAttribute('data-mw-entity', t.entityId) + }) + } else { + for (const t of graph.tasks) failed.push(t.entityId) + } + return { nodes, edges, edgeLabels, failed } +} + +/** + * Generic correlation for line-item chart types: match each item's rendered + * label by text content against the SVG's text-bearing elements. Diagram-type + * agnostic — items whose label can't be found stay code-only. + */ +export function correlateLineItems(svg: SVGSVGElement, graph: LineItemsGraph): Correlation { + const nodes = new Map() + const edges = new Map() + const edgeLabels = new Map() + const failed: string[] = [] + + // leaf text carriers: svg / and html label spans + const candidates = [...svg.querySelectorAll('text, span, p, div')].filter( + (el) => el.children.length === 0 || el.tagName === 'text', + ) + const claimed = new Set() + + for (const item of graph.items) { + if (!item.matchText) { + failed.push(item.entityId) + continue + } + const target = item.matchText.trim() + let el = + candidates.find((c) => !claimed.has(c) && c.textContent?.trim() === target) ?? + candidates.find((c) => !claimed.has(c) && (c.textContent?.trim().startsWith(target) ?? false)) + if (!el) { + failed.push(item.entityId) + continue + } + claimed.add(el) + // tag a small ancestor group when available so hit areas include shapes + let anchor: Element = el + const parent = el.closest('g') ?? el.parentElement + if (parent && parent !== svg && (parent.textContent?.trim().length ?? 0) <= target.length + 24) { + anchor = parent + } + anchor.setAttribute('data-mw-entity', item.entityId) + if (anchor !== el) el.setAttribute('data-mw-entity', item.entityId) + // several chart renderers disable pointer events on labels — re-enable so + // the item is clickable (an explicit 'auto' wins over an ancestor 'none') + for (const target of new Set([anchor, el])) { + const style = (target as HTMLElement | SVGElement).style + if (style) style.pointerEvents = 'auto' + } + nodes.set(item.entityId, anchor as SVGGElement) + } + + return { nodes, edges, edgeLabels, failed } +} + +export interface SequenceCorrelation { + /** participant entity id → every SVG element belonging to it (top + bottom boxes) */ + participants: Map + /** participant entity id → lifeline element */ + lifelines: Map + /** event entity id → primary element (message line / note rect) */ + events: Map + /** event entity id → its text element */ + eventTexts: Map + failed: string[] +} + +/** + * Correlate mermaid's sequence-diagram SVG. Mermaid renders messages, notes and + * actors in document order, so index matching is reliable; actor elements also + * carry a `name` attribute we can key on. + */ +export function correlateSequence(svg: SVGSVGElement, graph: SequenceGraph): SequenceCorrelation { + const participants = new Map() + const lifelines = new Map() + const events = new Map() + const eventTexts = new Map() + const failed: string[] = [] + + // --- participants: mermaid stamps `name=""` on actor rects/paths/lines + for (const p of graph.participants) { + const els = [...svg.querySelectorAll(`[name="${CSS.escape(p.id)}"]`)] + if (els.length) { + participants.set(p.entityId, els) + for (const el of els) el.setAttribute('data-mw-entity', p.entityId) + // also tag the text label drawn next to each actor element + for (const el of els) { + const g = el.parentElement + if (g && (g as Element).tagName === 'g') (g as Element).setAttribute('data-mw-entity', p.entityId) + } + } else { + failed.push(p.entityId) + } + } + + // --- lifelines: match by x proximity to each participant's box (DOM order + // of `line.actor-line` is NOT guaranteed to follow declaration order) + const lifelineEls = [...svg.querySelectorAll('line.actor-line, .actor-line')] + for (const p of graph.participants) { + const els = participants.get(p.entityId) + if (!els?.length) continue + const box = (els[0] as SVGGraphicsElement).getBoundingClientRect() + const cx = box.left + box.width / 2 + let best: SVGLineElement | null = null + let bestDist = Infinity + for (const l of lifelineEls) { + const r = l.getBoundingClientRect() + const d = Math.abs(r.left + r.width / 2 - cx) + if (d < bestDist) { + bestDist = d + best = l as SVGLineElement + } + } + if (best && bestDist < 40) lifelines.set(p.entityId, best) + } + + // --- messages: text.messageText + messageLine paths, both in message order + const messages = graph.events.filter((e) => e.kind === 'message') + const msgTexts = [...svg.querySelectorAll('text.messageText')] + const msgLines = [...svg.querySelectorAll('.messageLine0, .messageLine1')] + messages.forEach((ev, i) => { + const line = msgLines[i] + const text = msgTexts[i] + if (line) { + events.set(ev.entityId, line) + line.setAttribute('data-mw-entity', ev.entityId) + } else { + failed.push(ev.entityId) + } + if (text) { + eventTexts.set(ev.entityId, text) + text.setAttribute('data-mw-entity', ev.entityId) + } + }) + + // --- notes: rect.note + text.noteText in note order + const notes = graph.events.filter((e) => e.kind === 'note') + const noteRects = [...svg.querySelectorAll('.note')] + const noteTexts = [...svg.querySelectorAll('.noteText')] + notes.forEach((ev, i) => { + const rect = noteRects[i] + const text = noteTexts[i] + if (rect) { + events.set(ev.entityId, rect) + rect.setAttribute('data-mw-entity', ev.entityId) + const g = rect.parentElement + if (g && (g as Element).tagName === 'g') (g as Element).setAttribute('data-mw-entity', ev.entityId) + } else { + failed.push(ev.entityId) + } + if (text) { + eventTexts.set(ev.entityId, text) + text.setAttribute('data-mw-entity', ev.entityId) + } + }) + + return { participants, lifelines, events, eventTexts, failed } +} diff --git a/packages/dom/src/icons.ts b/packages/dom/src/icons.ts new file mode 100644 index 0000000..189d197 --- /dev/null +++ b/packages/dom/src/icons.ts @@ -0,0 +1,54 @@ +/** Inline Lucide icons (https://lucide.dev, ISC) used by the popover UI. */ + +const icon = (paths: string) => + `` + +export const ICONS = { + trash: icon( + '', + ), + pencil: icon( + '', + ), + palette: icon( + '', + ), + copy: icon( + '', + ), + shapes: icon( + '', + ), + arrowRight: icon(''), + arrowLeftRight: icon(''), + arrowLeftToLine: icon(''), + arrowRightToLine: icon(''), + square: icon(''), + stickyNote: icon( + '', + ), + frame: icon( + '', + ), + group: icon( + '', + ), + listPlus: icon(''), + braces: icon( + '', + ), + hash: icon( + '', + ), + calendar: icon( + '', + ), + user: icon(''), + minus: icon(''), + plus: icon(''), + maximize: icon( + '', + ), + ellipsis: icon(''), + chevronDown: icon(''), +} diff --git a/packages/dom/src/index.ts b/packages/dom/src/index.ts new file mode 100644 index 0000000..1276ba7 --- /dev/null +++ b/packages/dom/src/index.ts @@ -0,0 +1,3 @@ +export { MermaidCanvasView, type ViewOptions, type ViewHooks, type Tool, type MermaidLike } from './view' +export { correlateFlowchart, correlateSequence, type Correlation, type SequenceCorrelation } from './correlate' +export { Popover, type PopoverAction, type PopoverPanelItem } from './popover' diff --git a/packages/dom/src/popover.ts b/packages/dom/src/popover.ts new file mode 100644 index 0000000..aabf005 --- /dev/null +++ b/packages/dom/src/popover.ts @@ -0,0 +1,247 @@ +/** Floating entity-action popover, anchored above a canvas element (mermaid.ai-style). */ + +import { ICONS } from './icons' + +export interface PopoverAction { + /** small text glyph shown on the button (fallback when no icon) */ + glyph?: string + /** inline svg icon markup (see icons.ts); takes precedence over glyph */ + icon?: string + title: string + onClick?: () => void + /** opens a secondary panel instead of firing directly */ + panel?: PopoverPanel + active?: boolean + danger?: boolean +} + +export interface PopoverPanelItem { + /** text glyph for the cell */ + glyph?: string + /** color swatch cell (takes precedence over glyph) */ + swatch?: string + title: string + selected?: boolean + onClick: () => void +} + +export interface PopoverPanelSection { + title?: string + items: PopoverPanelItem[] + /** grid columns for this section (default 3; swatch rows look best at 8) */ + columns?: number +} + +export interface PopoverPanel { + title: string + items?: PopoverPanelItem[] + sections?: PopoverPanelSection[] +} + +export class Popover { + private host: HTMLElement + private el: HTMLDivElement | null = null + private panelEl: HTMLDivElement | null = null + + constructor(host: HTMLElement) { + this.host = host + } + + get isOpen(): boolean { + return this.el !== null + } + + /** anchorRect is in host-relative coordinates */ + show(anchorRect: { left: number; top: number; width: number; height?: number }, actions: PopoverAction[]) { + this.hide() + const el = document.createElement('div') + el.className = 'mw-popover' + el.addEventListener('pointerdown', (e) => e.stopPropagation()) + for (const action of actions) { + const btn = document.createElement('button') + btn.type = 'button' + btn.className = 'mw-popover-btn' + (action.active ? ' active' : '') + (action.danger ? ' danger' : '') + btn.title = action.title + if (action.icon) btn.innerHTML = action.icon + else { + btn.classList.add('text') + btn.textContent = action.glyph ?? '' + } + if (action.panel) { + btn.classList.add('has-panel') + const caret = document.createElement('span') + caret.className = 'mw-caret' + caret.innerHTML = ICONS.chevronDown + btn.appendChild(caret) + } + btn.addEventListener('click', (e) => { + e.stopPropagation() + if (action.panel) this.togglePanel(action.panel) + else { + this.hide() + action.onClick?.() + } + }) + el.appendChild(btn) + } + this.host.appendChild(el) + this.el = el + // hosts commonly clip at their bounds (overflow: hidden) — keep the + // toolbar inside them: clamp horizontally, and flip below the anchor + // when there is no headroom above it + const bounds = this.host.parentElement + let centerX = anchorRect.left + anchorRect.width / 2 + const half = el.offsetWidth / 2 + if (bounds && bounds.clientWidth > el.offsetWidth + 8) { + centerX = Math.min(Math.max(centerX, half + 4 + bounds.scrollLeft), bounds.scrollLeft + bounds.clientWidth - half - 4) + } + el.style.left = `${centerX}px` + const above = anchorRect.top - 8 + if (above - el.offsetHeight < 4) { + el.classList.add('mw-popover-below') + el.style.top = `${anchorRect.top + (anchorRect.height ?? 0) + 8}px` + } else { + el.style.top = `${above}px` + } + } + + private togglePanel(panel: PopoverPanel) { + if (this.panelEl) { + this.panelEl.remove() + this.panelEl = null + return + } + const p = document.createElement('div') + p.className = 'mw-popover-panel' + const title = document.createElement('div') + title.className = 'mw-popover-panel-title' + title.textContent = panel.title + p.appendChild(title) + + const sections: PopoverPanelSection[] = panel.sections ?? [{ items: panel.items ?? [] }] + for (const section of sections) { + if (section.title) { + const t = document.createElement('div') + t.className = 'mw-popover-section-title' + t.textContent = section.title + p.appendChild(t) + } + const grid = document.createElement('div') + grid.className = 'mw-popover-grid' + grid.style.gridTemplateColumns = `repeat(${section.columns ?? 3}, 1fr)` + for (const item of section.items) { + const b = document.createElement('button') + b.type = 'button' + b.className = 'mw-popover-cell' + (item.selected ? ' selected' : '') + (item.swatch ? ' swatch' : '') + b.title = item.title + if (item.swatch) { + const dot = document.createElement('span') + dot.className = 'mw-swatch-dot' + if (item.swatch === 'none') dot.classList.add('none') + else dot.style.background = item.swatch + b.appendChild(dot) + } else { + b.textContent = item.glyph ?? '' + } + b.addEventListener('click', (e) => { + e.stopPropagation() + this.hide() + item.onClick() + }) + grid.appendChild(b) + } + p.appendChild(grid) + } + + this.el!.appendChild(p) + this.panelEl = p + // flip below the toolbar if the panel would clip past the top of the host + const hostTop = this.host.getBoundingClientRect().top + if (p.getBoundingClientRect().top < hostTop + 2) p.classList.add('below') + } + + hide() { + this.el?.remove() + this.el = null + this.panelEl = null + } +} + +export const POPOVER_CSS = ` +.mw-popover.mw-popover-below { transform: translate(-50%, 0); } +.mw-popover { + position: absolute; z-index: 20; transform: translate(-50%, -100%); + display: flex; align-items: center; gap: 2px; padding: 4px; + background: var(--mw-chrome-bg, #0a0a0a); border: 1px solid var(--mw-chrome-border, #333); + border-radius: 8px; box-shadow: 0 0 0 1px rgba(0,0,0,0.4), 0 8px 30px rgba(0,0,0,0.45); + font: 13px/1 -apple-system, "Geist", "Inter", "Segoe UI", ui-sans-serif, system-ui, sans-serif; +} +.mw-popover-btn { + border: none; background: none; border-radius: 6px; cursor: pointer; + height: 28px; min-width: 28px; padding: 0 6px; + display: inline-flex; align-items: center; justify-content: center; gap: 3px; + color: var(--mw-chrome-dim, #a1a1a1); line-height: 1; + transition: background 0.12s, color 0.12s; +} +.mw-popover-btn svg { width: 15px; height: 15px; flex: none; } +.mw-popover-btn.text { font-size: 11.5px; font-weight: 500; letter-spacing: 0.01em; } +.mw-popover-btn .mw-caret { display: inline-flex; opacity: 0.55; margin-left: -1px; } +.mw-popover-btn .mw-caret svg { width: 9px; height: 9px; } +.mw-popover-btn:hover { background: var(--mw-chrome-hover, #1f1f1f); color: var(--mw-chrome-fg, #ededed); } +.mw-popover-btn.active { background: var(--mw-accent, #0070f3); color: #fff; } +.mw-popover-btn.danger:hover { background: rgba(255, 68, 68, 0.12); color: #ff4444; } +.mw-popover-panel { + position: absolute; bottom: calc(100% + 6px); left: 50%; transform: translateX(-50%); + background: var(--mw-chrome-bg, #0a0a0a); border: 1px solid var(--mw-chrome-border, #333); + border-radius: 8px; box-shadow: 0 0 0 1px rgba(0,0,0,0.4), 0 8px 30px rgba(0,0,0,0.45); + padding: 6px; min-width: 140px; +} +.mw-popover-panel.below { bottom: auto; top: calc(100% + 6px); } +.mw-popover-panel-title { + font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.07em; + color: var(--mw-chrome-dim, #666); padding: 3px 5px 7px; white-space: nowrap; +} +.mw-popover-section-title { + font-size: 10px; font-weight: 500; text-transform: uppercase; letter-spacing: 0.06em; + color: var(--mw-chrome-dim, #666); padding: 6px 5px 4px; white-space: nowrap; +} +.mw-popover-grid { display: grid; gap: 2px; } +.mw-popover-cell { + border: 1px solid transparent; background: none; border-radius: 6px; padding: 7px 8px; + cursor: pointer; font-size: 12px; color: var(--mw-chrome-dim, #a1a1a1); white-space: nowrap; + transition: background 0.12s, color 0.12s; +} +.mw-popover-cell:hover { background: var(--mw-chrome-hover, #1f1f1f); color: var(--mw-chrome-fg, #ededed); } +.mw-popover-cell.selected { + border-color: var(--mw-accent, #0070f3); color: var(--mw-chrome-fg, #ededed); + background: color-mix(in srgb, var(--mw-accent, #0070f3) 12%, transparent); +} +.mw-popover-cell.swatch { padding: 5px; display: flex; align-items: center; justify-content: center; } +.mw-swatch-dot { width: 15px; height: 15px; border-radius: 99px; display: block; border: 1px solid rgba(255,255,255,0.18); } +.mw-swatch-dot.none { + background: linear-gradient(to top left, transparent 45%, #ff4444 46%, #ff4444 54%, transparent 55%); + border: 1px solid var(--mw-chrome-dim, #666); +} +.mw-lifeline-plus { + position: absolute; z-index: 15; transform: translate(-50%, -50%); + width: 18px; height: 18px; border-radius: 99px; border: none; cursor: pointer; + background: var(--mw-accent, #0070f3); color: #fff; font-size: 13px; line-height: 1; + display: flex; align-items: center; justify-content: center; + box-shadow: 0 2px 8px rgba(0,0,0,0.5); + transition: transform 0.12s; +} +.mw-lifeline-plus:hover { transform: translate(-50%, -50%) scale(1.15); } +.mw-plus-menu { + position: absolute; z-index: 21; transform: translate(-50%, 4px); + background: var(--mw-chrome-bg, #0a0a0a); border: 1px solid var(--mw-chrome-border, #333); + border-radius: 8px; box-shadow: 0 0 0 1px rgba(0,0,0,0.4), 0 8px 30px rgba(0,0,0,0.45); padding: 4px; + display: flex; flex-direction: column; min-width: 150px; + font: 13px/1.2 -apple-system, "Geist", "Inter", "Segoe UI", ui-sans-serif, system-ui, sans-serif; +} +.mw-plus-menu button { + border: none; background: none; text-align: left; border-radius: 6px; padding: 7px 10px; + cursor: pointer; color: var(--mw-chrome-dim, #a1a1a1); font-size: 12.5px; + transition: background 0.12s, color 0.12s; +} +.mw-plus-menu button:hover { background: var(--mw-chrome-hover, #1f1f1f); color: var(--mw-chrome-fg, #ededed); } +` diff --git a/packages/dom/src/view.ts b/packages/dom/src/view.ts new file mode 100644 index 0000000..0bf02ec --- /dev/null +++ b/packages/dom/src/view.ts @@ -0,0 +1,2146 @@ +import { + MermaidWysiwygEditor, + MESSAGE_OPS, + PARTICIPANT_TYPES, + type Diagnostic, + type ShapeId, + type EdgeLine, + type EdgeArrow, + type MessageOp, + type ParticipantType, +} from '@visimer/core' +import { + correlateClass, + correlateEr, + correlateFlowchart, + correlateGantt, + correlateLineItems, + correlatePie, + correlateSequence, + correlateState, + type Correlation, + type SequenceCorrelation, +} from './correlate' +import { + Popover, + POPOVER_CSS, + type PopoverAction, + type PopoverPanelItem, + type PopoverPanelSection, +} from './popover' +import { ICONS } from './icons' + +/** default swatches offered in the color panels (works on light and dark themes) */ +export const COLOR_PALETTE = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#6366f1', '#d946ef', '#64748b'] + +export interface MermaidLike { + initialize(config: Record): void + render(id: string, code: string): Promise<{ svg: string; bindFunctions?: (el: Element) => void }> + parse(code: string, options?: Record): Promise +} + +export type Tool = 'select' | 'connect' + +export interface ViewHooks { + /** return true to suppress the built-in inline label editor */ + onEntityDoubleClick?: (entityId: string) => boolean | void + onEntityClick?: (entityId: string, event: MouseEvent) => void +} + +export interface ViewOptions { + editor: MermaidWysiwygEditor + container: HTMLElement + mermaid: MermaidLike + /** merged into mermaid.initialize; e.g. { theme: 'dark' } */ + mermaidConfig?: Record + debounceMs?: number + readOnly?: boolean + /** + * Canvas pan/zoom: fit-to-canvas on first render, drag empty space to pan, + * ctrl/cmd+wheel (or trackpad pinch) to zoom, plus corner zoom controls. + */ + panZoom?: boolean + /** CSS color for selection/hover/ghost-edge chrome */ + accentColor?: string + /** kind of edge created by drag-to-connect */ + defaultEdge?: { line?: EdgeLine; arrowEnd?: EdgeArrow } + hooks?: ViewHooks +} + +type ViewEventMap = { + render: { ok: boolean; error?: string } + toolChange: Tool + connectPreview: { source: string | null } +} + +const BASE_CSS = ` +.mw-canvas { position: relative; overflow: auto; outline: none; } +.mw-canvas .mw-svg-host { user-select: none; -webkit-user-select: none; } +.mw-canvas .mw-svg-host { min-height: 100%; display: flex; align-items: flex-start; justify-content: center; padding: 16px; box-sizing: border-box; } +.mw-canvas svg { max-width: 100%; height: auto; } +.mw-canvas.mw-panzoom { overflow: hidden; } +.mw-canvas.mw-panzoom .mw-svg-host { position: absolute; inset: 0; display: block; padding: 0; min-height: 0; overflow: hidden; } +.mw-canvas.mw-panzoom .mw-svg-host svg { position: absolute; left: 0; top: 0; max-width: none; height: auto; transform-origin: 0 0; } +.mw-canvas.mw-panzoom .mw-svg-host { cursor: grab; } +.mw-canvas.mw-panzoom.mw-panning .mw-svg-host { cursor: grabbing; } +.mw-canvas.mw-panzoom [data-mw-entity] { cursor: pointer; } +.mw-zoom-controls { position: absolute; right: 10px; bottom: 10px; display: flex; gap: 4px; z-index: 6; } +.mw-zoom-btn { display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 0; border-radius: 7px; border: 1px solid var(--mw-chrome-border, rgba(128,128,128,.35)); background: var(--mw-chrome-bg, rgba(24,24,27,.92)); color: var(--mw-chrome-fg, #e4e4e7); cursor: pointer; } +.mw-zoom-btn:hover { background: var(--mw-chrome-hover, rgba(63,63,70,.9)); } +.mw-zoom-btn svg { width: 14px; height: 14px; } +.mw-canvas [data-mw-entity] { cursor: pointer; } +.mw-canvas.mw-readonly [data-mw-entity] { cursor: default; } +.mw-canvas.mw-tool-connect [data-mw-entity^="node:"] { cursor: crosshair; } +.mw-canvas [data-mw-entity]:hover { filter: drop-shadow(0 0 3px var(--mw-accent, #6366f1)); } +.mw-canvas .mw-selected { filter: drop-shadow(0 0 2px var(--mw-accent, #6366f1)) drop-shadow(0 0 5px var(--mw-accent, #6366f1)) !important; } +.mw-canvas .mw-ghost-edge { stroke: var(--mw-accent, #6366f1); stroke-width: 2; stroke-dasharray: 6 4; fill: none; pointer-events: none; } +.mw-canvas .mw-connect-source { filter: drop-shadow(0 0 5px var(--mw-accent, #6366f1)) !important; } +.mw-inplace-editor { + position: absolute; z-index: 10; white-space: pre; outline: none; + padding: 1px 3px; margin: -1px -3px; border-radius: 3px; + caret-color: var(--mw-accent, #6366f1); + box-shadow: 0 1.5px 0 0 var(--mw-accent, #6366f1); + background: color-mix(in srgb, var(--mw-editor-bg, #fff) 35%, transparent); +} +.mw-error-badge { + position: absolute; top: 8px; right: 8px; z-index: 5; + font: 500 11px/1.4 ui-sans-serif, system-ui, sans-serif; + background: #dc2626; color: #fff; border-radius: 6px; padding: 3px 8px; + max-width: 60%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.mw-drop-indicator { + position: absolute; left: 6%; right: 6%; height: 2.5px; z-index: 14; + background: var(--mw-accent, #6366f1); border-radius: 2px; pointer-events: none; + box-shadow: 0 0 6px var(--mw-accent, #6366f1); +} +.mw-canvas [contenteditable="true"] { + outline: none; cursor: text; white-space: pre-wrap; min-width: 8px; + caret-color: var(--mw-accent, #6366f1); +} +` + +let styleInjected = false +/** global counter so concurrent views never collide on mermaid's DOM ids */ +let renderIdCounter = 0 + +/** + * Read the in-place editor's label back to the string form the mermaid source + * carries. `textContent` alone drops `
` DOM elements to nothing, so a + * source label like `"3. Layout
dagre computes coordinates"` round-trips + * to `"3. Layoutdagre computes coordinates"` on a no-op blur — the browser's + * innerHTML preserves the break, and normalising the two forms mermaid emits + * (`
` on Chromium, occasional `
` on serializers) to the canonical + * source form `
` keeps the strict equality check in the caller + * meaningful. Trims outer whitespace to match the prior `.textContent.trim()` + * contract. Kept small and reversible — any richer HTML the user paste-injects + * flows through untouched (mermaid accepts a small allowlist of inline tags + * inside labels; the parser rejects anything it doesn't know). + */ +function readLabelHtml(label: HTMLElement): string { + return (label.innerHTML ?? '').replace(//gi, '
').trim() +} + +/** + * Absolute caret offset within `root`, counted across all of its text nodes. + * `Selection.anchorOffset` alone is relative to the anchor node — a label that + * the browser split into several text nodes (or that contains `
`) would + * restore the caret into the wrong word. + */ +function caretOffsetWithin(root: HTMLElement): number { + const sel = window.getSelection() + if (!sel?.anchorNode || !root.contains(sel.anchorNode)) return -1 + const range = document.createRange() + range.selectNodeContents(root) + range.setEnd(sel.anchorNode, sel.anchorOffset) + return range.toString().length +} + +/** place a collapsed caret at an absolute text offset within `root` */ +function placeCaretAt(root: HTMLElement, offset: number) { + const sel = window.getSelection() + if (!sel) return + const range = document.createRange() + let remaining = Math.max(0, offset) + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT) + let node = walker.nextNode() as Text | null + while (node) { + const len = node.textContent?.length ?? 0 + if (remaining <= len) { + range.setStart(node, remaining) + range.collapse(true) + sel.removeAllRanges() + sel.addRange(range) + return + } + remaining -= len + node = walker.nextNode() as Text | null + } + range.selectNodeContents(root) + range.collapse(false) + sel.removeAllRanges() + sel.addRange(range) +} + +/** + * Interactive canvas bound to a MermaidWysiwygEditor. + * Renders through mermaid itself (full fidelity) and overlays interaction: + * click-select, drag-to-connect, double-click inline label editing. + */ +export class MermaidCanvasView { + readonly editor: MermaidWysiwygEditor + readonly container: HTMLElement + private mermaid: MermaidLike + private mermaidConfig: Record + private svgHost: HTMLElement + private overlayHost: HTMLElement + private errorBadge: HTMLElement + private correlation: Correlation | null = null + private seqCorrelation: SequenceCorrelation | null = null + private popover: Popover + private plusButtons: HTMLElement[] = [] + private plusMenu: HTMLElement | null = null + private hoveredLifeline: string | null = null + private svg: SVGSVGElement | null = null + /** tracks external transforms on the svg (host pan/zoom toolbars) */ + private svgTransformObserver: MutationObserver | null = null + private popoverFollowRaf = 0 + /** our own pan/zoom writes must not trip the external-transform observer */ + private applyingOwnTransform = false + /** which of an entity's twin elements the user clicked (popover anchor) */ + private popoverAnchorPick: { entityId: string; index: number } | null = null + private tool: Tool = 'select' + private readOnly: boolean + private debounceMs: number + private defaultEdge: { line?: EdgeLine; arrowEnd?: EdgeArrow } + private hooks: ViewHooks + private renderTimer: ReturnType | null = null + private lastRenderedCode = '' + private disposers: Array<() => void> = [] + private listeners = new Map void>>() + private connectSource: string | null = null + private suppressNextClick = false + private ghostPath: SVGLineElement | null = null + private dragEvent: string | null = null + private dragStartY = 0 + private dragging = false + private dropIndicator: HTMLElement | null = null + private dropSlots: Array<{ y: number; afterEvent: string | null }> = [] + private inlineInput: HTMLElement | null = null + private inlineHidden: { el: HTMLElement | SVGElement; prevVisibility: string } | null = null + private pendingEditEntity: string | null = null + private inPlaceSession: { + entityId: string + labelSelector: string + original: string + lastCommitted: string + caretOffset: number + commitValue: (v: string) => void + liveTimer: ReturnType | null + /** the label element the session is currently attached to */ + label: HTMLElement | null + /** finish the session from outside the attach closure (commit or revert) */ + finish: ((commit: boolean) => void) | null + } | null = null + private lastError: string | null = null + /** a render was requested while an in-place edit was typing; applied on finish */ + private renderHeldByEdit = false + // pan/zoom state (only used when options.panZoom is set) + private panZoomEnabled: boolean + private zoomScale = 1 + private zoomTx = 0 + private zoomTy = 0 + /** the user panned/zoomed — keep their viewport across re-renders */ + private hasUserView = false + private svgSize: { width: number; height: number } | null = null + private panSession: { pointerId: number; x: number; y: number; tx: number; ty: number; moved: boolean } | null = null + private zoomControls: HTMLElement | null = null + /** per-instance staleness counter; a shared one would drop renders across instances */ + private renderSeq = 0 + + constructor(options: ViewOptions) { + this.editor = options.editor + this.container = options.container + this.mermaid = options.mermaid + this.readOnly = options.readOnly ?? false + this.panZoomEnabled = options.panZoom ?? false + this.debounceMs = options.debounceMs ?? 200 + this.defaultEdge = options.defaultEdge ?? {} + this.hooks = options.hooks ?? {} + this.mermaidConfig = { startOnLoad: false, ...options.mermaidConfig } + + if (!styleInjected) { + const style = document.createElement('style') + style.setAttribute('data-mw', '') + style.textContent = BASE_CSS + POPOVER_CSS + document.head.appendChild(style) + styleInjected = true + } + + this.container.classList.add('mw-canvas') + this.container.tabIndex = 0 + if (options.accentColor) this.container.style.setProperty('--mw-accent', options.accentColor) + if (this.readOnly) this.container.classList.add('mw-readonly') + + this.svgHost = document.createElement('div') + this.svgHost.className = 'mw-svg-host' + this.overlayHost = document.createElement('div') + this.errorBadge = document.createElement('div') + this.errorBadge.className = 'mw-error-badge' + this.errorBadge.style.display = 'none' + this.container.append(this.svgHost, this.overlayHost, this.errorBadge) + this.popover = new Popover(this.overlayHost) + + this.mermaid.initialize(this.mermaidConfig) + + this.disposers.push( + this.editor.on('change', () => this.scheduleRender()), + this.editor.on('selectionChange', () => this.applySelectionStyles()), + ) + + const keydown = (e: KeyboardEvent) => this.onKeyDown(e) + this.container.addEventListener('keydown', keydown) + this.disposers.push(() => this.container.removeEventListener('keydown', keydown)) + + // interacting anywhere outside the canvas deselects: the popover, the + // lifeline plus-menu, and any open editing session must not linger while + // the user works elsewhere (e.g. a host app's own pan controls) + const docPointerDown = (e: PointerEvent) => { + const target = e.target as Node | null + if (!target || this.container.contains(target)) return + this.inPlaceSession?.finish?.(true) + // the overlay editor commits itself via its own blur handler + this.clearLifelineUi() + this.popover.hide() + if (this.editor.selection.length) this.editor.clearSelection('canvas') + } + // capture phase: other widgets on the page (e.g. CodeMirror) stop the + // bubbling pointerdown before it would reach the document + document.addEventListener('pointerdown', docPointerDown, true) + this.disposers.push(() => document.removeEventListener('pointerdown', docPointerDown, true)) + + if (this.panZoomEnabled) this.setupPanZoom() + + void this.render() + } + + on(event: K, fn: (payload: ViewEventMap[K]) => void): () => void { + if (!this.listeners.has(event)) this.listeners.set(event, new Set()) + this.listeners.get(event)!.add(fn as never) + return () => this.listeners.get(event)!.delete(fn as never) + } + + private emit(event: K, payload: ViewEventMap[K]) { + this.listeners.get(event)?.forEach((fn) => (fn as (p: ViewEventMap[K]) => void)(payload)) + } + + get currentTool(): Tool { + return this.tool + } + + get renderError(): string | null { + return this.lastError + } + + setTool(tool: Tool) { + this.tool = tool + this.container.classList.toggle('mw-tool-connect', tool === 'connect') + this.cancelConnect() + this.emit('toolChange', tool) + } + + setReadOnly(readOnly: boolean) { + this.readOnly = readOnly + this.container.classList.toggle('mw-readonly', readOnly) + this.closeInlineEditor(false) + this.cancelConnect() + this.clearLifelineUi() + this.updatePopover() + } + + /** Replace the interaction hooks (bindings re-apply this when props change). */ + setHooks(hooks: ViewHooks) { + this.hooks = hooks + } + + setAccentColor(color: string) { + this.container.style.setProperty('--mw-accent', color) + } + + /** Re-initialize mermaid with new config (e.g. theme) and re-render. */ + setMermaidConfig(config: Record) { + this.mermaidConfig = { ...this.mermaidConfig, ...config } + this.mermaid.initialize(this.mermaidConfig) + this.lastRenderedCode = '' + void this.render() + } + + /** Add a node and immediately open its inline label editor. */ + addNode(shape: ShapeId = 'rect', label = 'New node') { + if (this.readOnly) return + const res = this.editor.dispatch({ type: 'addNode', shape, label }) + const created = res?.created?.[0] + if (created) this.pendingEditEntity = created + } + + private scheduleRender() { + if (this.renderTimer) clearTimeout(this.renderTimer) + this.renderTimer = setTimeout(() => void this.render(), this.debounceMs) + } + + // ----- pan / zoom ----- + + private setupPanZoom() { + this.container.classList.add('mw-panzoom') + + const onWheel = (e: WheelEvent) => { + // trackpad pinch arrives as ctrl+wheel; plain wheel keeps scrolling the page + if (!e.ctrlKey && !e.metaKey) return + e.preventDefault() + // pinches stream small deltas; discrete wheel ticks send ±100+ — clamp + // so one tick can't triple the scale + const delta = Math.max(-40, Math.min(40, e.deltaY)) + this.zoomAt(e.clientX, e.clientY, Math.exp(-delta * 0.01)) + } + this.container.addEventListener('wheel', onWheel, { passive: false }) + this.disposers.push(() => this.container.removeEventListener('wheel', onWheel)) + + const onDown = (e: PointerEvent) => { + if (e.button !== 0) return + if (this.tool === 'connect' || e.altKey) return + if (this.inlineInput || this.inPlaceSession) return + const target = e.target as Element | null + if (!target || !this.svgHost.contains(target)) return + // background only — entity presses belong to select/drag gestures + let el: Element | null = target + while (el && el !== this.container) { + if (el.getAttribute?.('data-mw-entity')) return + el = el.parentElement + } + this.panSession = { pointerId: e.pointerId, x: e.clientX, y: e.clientY, tx: this.zoomTx, ty: this.zoomTy, moved: false } + } + const onMove = (e: PointerEvent) => { + const pan = this.panSession + if (!pan || e.pointerId !== pan.pointerId) return + const dx = e.clientX - pan.x + const dy = e.clientY - pan.y + if (!pan.moved && Math.hypot(dx, dy) > 4) { + pan.moved = true + this.container.classList.add('mw-panning') + try { + this.container.setPointerCapture(e.pointerId) + } catch { + // synthetic pointers (tests, automation) have no capturable id + } + this.popover.hide() + } + if (pan.moved) { + this.zoomTx = pan.tx + dx + this.zoomTy = pan.ty + dy + this.hasUserView = true + this.applyViewTransform() + } + } + const onUp = (e: PointerEvent) => { + const pan = this.panSession + if (!pan || e.pointerId !== pan.pointerId) return + this.panSession = null + this.container.classList.remove('mw-panning') + if (pan.moved) { + this.suppressNextClick = true + this.updatePopover() + } + } + this.container.addEventListener('pointerdown', onDown) + this.container.addEventListener('pointermove', onMove) + this.container.addEventListener('pointerup', onUp) + this.container.addEventListener('pointercancel', onUp) + this.disposers.push(() => { + this.container.removeEventListener('pointerdown', onDown) + this.container.removeEventListener('pointermove', onMove) + this.container.removeEventListener('pointerup', onUp) + this.container.removeEventListener('pointercancel', onUp) + }) + + const controls = document.createElement('div') + controls.className = 'mw-zoom-controls' + const btn = (title: string, iconMarkup: string, fn: () => void) => { + const b = document.createElement('button') + b.type = 'button' + b.className = 'mw-zoom-btn' + b.title = title + b.innerHTML = iconMarkup + b.addEventListener('pointerdown', (e) => e.stopPropagation()) + b.addEventListener('click', (e) => { + e.stopPropagation() + fn() + }) + controls.appendChild(b) + } + btn('Zoom out', ICONS.minus, () => this.zoomBy(1 / 1.25)) + btn('Zoom in', ICONS.plus, () => this.zoomBy(1.25)) + btn('Fit diagram', ICONS.maximize, () => this.fitView()) + this.container.appendChild(controls) + this.zoomControls = controls + + if (typeof ResizeObserver !== 'undefined') { + const ro = new ResizeObserver(() => { + if (!this.hasUserView) this.fitView() + }) + ro.observe(this.container) + this.disposers.push(() => ro.disconnect()) + } + } + + /** zoom about the canvas center (used by the corner controls) */ + zoomBy(factor: number) { + const rect = this.container.getBoundingClientRect() + this.zoomAt(rect.left + rect.width / 2, rect.top + rect.height / 2, factor) + } + + private zoomAt(clientX: number, clientY: number, factor: number) { + if (!this.panZoomEnabled) return + const rect = this.container.getBoundingClientRect() + const px = clientX - rect.left + const py = clientY - rect.top + const next = Math.min(Math.max(this.zoomScale * factor, 0.1), 4) + const k = next / this.zoomScale + if (k === 1) return + this.zoomTx = px - (px - this.zoomTx) * k + this.zoomTy = py - (py - this.zoomTy) * k + this.zoomScale = next + this.hasUserView = true + this.applyViewTransform() + this.updatePopover() + } + + /** scale + center the diagram to the canvas (the default view) */ + fitView() { + if (!this.panZoomEnabled || !this.svg || !this.svgSize) return + const cw = this.container.clientWidth + const ch = this.container.clientHeight + if (!cw || !ch) return + const pad = 28 + const { width, height } = this.svgSize + const s = Math.min((cw - pad * 2) / width, (ch - pad * 2) / height) + this.zoomScale = Math.min(Math.max(s, 0.1), 2) + this.zoomTx = (cw - width * this.zoomScale) / 2 + this.zoomTy = (ch - height * this.zoomScale) / 2 + this.hasUserView = false + this.applyViewTransform() + this.updatePopover() + } + + private applyViewTransform() { + if (!this.svg) return + this.applyingOwnTransform = true + this.svg.style.transform = `translate(${this.zoomTx}px, ${this.zoomTy}px) scale(${this.zoomScale})` + // mutation records for this write are delivered in a microtask queued at + // mutation time — before this one — so the observer still sees the flag + queueMicrotask(() => { + this.applyingOwnTransform = false + }) + } + + /** pixel-size the fresh svg from its viewBox so the transform is the only scaling */ + private prepareSvgForPanZoom() { + const svg = this.svg + if (!svg) return + let width = 0 + let height = 0 + const vb = svg.viewBox?.baseVal + if (vb && vb.width && vb.height) { + width = vb.width + height = vb.height + } else { + const bb = (svg as SVGGraphicsElement).getBBox?.() + width = bb?.width || 800 + height = bb?.height || 600 + } + this.svgSize = { width, height } + svg.removeAttribute('width') + svg.removeAttribute('height') + svg.style.width = `${width}px` + svg.style.height = `${height}px` + svg.style.maxWidth = 'none' + if (this.hasUserView) this.applyViewTransform() + else this.fitView() + } + + async render(): Promise { + // typing in place must feel like a plain textbox: never swap the SVG out + // from under an active session. Live commits keep the code surfaces in + // sync; the canvas catches up the moment the session ends. + if (this.inPlaceSession) { + this.renderHeldByEdit = true + return + } + const code = this.editor.code + if (code === this.lastRenderedCode) { + // an intervening failed parse can leave a stale error badge even though + // the document is back to the last successfully rendered text (e.g. undo + // right after a broken keystroke) + if (this.lastError) { + this.lastError = null + this.errorBadge.style.display = 'none' + this.editor.setDiagnostics([]) + this.emit('render', { ok: true }) + } + return + } + const seq = ++this.renderSeq + const id = `mw-render-${++renderIdCounter}` + try { + const { svg } = await this.mermaid.render(id, code) + if (seq !== this.renderSeq) return // stale + // an in-place edit may have kept typing since the live commit this render + // came from — snapshot the label's real text and caret before the swap + // destroys them, then reinject below so no keystroke is lost + // a session may have OPENED while the render was in flight — re-read it + // through the accessor so the early-return narrowing above doesn't apply + let liveEdit: { value: string; caret: number } | null = null + const midFlightSession = this.activeInPlaceSession() + const editingLabel = midFlightSession?.label + if (midFlightSession && editingLabel?.isConnected) { + liveEdit = { value: readLabelHtml(editingLabel), caret: caretOffsetWithin(editingLabel) } + // a pending live-commit timer closes over the label we are about to + // detach; the reinjection below reschedules it against the new one + if (midFlightSession.liveTimer) { + clearTimeout(midFlightSession.liveTimer) + midFlightSession.liveTimer = null + } + } + this.lastRenderedCode = code + this.lastError = null + this.errorBadge.style.display = 'none' + this.svgHost.innerHTML = svg + this.svg = this.svgHost.querySelector('svg') + if (this.svg) { + if (this.panZoomEnabled) this.prepareSvgForPanZoom() + else this.svg.style.maxWidth = '100%' + this.bindSvg() + } + this.editor.setDiagnostics([]) + this.emit('render', { ok: true }) + if (this.activeInPlaceSession()) { + this.resumeInPlaceSession(liveEdit) + } else if (this.pendingEditEntity) { + const entity = this.pendingEditEntity + this.pendingEditEntity = null + if (this.editor.entityExists(entity)) this.editEntityLabel(entity) + } + } catch (err) { + if (seq !== this.renderSeq) return + // mermaid can leave a temp error element behind + document.getElementById(`d${id}`)?.remove() + document.getElementById(id)?.remove() + const message = err instanceof Error ? err.message.split('\n')[0] : String(err) + this.lastError = message + this.errorBadge.textContent = `render error: ${message}` + this.errorBadge.style.display = 'block' + const diag: Diagnostic = { message, span: null, severity: 'error', source: 'mermaid' } + this.editor.setDiagnostics([diag]) + this.emit('render', { ok: false, error: message }) + } + } + + private bindSvg() { + const svg = this.svg + if (!svg) return + const { flowchart, sequence, state, classGraph, er } = this.editor.result + this.correlation = flowchart + ? correlateFlowchart(svg, flowchart) + : state + ? correlateState(svg, state) + : classGraph + ? correlateClass(svg, classGraph) + : er + ? correlateEr(svg, er) + : this.editor.result.pie + ? correlatePie(svg, this.editor.result.pie) + : this.editor.result.gantt + ? correlateGantt(svg, this.editor.result.gantt) + : this.editor.result.lineItems + ? correlateLineItems(svg, this.editor.result.lineItems) + : null + this.seqCorrelation = sequence ? correlateSequence(svg, sequence) : null + this.clearLifelineUi() + + svg.addEventListener('click', (e) => this.onSvgClick(e)) + svg.addEventListener('dblclick', (e) => this.onSvgDblClick(e)) + svg.addEventListener('pointerdown', (e) => this.onSvgPointerDown(e)) + svg.addEventListener('pointermove', (e) => this.onSvgPointerMove(e)) + svg.addEventListener('pointerup', (e) => this.onSvgPointerUp(e)) + if (this.seqCorrelation) { + svg.addEventListener('pointermove', (e) => this.onLifelineHover(e)) + svg.addEventListener('pointerleave', () => this.scheduleLifelineClear()) + } + + // host apps may pan/zoom the svg from their own toolbars (transform on + // the svg or an inner group) — the popover must follow its entity instead + // of floating where the diagram used to be + this.svgTransformObserver?.disconnect() + if (typeof MutationObserver !== 'undefined') { + this.svgTransformObserver = new MutationObserver(() => { + // internal pan/zoom paths already reposition the popover themselves + if (this.applyingOwnTransform || this.popoverFollowRaf || !this.popover.isOpen) return + const schedule = + typeof requestAnimationFrame === 'function' + ? requestAnimationFrame + : (fn: FrameRequestCallback) => setTimeout(fn, 16) as unknown as number + this.popoverFollowRaf = schedule(() => { + this.popoverFollowRaf = 0 + this.updatePopover() + }) + }) + this.svgTransformObserver.observe(svg, { + attributes: true, + attributeFilter: ['style', 'transform'], + subtree: true, + }) + } + + this.applySelectionStyles() + } + + private entityFromEvent(e: Event): string | null { + return this.entityHitFromEvent(e)?.id ?? null + } + + /** entity id plus the concrete element that was hit (a participant has several) */ + private entityHitFromEvent(e: Event): { id: string; el: Element } | null { + let el = e.target as Element | null + while (el && el !== this.svg) { + const entity = el.getAttribute?.('data-mw-entity') + if (entity) return { id: entity, el } + el = el.parentElement as Element | null + } + return null + } + + /** topmost of the elements belonging to an entity (e.g. a participant's top box) */ + private topmostEntityElement(entityId: string): Element | null { + let best: Element | null = null + let bestTop = Infinity + this.svg?.querySelectorAll(`[data-mw-entity="${CSS.escape(entityId)}"]`).forEach((el) => { + const top = el.getBoundingClientRect().top + if (top < bestTop) { + bestTop = top + best = el + } + }) + return best + } + + private onSvgClick(e: MouseEvent) { + // pointer capture during a connect gesture retargets the click to the svg + // root; the pointerup handler has already decided what the gesture meant + if (this.suppressNextClick) { + this.suppressNextClick = false + return + } + // while an in-place editor is open, clicks must not re-select or steal + // focus — blurring the editor already commits the edit + if (this.inlineInput || this.inPlaceSession) return + const hit = this.entityHitFromEvent(e) + if (hit) { + const entity = hit.id + this.hooks.onEntityClick?.(entity, e) + // several elements can share one entity id (a sequence participant is + // rendered as a top AND a bottom box) — remember which one was clicked + // so the popover opens where the user pointed, not at the topmost twin + this.popoverAnchorPick = { entityId: entity, index: this.entityElementIndex(entity, hit.el) } + if (e.shiftKey || e.metaKey || e.ctrlKey) { + const sel = this.editor.selection.includes(entity) + ? this.editor.selection.filter((s) => s !== entity) + : [...this.editor.selection, entity] + this.editor.setSelection(sel, 'canvas') + } else { + this.editor.setSelection([entity], 'canvas') + // clicking the other twin of an already-selected entity changes the + // anchor pick but not the selection — no selectionChange fires, so + // re-anchor explicitly + this.updatePopover() + } + this.container.focus({ preventScroll: true }) + } else { + this.editor.clearSelection('canvas') + } + } + + /** position of `el` among all elements carrying this entity id (DOM order) */ + private entityElementIndex(entityId: string, el: Element): number { + if (!this.svg) return 0 + const els = [...this.svg.querySelectorAll(`[data-mw-entity="${CSS.escape(entityId)}"]`)] + const index = els.indexOf(el) + return index >= 0 ? index : 0 + } + + private onSvgDblClick(e: MouseEvent) { + if (this.readOnly) return + // class members are editable rows inside the class box + const memberEl = (e.target as Element).closest?.('[data-mw-member-line]') + if (memberEl && this.editor.result.classGraph) { + const clsId = memberEl.getAttribute('data-mw-member-class')! + const lineIndex = Number(memberEl.getAttribute('data-mw-member-line')) + const cls = this.editor.result.classGraph.classById.get(clsId) + const member = cls?.members.find((m) => m.lineIndex === lineIndex) + if (member) { + e.preventDefault() + this.popover.hide() + this.editInPlace( + `member:${clsId}:${lineIndex}`, + memberEl, + '.nodeLabel', + member.text, + (v) => this.editor.dispatch({ type: 'cl.setMemberText', id: clsId, memberLine: lineIndex, text: v }, 'canvas'), + { live: false }, + ) + return + } + } + // ER attribute rows edit the whole source line (type name keys "comment") + const attrEl = (e.target as Element).closest?.('[data-mw-attr-line]') + if (attrEl && this.editor.result.er) { + const entityId = attrEl.getAttribute('data-mw-attr-entity')! + const attrLine = Number(attrEl.getAttribute('data-mw-attr-line')) + const entity = this.editor.result.er.entityById.get(entityId) + const attr = entity?.attributes.find((a) => a.lineIndex === attrLine) + if (attr) { + e.preventDefault() + this.popover.hide() + this.openOverlayEditor(attrEl, attr.text, (v) => + this.editor.dispatch({ type: 'er.setAttributeText', id: entityId, attrLine, text: v }, 'canvas'), + ) + return + } + } + const hit = this.entityHitFromEvent(e) + if (!hit) return + e.preventDefault() + if (this.hooks.onEntityDoubleClick?.(hit.id)) return + // edit at the element the user actually double-clicked (a participant is + // rendered twice — top and bottom box — and either should be editable) + this.editEntityLabel(hit.id, hit.el) + } + + // ----- drag to connect ----- + + private onSvgPointerDown(e: PointerEvent) { + if (this.readOnly) return + const entity = this.entityFromEvent(e) + const connectable = + entity?.startsWith('node:') || + entity?.startsWith('participant:') || + entity?.startsWith('state:') || + entity?.startsWith('class:') || + entity?.startsWith('entity:') + const wantConnect = this.tool === 'connect' || e.altKey + if (connectable && wantConnect && entity) { + e.preventDefault() + this.connectSource = entity + this.svg?.setPointerCapture?.(e.pointerId) + this.entityElement(entity)?.classList.add('mw-connect-source') + this.emit('connectPreview', { source: entity }) + return + } + // sequence events can be dragged vertically to reorder statements + // (skip on a double-click's second press — that gesture is text editing). + // Pointer capture waits until the drag threshold: capturing on press + // retargets the click to the svg and breaks double-click detection. + if (entity?.startsWith('event:') && this.editor.result.sequence && e.detail <= 1) { + this.dragEvent = entity + this.dragStartY = e.clientY + this.dragging = false + } + } + + private entityElement(entityId: string): Element | null { + return this.svg?.querySelector(`[data-mw-entity="${CSS.escape(entityId)}"]`) ?? null + } + + private onSvgPointerMove(e: PointerEvent) { + if (this.dragEvent && this.svg) { + if (!this.dragging && Math.abs(e.clientY - this.dragStartY) > 8) { + this.dragging = true + this.svg.setPointerCapture?.(e.pointerId) + this.computeDropSlots() + this.popover.hide() + } + if (this.dragging) { + const host = this.container.getBoundingClientRect() + const y = e.clientY - host.top + this.container.scrollTop + const slot = this.nearestSlot(y) + if (slot) this.showDropIndicator(slot.y) + } + return + } + if (!this.connectSource || !this.svg) return + const from = this.entityCenter(this.connectSource) + const to = this.clientToSvg(e.clientX, e.clientY) + if (!from || !to) return + if (!this.ghostPath) { + this.ghostPath = document.createElementNS('http://www.w3.org/2000/svg', 'line') + this.ghostPath.classList.add('mw-ghost-edge') + this.svg.appendChild(this.ghostPath) + } + this.ghostPath.setAttribute('x1', String(from.x)) + this.ghostPath.setAttribute('y1', String(from.y)) + this.ghostPath.setAttribute('x2', String(to.x)) + this.ghostPath.setAttribute('y2', String(to.y)) + } + + private onSvgPointerUp(e: PointerEvent) { + if (this.dragEvent) { + const dragged = this.dragEvent + const wasDragging = this.dragging + this.dragEvent = null + this.dragging = false + this.dropIndicator?.remove() + this.dropIndicator = null + if (wasDragging) { + this.suppressNextClick = true + const host = this.container.getBoundingClientRect() + const slot = this.nearestSlot(e.clientY - host.top + this.container.scrollTop) + if (slot) this.editor.dispatch({ type: 'seq.moveEvent', eventId: dragged, afterEvent: slot.afterEvent }) + } + return + } + if (!this.connectSource) return + const source = this.connectSource + // hit test at pointer location (ghost line has pointer-events: none) + const kind = source.startsWith('node:') + ? 'node:' + : source.startsWith('state:') + ? 'state:' + : source.startsWith('class:') + ? 'class:' + : source.startsWith('entity:') + ? 'entity:' + : 'participant:' + const hit = document.elementFromPoint(e.clientX, e.clientY) + let target: string | null = null + let el = hit as Element | null + while (el && el !== this.container) { + const entity = el.getAttribute?.('data-mw-entity') + if (entity?.startsWith(kind)) { + target = entity + break + } + el = el.parentElement + } + this.cancelConnect() + this.suppressNextClick = true + if (target && target !== source && kind === 'node:') { + this.editor.dispatch({ + type: 'connect', + source: source.slice(5), + target: target.slice(5), + line: this.defaultEdge.line, + arrowEnd: this.defaultEdge.arrowEnd, + }) + } else if (target && target !== source && kind === 'participant:') { + this.editor.dispatch({ + type: 'seq.addMessage', + source: source.slice(12), + target: target.slice(12), + text: 'message', + }) + } else if (target && target !== source && kind === 'state:') { + this.editor.dispatch({ type: 'st.connect', source: source.slice(6), target: target.slice(6) }) + } else if (target && target !== source && kind === 'class:') { + this.editor.dispatch({ type: 'cl.connect', source: source.slice(6), target: target.slice(6) }) + } else if (target && target !== source && kind === 'entity:') { + this.editor.dispatch({ type: 'er.connect', source: source.slice(7), target: target.slice(7) }) + } else { + // no drag happened — treat as a plain click-select on the source node + this.editor.setSelection([source], 'canvas') + this.container.focus({ preventScroll: true }) + } + } + + private cancelConnect() { + if (this.connectSource) { + this.entityElement(this.connectSource)?.classList.remove('mw-connect-source') + } + this.connectSource = null + this.ghostPath?.remove() + this.ghostPath = null + this.emit('connectPreview', { source: null }) + } + + // ----- inline label editing ----- + + editEntityLabel(entityId: string, anchorHint?: Element | null) { + if (this.readOnly) return + const { flowchart, sequence } = this.editor.result + this.closeInlineEditor(false) + // moving to a different entity must settle the open session first — its + // deferred blur handler would see a replaced session and bail, stranding + // the old label contenteditable with uncommitted text + if (this.inPlaceSession && this.inPlaceSession.entityId !== entityId) { + this.inPlaceSession.finish?.(true) + } + this.popover.hide() + + // only honor a hint that actually belongs to this entity + const hint = + anchorHint && anchorHint.getAttribute('data-mw-entity') === entityId ? anchorHint : null + let anchor: Element | null = null + let current = '' + let commitValue: (value: string) => void = () => {} + + if (flowchart && entityId.startsWith('node:')) { + const node = flowchart.nodeById.get(entityId.slice(5)) + if (!node) return + current = node.label + anchor = this.correlation?.nodes.get(entityId) ?? null + commitValue = (v) => this.editor.dispatch({ type: 'renameNode', id: node.id, label: v }, 'canvas') + // natural in-node editing when mermaid rendered an HTML label + if (anchor && this.editInPlace(entityId, anchor, '.nodeLabel', current, commitValue)) return + } else if (flowchart && entityId.startsWith('edge:')) { + const edge = flowchart.edges.find((ed) => ed.entityId === entityId) + if (!edge) return + current = edge.label ?? '' + anchor = this.correlation?.edgeLabels.get(entityId) ?? this.correlation?.edges.get(entityId) ?? null + commitValue = (v) => this.editor.dispatch({ type: 'setEdgeLabel', edgeId: entityId, label: v }, 'canvas') + if (edge.label && anchor && this.editInPlace(entityId, anchor, '.edgeLabel', current, commitValue)) return + } else if (flowchart && entityId.startsWith('subgraph:')) { + const sg = flowchart.subgraphs.find((s) => s.entityId === entityId) + if (!sg) return + current = sg.title ?? sg.id + anchor = + this.correlation?.nodes.get(entityId)?.querySelector('.cluster-label') ?? + hint ?? + this.correlation?.nodes.get(entityId) ?? + this.svg?.querySelector(`#${CSS.escape(sg.id)}`) ?? + null + commitValue = (v) => this.editor.dispatch({ type: 'renameSubgraph', id: sg.id, title: v }, 'canvas') + } else if (this.editor.result.state && entityId.startsWith('state:')) { + const s = this.editor.result.state.stateById.get(entityId.slice(6)) + if (!s) return + current = s.label + anchor = this.correlation?.nodes.get(entityId) ?? null + commitValue = (v) => this.editor.dispatch({ type: 'st.setStateLabel', id: s.id, label: v }, 'canvas') + if (anchor && this.editInPlace(entityId, anchor, '.nodeLabel', current, commitValue)) return + } else if (this.editor.result.state && entityId.startsWith('trans:')) { + const t = this.editor.result.state.transitions.find((tr) => tr.entityId === entityId) + if (!t) return + current = t.label ?? '' + anchor = this.correlation?.edgeLabels.get(entityId) ?? this.correlation?.edges.get(entityId) ?? null + commitValue = (v) => this.editor.dispatch({ type: 'st.setTransitionLabel', transId: entityId, label: v }, 'canvas') + if (t.label && anchor && this.editInPlace(entityId, anchor, '.edgeLabel', current, commitValue)) return + } else if (this.editor.result.classGraph && entityId.startsWith('class:')) { + const c = this.editor.result.classGraph.classById.get(entityId.slice(6)) + if (!c) return + current = c.id + anchor = hint ?? this.correlation?.nodes.get(entityId) ?? null + commitValue = (v) => this.editor.dispatch({ type: 'cl.renameClass', id: c.id, name: v }, 'canvas') + } else if (this.editor.result.classGraph && entityId.startsWith('rel:')) { + const r = this.editor.result.classGraph.relations.find((rr) => rr.entityId === entityId) + if (!r) return + current = r.label ?? '' + anchor = this.correlation?.edgeLabels.get(entityId) ?? this.correlation?.edges.get(entityId) ?? null + commitValue = (v) => this.editor.dispatch({ type: 'cl.setRelationLabel', relId: entityId, label: v }, 'canvas') + } else if (this.editor.result.er && entityId.startsWith('entity:')) { + const e = this.editor.result.er.entityById.get(entityId.slice(7)) + if (!e) return + current = e.id + anchor = hint ?? this.correlation?.nodes.get(entityId) ?? null + commitValue = (v) => this.editor.dispatch({ type: 'er.renameEntity', id: e.id, name: v }, 'canvas') + } else if (this.editor.result.er && entityId.startsWith('erel:')) { + const r = this.editor.result.er.relations.find((rr) => rr.entityId === entityId) + if (!r) return + current = r.label + anchor = this.correlation?.edgeLabels.get(entityId) ?? this.correlation?.edges.get(entityId) ?? null + commitValue = (v) => this.editor.dispatch({ type: 'er.setRelationLabel', relId: entityId, label: v }, 'canvas') + } else if (this.editor.result.pie && entityId.startsWith('slice:')) { + const s = this.editor.result.pie.slices.find((sl) => sl.entityId === entityId) + if (!s) return + current = String(s.value) + anchor = hint ?? this.entityElement(entityId) + commitValue = (v) => { + const value = Number(v) + if (Number.isFinite(value)) this.editor.dispatch({ type: 'pie.setValue', sliceId: entityId, value }, 'canvas') + } + } else if (this.editor.result.gantt && entityId.startsWith('task:')) { + const t = this.editor.result.gantt.tasks.find((tk) => tk.entityId === entityId) + if (!t) return + current = t.name + anchor = hint ?? this.entityElement(entityId) + commitValue = (v) => this.editor.dispatch({ type: 'gantt.setTaskName', taskId: entityId, name: v }, 'canvas') + } else if (this.editor.result.lineItems && entityId.startsWith('item:')) { + const item = this.editor.result.lineItems.items.find((i) => i.entityId === entityId) + if (!item) return + current = item.text + anchor = hint ?? this.entityElement(entityId) + commitValue = (v) => this.editor.dispatch({ type: 'li.setLine', itemId: entityId, text: v }, 'canvas') + } else if (sequence && entityId.startsWith('participant:')) { + const p = sequence.participantById.get(entityId.slice(12)) + if (!p) return + current = p.label + // edit in whichever box was double-clicked; default to the top one + anchor = hint ?? this.topmostEntityElement(entityId) + commitValue = (v) => this.editor.dispatch({ type: 'seq.renameParticipant', id: p.id, name: v }, 'canvas') + } else if (sequence && entityId.startsWith('event:')) { + const ev = sequence.events.find((e) => e.entityId === entityId) + if (!ev) return + current = ev.stmt.text + anchor = this.seqCorrelation?.eventTexts.get(entityId) ?? hint ?? this.entityElement(entityId) + commitValue = (v) => this.editor.dispatch({ type: 'seq.setEventText', eventId: entityId, text: v }, 'canvas') + } + if (!anchor) return + this.openOverlayEditor(anchor, current, commitValue) + } + + /** + * Edit the label directly inside the node: mermaid's HTML labels + * (foreignObject spans) become contenteditable, so text is edited in place. + * While typing, changes are live-committed (debounced) so the node grows to + * fit — the session survives the re-render and re-attaches with the caret + * restored. + */ + private editInPlace( + entityId: string, + anchor: Element, + labelSelector: string, + current: string, + commitValue: (value: string) => void, + opts: { live?: boolean } = {}, + ): boolean { + const label = anchor.matches?.(labelSelector) + ? ((anchor.querySelector('p') ?? anchor) as HTMLElement) + : (anchor.querySelector(`${labelSelector} p`) ?? anchor.querySelector(labelSelector)) + if (!label || !(label instanceof HTMLElement)) return false + + const resuming = this.inPlaceSession?.entityId === entityId + if (resuming && this.inPlaceSession!.label === label && label.isConnected) { + // the session is already attached to this exact element (e.g. a second + // double-click mid-session) — re-adding listeners would duplicate every + // commit and let stale closures race the live ones + label.focus() + return true + } + if (!resuming) { + this.inPlaceSession = { + entityId, + labelSelector, + original: current, + lastCommitted: current, + caretOffset: -1, + commitValue, + liveTimer: null, + label: null, + finish: null, + } + } + const session = this.inPlaceSession! + session.label = label + + label.setAttribute('contenteditable', 'true') + // the box can't grow while renders are held — keep the text on one line + // and let it spill past the shape instead of wrapping into the clip + const clipHost = label.closest('foreignObject') as SVGElement | null + const prevWhiteSpace = label.style.whiteSpace + const prevClipOverflow = clipHost?.style.overflow ?? '' + label.style.whiteSpace = 'nowrap' + if (clipHost) clipHost.style.overflow = 'visible' + label.focus() + if (resuming && session.caretOffset >= 0) { + placeCaretAt(label, session.caretOffset) + } else { + const selection = window.getSelection() + const range = document.createRange() + range.selectNodeContents(label) + selection?.removeAllRanges() + selection?.addRange(range) + } + + const rememberCaret = () => { + const offset = caretOffsetWithin(label) + if (offset >= 0) session.caretOffset = offset + } + + const liveCommit = () => { + if (this.inPlaceSession !== session) return + const value = readLabelHtml(label) + if (!value || value === session.lastCommitted) return + rememberCaret() + session.lastCommitted = value + session.commitValue(value) + } + + const finish = (commit: boolean) => { + if (this.inPlaceSession !== session) return + this.inPlaceSession = null + if (session.liveTimer) clearTimeout(session.liveTimer) + label.removeAttribute('contenteditable') + label.style.whiteSpace = prevWhiteSpace + if (clipHost) clipHost.style.overflow = prevClipOverflow + label.removeEventListener('keydown', onKey) + label.removeEventListener('blur', onBlur) + label.removeEventListener('input', onInput) + const value = readLabelHtml(label) + if (commit) { + if (value && value !== session.lastCommitted) session.commitValue(value) + else if (!value) label.textContent = session.lastCommitted + } else { + // Escape: revert everything, including live-committed intermediate + // states. Restore the label DOM directly too — when the reverted code + // equals the last rendered code, render() short-circuits and would + // leave the typed text on screen. + label.innerHTML = session.original + if (session.lastCommitted !== session.original) session.commitValue(session.original) + } + // apply any canvas render held back while the session was typing + if (this.renderHeldByEdit) { + this.renderHeldByEdit = false + this.scheduleRender() + } + this.updatePopover() + } + + const onInput = () => { + if (opts.live === false) return + if (session.liveTimer) clearTimeout(session.liveTimer) + session.liveTimer = setTimeout(liveCommit, 450) + } + const onKey = (e: KeyboardEvent) => { + e.stopPropagation() + if (e.key === 'Enter') { + e.preventDefault() + finish(true) + } else if (e.key === 'Escape') { + finish(false) + } + } + const onBlur = () => { + // a re-render removes the label mid-session — that is not a real blur + setTimeout(() => { + if (this.inPlaceSession === session && label.isConnected && document.activeElement !== label) { + finish(true) + } + }, 60) + } + session.finish = finish + label.addEventListener('keydown', onKey) + label.addEventListener('blur', onBlur) + label.addEventListener('input', onInput) + return true + } + + /** non-narrowing accessor: render() re-reads the session after awaits */ + private activeInPlaceSession() { + return this.inPlaceSession + } + + /** re-attach a live in-place editing session after a render replaced the DOM */ + private resumeInPlaceSession(liveEdit?: { value: string; caret: number } | null) { + const session = this.inPlaceSession + if (!session) return + if (!this.editor.entityExists(session.entityId)) { + this.inPlaceSession = null + return + } + const anchor = + (session.entityId.startsWith('edge:') ? this.correlation?.edgeLabels.get(session.entityId) : null) ?? + this.entityElement(session.entityId) + if (!anchor) { + this.inPlaceSession = null + return + } + this.editInPlace(session.entityId, anchor, session.labelSelector, session.lastCommitted, session.commitValue) + // reinject what the user typed between the live commit and the swap — the + // freshly rendered label only carries the committed text + const label = this.inPlaceSession === session ? session.label : null + if (!label || !liveEdit) return + if (liveEdit.value && liveEdit.value !== session.lastCommitted && liveEdit.value !== readLabelHtml(label)) { + label.innerHTML = liveEdit.value + // run the carried-over delta through the normal live-commit debounce + label.dispatchEvent(new Event('input')) + } + if (liveEdit.caret >= 0) placeCaretAt(label, liveEdit.caret) + } + + /** + * Seamless in-place editing for targets that can't be contenteditable + * themselves (SVG text, slices, bars): a chrome-less editable div is placed + * exactly over the element with its typography copied, and the original is + * hidden for the duration — it reads as editing the diagram text directly. + */ + /** the visual text node inside a target — editing must sit on the glyphs */ + private findTextTarget(anchor: Element, current: string): Element { + const isLeafText = (el: Element) => + (el.tagName === 'text' || el.tagName === 'SPAN' || el.tagName === 'P' || el.tagName === 'DIV' || el.tagName === 'tspan') && + (el.children.length === 0 || el.tagName === 'text') && + (el.textContent?.trim().length ?? 0) > 0 + if (isLeafText(anchor)) return anchor + let leaves = [...anchor.querySelectorAll('text, tspan, span, p, div')].filter(isLeafText) + if (!leaves.length && anchor.parentElement) { + // a hit on a bare shape (e.g. a participant box ) has no text + // descendants — the glyphs are a SIBLING inside the same group. Without + // this widening the shape itself gets hidden and the editor floats over + // the still-visible label, double-rendering it. + leaves = [...anchor.parentElement.querySelectorAll('text, tspan, span, p, div')].filter(isLeafText) + } + return ( + leaves.find((el) => el.textContent?.trim() === current.trim()) ?? + leaves.find((el) => current.trim().startsWith(el.textContent?.trim() ?? '')) ?? + leaves[0] ?? + anchor + ) + } + + private openOverlayEditor(anchor: Element, current: string, commitValue: (value: string) => void) { + anchor = this.findTextTarget(anchor, current) + const box = this.hostRect(anchor) + // SVG glyph paint often lives on an inner while the outer + // carries a background-matching fill (mermaid sequence actor boxes do + // this) — read typography and color from the innermost carrier so the + // editor's text doesn't render background-on-background. + const paintSource = anchor.querySelector('tspan') ?? anchor + const cs = window.getComputedStyle(paintSource) + const div = document.createElement('div') + div.className = 'mw-inplace-editor' + div.contentEditable = 'true' + div.spellcheck = false + div.textContent = current + // grow from the text's center so it stays visually anchored while typing + div.style.left = `${box.left + box.width / 2}px` + div.style.top = `${box.top}px` + div.style.transform = 'translateX(-50%)' + div.style.textAlign = 'center' + div.style.minWidth = `${Math.max(24, box.width)}px` + div.style.lineHeight = `${Math.max(box.height, 12)}px` + div.style.fontStyle = cs.fontStyle + div.style.fontWeight = cs.fontWeight + // computed font-size ignores the pan/zoom transform; the overlay must + // match the scaled glyphs it covers + const zoom = this.panZoomEnabled ? this.zoomScale : 1 + const basePx = Number.parseFloat(cs.fontSize) + div.style.fontSize = Number.isFinite(basePx) ? `${basePx * zoom}px` : cs.fontSize + div.style.fontFamily = cs.fontFamily + // svg text is colored via fill; html labels via color + const fill = cs.fill && cs.fill !== 'none' && paintSource instanceof SVGElement ? cs.fill : cs.color + div.style.color = fill || 'inherit' + this.overlayHost.appendChild(div) + this.inlineInput = div + + // hide the original so the editor reads as the element itself + const target = anchor as HTMLElement | SVGElement + const prevVisibility = target.style?.visibility ?? '' + if (target.style) target.style.visibility = 'hidden' + this.inlineHidden = { el: target, prevVisibility } + + div.focus() + const range = document.createRange() + range.selectNodeContents(div) + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + + const commit = () => { + const value = (div.textContent ?? '').trim() + this.closeInlineEditor(false) + if (value === current) return + commitValue(value) + } + div.addEventListener('keydown', (e) => { + e.stopPropagation() + if (e.key === 'Enter') { + e.preventDefault() + commit() + } + if (e.key === 'Escape') this.closeInlineEditor(false) + }) + div.addEventListener('blur', commit) + } + + private closeInlineEditor(refocus: boolean) { + if (this.inlineHidden) { + if (this.inlineHidden.el.style) this.inlineHidden.el.style.visibility = this.inlineHidden.prevVisibility + this.inlineHidden = null + } + if (this.inlineInput) { + const input = this.inlineInput + this.inlineInput = null + input.remove() + if (refocus) this.container.focus({ preventScroll: true }) + } + } + + // ----- keyboard ----- + + private onKeyDown(e: KeyboardEvent) { + if (this.inlineInput) return + const mod = e.metaKey || e.ctrlKey + if (mod && e.key.toLowerCase() === 'z') { + e.preventDefault() + if (e.shiftKey) this.editor.redo() + else this.editor.undo() + return + } + if (this.readOnly) return + if ((e.key === 'Delete' || e.key === 'Backspace') && this.editor.selection.length) { + e.preventDefault() + this.editor.deleteEntities(this.editor.selection, 'canvas') + return + } + if (e.key === 'Escape') { + this.cancelConnect() + this.editor.clearSelection('canvas') + return + } + if (e.key === 'Enter' && this.editor.selection.length === 1) { + e.preventDefault() + this.editEntityLabel(this.editor.selection[0]) + } + } + + // ----- selection visuals ----- + + private applySelectionStyles() { + if (!this.svg) return + this.svg.querySelectorAll('.mw-selected').forEach((el) => el.classList.remove('mw-selected')) + for (const id of this.editor.selection) { + this.svg.querySelectorAll(`[data-mw-entity="${CSS.escape(id)}"]`).forEach((el) => { + el.classList.add('mw-selected') + }) + } + this.updatePopover() + } + + // ----- entity popover ----- + + private hostRect(el: Element): { left: number; top: number; width: number; height: number } { + const rect = el.getBoundingClientRect() + const host = this.container.getBoundingClientRect() + return { + left: rect.left - host.left + this.container.scrollLeft, + top: rect.top - host.top + this.container.scrollTop, + width: rect.width, + height: rect.height, + } + } + + private updatePopover() { + this.popover.hide() + if (this.readOnly || this.inlineInput || this.inPlaceSession) return + const sel = this.editor.selection + if (sel.length !== 1 || !this.svg) return + const id = sel[0] + // several elements can belong to one entity (e.g. participant top+bottom + // boxes) — anchor at the element the user actually clicked when we know + // it, falling back to the topmost twin (code-pane selection sync, resumes + // after re-renders that changed the element count) + const els = [...this.svg.querySelectorAll(`[data-mw-entity="${CSS.escape(id)}"]`)] + let anchor: Element | null = null + const pick = this.popoverAnchorPick + if (pick && pick.entityId === id && els[pick.index]) { + anchor = els[pick.index] + } else { + let anchorTop = Infinity + for (const el of els) { + const top = el.getBoundingClientRect().top + if (top < anchorTop) { + anchorTop = top + anchor = el + } + } + } + if (!anchor) return + const actions = this.actionsFor(id) + if (!actions.length) return + this.popover.show(this.hostRect(anchor), actions) + } + + private colorSection(title: string, onPick: (value: string | null) => void): PopoverPanelSection { + return { + title, + columns: 5, + items: [ + ...COLOR_PALETTE.map((c) => ({ swatch: c, title: `${title}: ${c}`, onClick: () => onPick(c) })), + { swatch: 'none', title: `${title}: default`, onClick: () => onPick(null) }, + ], + } + } + + private actionsFor(id: string): PopoverAction[] { + const { flowchart, sequence } = this.editor.result + const del: PopoverAction = { + icon: ICONS.trash, + title: 'Delete', + danger: true, + onClick: () => this.editor.deleteEntities([id]), + } + const rename: PopoverAction = { icon: ICONS.pencil, title: 'Edit text', onClick: () => this.editEntityLabel(id) } + + if (flowchart && id.startsWith('node:')) { + const node = flowchart.nodeById.get(id.slice(5)) + if (!node) return [] + const shapes: Array<{ s: ShapeId; g: string; t: string }> = [ + { s: 'rect', g: '▭', t: 'Rectangle' }, + { s: 'round', g: '▢', t: 'Rounded' }, + { s: 'stadium', g: '⬭', t: 'Stadium' }, + { s: 'diamond', g: '◇', t: 'Diamond' }, + { s: 'hexagon', g: '⬡', t: 'Hexagon' }, + { s: 'circle', g: '◯', t: 'Circle' }, + { s: 'cylinder', g: '⛁', t: 'Cylinder' }, + { s: 'subroutine', g: '⧉', t: 'Subroutine' }, + ] + return [ + { + icon: ICONS.shapes, + title: 'Node shape', + panel: { + title: 'Node shape', + items: shapes.map((sh) => ({ + glyph: sh.g, + title: sh.t, + selected: node.shape === sh.s, + onClick: () => this.editor.dispatch({ type: 'setNodeShape', id: node.id, shape: sh.s }), + })), + }, + }, + { + icon: ICONS.palette, + title: 'Colors', + panel: { + title: 'Colors', + sections: (['fill', 'stroke', 'color'] as const).map((prop) => + this.colorSection(prop === 'fill' ? 'Fill' : prop === 'stroke' ? 'Border' : 'Text', (value) => + this.editor.dispatch({ type: 'setNodeColor', id: node.id, prop, value }), + ), + ), + }, + }, + { + icon: ICONS.copy, + title: 'Duplicate', + onClick: () => this.editor.dispatch({ type: 'duplicateNode', id: node.id }), + }, + rename, + del, + ] + } + + if (flowchart && id.startsWith('subgraph:')) { + return [{ icon: ICONS.pencil, title: 'Rename subgraph', onClick: () => this.editEntityLabel(id) }] + } + + if (flowchart && id.startsWith('edge:')) { + const edge = flowchart.edges.find((e) => e.entityId === id) + if (!edge) return [] + const kinds: Array<{ line: EdgeLine; arrowEnd: EdgeArrow; g: string; t: string }> = [ + { line: 'solid', arrowEnd: 'arrow', g: '—▶', t: 'solid arrow' }, + { line: 'dotted', arrowEnd: 'arrow', g: '⋯▶', t: 'dotted arrow' }, + { line: 'thick', arrowEnd: 'arrow', g: '═▶', t: 'thick arrow' }, + { line: 'solid', arrowEnd: 'open', g: '——', t: 'solid open' }, + { line: 'dotted', arrowEnd: 'open', g: '⋯⋯', t: 'dotted open' }, + { line: 'thick', arrowEnd: 'open', g: '══', t: 'thick open' }, + { line: 'solid', arrowEnd: 'cross', g: '—✕', t: 'solid cross' }, + { line: 'solid', arrowEnd: 'circle', g: '—●', t: 'solid circle' }, + ] + return [ + { + icon: ICONS.arrowRight, + title: 'Edge type', + panel: { + title: 'Edge type', + items: kinds.map((k) => ({ + glyph: k.g, + title: k.t, + selected: edge.seg.line === k.line && edge.seg.arrowEnd === k.arrowEnd, + onClick: () => + this.editor.dispatch({ type: 'setEdgeStyle', edgeId: id, line: k.line, arrowEnd: k.arrowEnd }), + })), + }, + }, + { + icon: ICONS.palette, + title: 'Edge color', + panel: { + title: 'Edge color', + sections: [this.colorSection('Stroke', (value) => this.editor.dispatch({ type: 'setEdgeColor', edgeId: id, value }))], + }, + }, + { + icon: ICONS.arrowLeftRight, + title: 'Reverse direction', + onClick: () => this.editor.dispatch({ type: 'reverseEdge', edgeId: id }), + }, + rename, + del, + ] + } + + if (this.editor.result.state && id.startsWith('state:')) { + const s = this.editor.result.state.stateById.get(id.slice(6)) + if (!s) return [] + if (s.isComposite) return [rename] + const types = [ + { t: 'state', g: '▢' }, + { t: 'choice', g: '◇' }, + { t: 'fork', g: '⑃' }, + { t: 'join', g: '⑂' }, + ] as const + return [ + { + icon: ICONS.square, + title: 'State type', + panel: { + title: 'State type', + items: types.map((k) => ({ + glyph: k.g, + title: k.t, + selected: (s.stereotype ?? 'state') === k.t, + onClick: () => this.editor.dispatch({ type: 'st.setStateType', id: s.id, stype: k.t }), + })), + }, + }, + { + icon: ICONS.stickyNote, + title: 'Add note', + panel: { + title: 'Note', + items: (['left', 'right'] as const).map((side) => ({ + glyph: side === 'left' ? '◧' : '◨', + title: `Note ${side}`, + onClick: () => this.editor.dispatch({ type: 'st.addStateNote', id: s.id, side }), + })), + }, + }, + { + icon: ICONS.frame, + title: 'Move to composite', + onClick: () => this.editor.dispatch({ type: 'st.moveToComposite', id: s.id }), + }, + rename, + del, + ] + } + if (this.editor.result.state && id.startsWith('trans:')) { + return [ + { + icon: ICONS.arrowLeftRight, + title: 'Reverse direction', + onClick: () => this.editor.dispatch({ type: 'st.reverseTransition', transId: id }), + }, + rename, + del, + ] + } + + if (this.editor.result.classGraph && id.startsWith('class:')) { + const c = this.editor.result.classGraph.classById.get(id.slice(6)) + if (!c) return [] + const annotations = ['interface', 'abstract', 'service', 'enumeration'] as const + return [ + { + icon: ICONS.listPlus, + title: 'Add member', + onClick: () => { + const res = this.editor.dispatch({ type: 'cl.addMember', id: c.id, text: '+attribute' }) + void res + }, + }, + { + icon: ICONS.braces, + title: 'Annotation', + panel: { + title: 'Annotation', + items: [ + ...annotations.map((a) => ({ + glyph: a, + title: `<<${a}>>`, + onClick: () => this.editor.dispatch({ type: 'cl.setAnnotation', id: c.id, annotation: a }), + })), + { + glyph: 'none', + title: 'Remove annotation', + onClick: () => this.editor.dispatch({ type: 'cl.setAnnotation', id: c.id, annotation: null }), + }, + ], + }, + }, + { + icon: ICONS.stickyNote, + title: 'Add note', + onClick: () => this.editor.dispatch({ type: 'cl.addNoteFor', id: c.id }), + }, + { icon: ICONS.pencil, title: 'Rename class', onClick: () => this.editEntityLabel(id) }, + del, + ] + } + if (this.editor.result.classGraph && id.startsWith('rel:')) { + const r = this.editor.result.classGraph.relations.find((rr) => rr.entityId === id) + if (!r) return [] + const kinds: Array<{ op: string; g: string; t: string }> = [ + { op: '<|--', g: '◁—', t: 'inheritance' }, + { op: '*--', g: '◆—', t: 'composition' }, + { op: 'o--', g: '◇—', t: 'aggregation' }, + { op: '-->', g: '—▶', t: 'association' }, + { op: '--', g: '——', t: 'link (solid)' }, + { op: '..>', g: '⋯▶', t: 'dependency' }, + { op: '..|>', g: '⋯▷', t: 'realization' }, + { op: '..', g: '⋯⋯', t: 'link (dashed)' }, + ] + return [ + { + icon: ICONS.arrowRight, + title: 'Relation type', + panel: { + title: 'Relation type', + items: kinds.map((k) => ({ + glyph: k.g, + title: k.t, + selected: r.stmt.op === k.op, + onClick: () => + this.editor.dispatch({ type: 'cl.setRelationType', relId: id, op: k.op as never }), + })), + }, + }, + { + icon: ICONS.arrowLeftRight, + title: 'Reverse direction', + onClick: () => this.editor.dispatch({ type: 'cl.reverseRelation', relId: id }), + }, + { + glyph: '1:1', + title: 'Cardinality (e.g. 1, 0..1, 1..*, *)', + onClick: () => { + const anchor = this.entityElement(id) + if (!anchor) return + this.popover.hide() + const current = `${r.stmt.sourceCard ?? ''}:${r.stmt.targetCard ?? ''}` + this.openOverlayEditor(anchor, current, (v) => { + const [src = '', tgt = ''] = v.split(':') + this.editor.dispatch({ type: 'cl.setCardinality', relId: id, side: 'source', value: src.trim() || null }, 'canvas') + const again = this.editor.result.classGraph?.relations.find( + (rr) => rr.lineIndex === r.lineIndex && rr.source === r.source && rr.target === r.target, + ) + if (again) { + this.editor.dispatch( + { type: 'cl.setCardinality', relId: again.entityId, side: 'target', value: tgt.trim() || null }, + 'canvas', + ) + } + }) + }, + }, + rename, + del, + ] + } + + if (this.editor.result.er && id.startsWith('entity:')) { + const e = this.editor.result.er.entityById.get(id.slice(7)) + if (!e) return [] + return [ + { + icon: ICONS.listPlus, + title: 'Add attribute', + onClick: () => this.editor.dispatch({ type: 'er.addAttribute', id: e.id }), + }, + { icon: ICONS.pencil, title: 'Rename entity', onClick: () => this.editEntityLabel(id) }, + del, + ] + } + if (this.editor.result.er && id.startsWith('erel:')) { + const r = this.editor.result.er.relations.find((rr) => rr.entityId === id) + if (!r) return [] + const cards = [ + { c: 'zero-or-one', g: '0..1' }, + { c: 'exactly-one', g: '1' }, + { c: 'zero-or-more', g: '0..*' }, + { c: 'one-or-more', g: '1..*' }, + ] as const + const cardPanel = (side: 'left' | 'right'): PopoverAction => ({ + icon: side === 'left' ? ICONS.arrowLeftToLine : ICONS.arrowRightToLine, + title: `${side === 'left' ? 'Source' : 'Target'} cardinality`, + panel: { + title: `${side === 'left' ? r.source : r.target} cardinality`, + items: cards.map((k) => ({ + glyph: k.g, + title: k.c, + onClick: () => this.editor.dispatch({ type: 'er.setCardinality', relId: id, side, card: k.c }), + })), + }, + }) + return [ + cardPanel('left'), + cardPanel('right'), + { + icon: r.stmt.line === '--' ? ICONS.minus : ICONS.ellipsis, + title: r.stmt.line === '--' ? 'Make non-identifying (dashed)' : 'Make identifying (solid)', + onClick: () => this.editor.dispatch({ type: 'er.setIdentifying', relId: id, identifying: r.stmt.line !== '--' }), + }, + rename, + del, + ] + } + + if (this.editor.result.pie && id.startsWith('slice:')) { + const s = this.editor.result.pie.slices.find((sl) => sl.entityId === id) + if (!s) return [] + return [ + { icon: ICONS.hash, title: 'Edit value', onClick: () => this.editEntityLabel(id) }, + { + icon: ICONS.pencil, + title: 'Edit label', + onClick: () => { + const anchor = this.entityElement(id) + if (anchor) { + this.popover.hide() + this.openOverlayEditor(anchor, s.label, (v) => + this.editor.dispatch({ type: 'pie.setLabel', sliceId: id, label: v }, 'canvas'), + ) + } + }, + }, + del, + ] + } + + if (this.editor.result.gantt && id.startsWith('task:')) { + const t = this.editor.result.gantt.tasks.find((tk) => tk.entityId === id) + if (!t) return [] + return [ + { icon: ICONS.pencil, title: 'Rename task', onClick: () => this.editEntityLabel(id) }, + { + icon: ICONS.calendar, + title: 'Edit schedule (id, start, duration)', + onClick: () => { + const anchor = this.entityElement(id) + if (anchor) { + this.popover.hide() + this.openOverlayEditor(anchor, t.meta, (v) => + this.editor.dispatch({ type: 'gantt.setTaskMeta', taskId: id, meta: v }, 'canvas'), + ) + } + }, + }, + del, + ] + } + + if (this.editor.result.lineItems && id.startsWith('item:')) { + return [{ icon: ICONS.pencil, title: 'Edit line', onClick: () => this.editEntityLabel(id) }, del] + } + + if (sequence && id.startsWith('participant:')) { + const p = sequence.participantById.get(id.slice(12)) + if (!p) return [] + const glyphs: Record = { + participant: '▭', + actor: '웃', + boundary: '⊢○', + control: '◉', + entity: '◯', + database: '⛁', + collections: '⧉', + queue: '▤', + } + return [ + { + icon: ICONS.user, + title: 'Participant type', + panel: { + title: 'Participant type', + items: PARTICIPANT_TYPES.map((pt): PopoverPanelItem => ({ + glyph: glyphs[pt], + title: pt, + selected: p.ptype === pt, + onClick: () => this.editor.dispatch({ type: 'seq.setParticipantType', id: p.id, ptype: pt }), + })), + }, + }, + { icon: ICONS.pencil, title: 'Rename', onClick: () => this.editEntityLabel(id) }, + del, + ] + } + + if (sequence && id.startsWith('event:')) { + const ev = sequence.events.find((e) => e.entityId === id) + if (!ev) return [] + if (ev.kind === 'message') { + const glyphs: Record = { + '->': '——', + '->>': '—▶', + '-->': '⋯⋯', + '-->>': '⋯▶', + '-x': '—✕', + '--x': '⋯✕', + '-)': '—⟩', + '--)': '⋯⟩', + } + return [ + { + icon: ICONS.arrowRight, + title: 'Message type', + panel: { + title: 'Message type', + items: MESSAGE_OPS.map((m): PopoverPanelItem => ({ + glyph: glyphs[m.op], + title: m.label, + selected: ev.stmt.op === m.op, + onClick: () => this.editor.dispatch({ type: 'seq.setMessageOp', eventId: id, op: m.op }), + })), + }, + }, + { + icon: ICONS.arrowLeftRight, + title: 'Reverse direction', + onClick: () => this.editor.dispatch({ type: 'seq.reverseMessage', eventId: id }), + }, + { + icon: ICONS.group, + title: 'Wrap in fragment', + panel: { + title: 'Fragment', + items: (['loop', 'alt', 'opt', 'par', 'critical', 'break', 'rect'] as const).map((kind) => ({ + glyph: kind, + title: `Wrap in ${kind}`, + onClick: () => this.editor.dispatch({ type: 'seq.wrapInFragment', eventId: id, kind }), + })), + }, + }, + rename, + del, + ] + } + return [rename, del] + } + + return [] + } + + // ----- sequence message drag-reorder ----- + + private computeDropSlots() { + const graph = this.editor.result.sequence + this.dropSlots = [] + if (!graph || !this.seqCorrelation) return + const boxes = graph.events + .map((ev) => { + const el = this.seqCorrelation!.events.get(ev.entityId) + if (!el) return null + const b = this.hostRect(el) + return { ev, y: b.top + b.height / 2 } + }) + .filter((v): v is { ev: (typeof graph.events)[number]; y: number } => v !== null) + if (!boxes.length) return + this.dropSlots.push({ y: boxes[0].y - 24, afterEvent: null }) + for (let i = 1; i < boxes.length; i++) { + this.dropSlots.push({ y: (boxes[i - 1].y + boxes[i].y) / 2, afterEvent: boxes[i - 1].ev.entityId }) + } + this.dropSlots.push({ y: boxes[boxes.length - 1].y + 24, afterEvent: boxes[boxes.length - 1].ev.entityId }) + } + + private nearestSlot(y: number): { y: number; afterEvent: string | null } | null { + let best: { y: number; afterEvent: string | null } | null = null + let bestDist = Infinity + for (const slot of this.dropSlots) { + const d = Math.abs(slot.y - y) + if (d < bestDist) { + bestDist = d + best = slot + } + } + return best + } + + private showDropIndicator(y: number) { + if (!this.dropIndicator) { + this.dropIndicator = document.createElement('div') + this.dropIndicator.className = 'mw-drop-indicator' + this.overlayHost.appendChild(this.dropIndicator) + } + this.dropIndicator.style.top = `${y}px` + } + + // ----- sequence lifeline insertion (+ buttons) ----- + + private plusHovered = false + private lifelineClearTimer: ReturnType | null = null + + private onLifelineHover(e: PointerEvent) { + if (this.readOnly || !this.seqCorrelation || this.connectSource) return + const graph = this.editor.result.sequence + if (!graph) return + let found: string | null = null + for (const [entityId, line] of this.seqCorrelation.lifelines) { + const r = line.getBoundingClientRect() + const cx = r.left + r.width / 2 + if (Math.abs(e.clientX - cx) <= 16 && e.clientY >= r.top - 8 && e.clientY <= r.bottom + 8) { + found = entityId + break + } + } + if (found === this.hoveredLifeline) return + if (found) this.showLifelinePlus(found) + else this.scheduleLifelineClear() + } + + private showLifelinePlus(participantEntity: string) { + this.clearLifelineUi() + const graph = this.editor.result.sequence + const line = this.seqCorrelation?.lifelines.get(participantEntity) + if (!graph || !line) return + this.hoveredLifeline = participantEntity + const participantId = participantEntity.slice(12) + const lineBox = this.hostRect(line) + const x = lineBox.left + lineBox.width / 2 + + const anchors: Array<{ y: number; afterEvent: string | null }> = [] + const eventBoxes = graph.events + .map((ev) => { + const el = this.seqCorrelation?.events.get(ev.entityId) + if (!el) return null + const b = this.hostRect(el) + return { ev, y: b.top + b.height / 2 } + }) + .filter((v): v is { ev: (typeof graph.events)[number]; y: number } => v !== null) + + if (eventBoxes.length === 0) { + anchors.push({ y: lineBox.top + lineBox.height / 2, afterEvent: null }) + } else { + anchors.push({ y: (lineBox.top + eventBoxes[0].y) / 2, afterEvent: null }) + for (let i = 1; i < eventBoxes.length; i++) { + anchors.push({ y: (eventBoxes[i - 1].y + eventBoxes[i].y) / 2, afterEvent: eventBoxes[i - 1].ev.entityId }) + } + anchors.push({ + y: (eventBoxes[eventBoxes.length - 1].y + lineBox.top + lineBox.height) / 2, + afterEvent: eventBoxes[eventBoxes.length - 1].ev.entityId, + }) + } + + for (const anchor of anchors) { + const btn = document.createElement('button') + btn.type = 'button' + btn.className = 'mw-lifeline-plus' + btn.textContent = '+' + btn.title = 'Insert here (self message / note)' + btn.style.left = `${x}px` + btn.style.top = `${anchor.y}px` + btn.addEventListener('pointerenter', () => { + this.plusHovered = true + }) + btn.addEventListener('pointerleave', () => { + this.plusHovered = false + this.scheduleLifelineClear() + }) + btn.addEventListener('click', (e) => { + e.stopPropagation() + this.openPlusMenu(btn, participantId, anchor.afterEvent) + }) + this.overlayHost.appendChild(btn) + this.plusButtons.push(btn) + } + } + + private openPlusMenu(button: HTMLElement, participantId: string, afterEvent: string | null) { + this.plusMenu?.remove() + const menu = document.createElement('div') + menu.className = 'mw-plus-menu' + menu.style.left = button.style.left + menu.style.top = `${parseFloat(button.style.top) + 12}px` + menu.addEventListener('pointerdown', (e) => e.stopPropagation()) + + const item = (label: string, fn: () => void) => { + const b = document.createElement('button') + b.type = 'button' + b.textContent = label + b.addEventListener('click', (e) => { + e.stopPropagation() + this.clearLifelineUi() + fn() + }) + menu.appendChild(b) + } + item('↩ Self message', () => { + const res = this.editor.dispatch( + { type: 'seq.addMessage', source: participantId, target: participantId, text: 'message', afterEvent }, + 'canvas', + ) + if (res?.created?.[0]) this.pendingEditEntity = res.created[0] + }) + const note = (label: string, placement: 'over' | 'left of' | 'right of') => + item(label, () => { + const res = this.editor.dispatch( + { type: 'seq.addNote', participant: participantId, placement, text: 'note', afterEvent }, + 'canvas', + ) + if (res?.created?.[0]) this.pendingEditEntity = res.created[0] + }) + note('▭ Note over', 'over') + note('▭ Note left', 'left of') + note('▭ Note right', 'right of') + + this.overlayHost.appendChild(menu) + this.plusMenu = menu + } + + private scheduleLifelineClear() { + if (this.lifelineClearTimer) clearTimeout(this.lifelineClearTimer) + this.lifelineClearTimer = setTimeout(() => { + if (!this.plusHovered && !this.plusMenu) this.clearLifelineUi() + }, 400) + } + + private clearLifelineUi() { + this.plusButtons.forEach((b) => b.remove()) + this.plusButtons = [] + this.plusMenu?.remove() + this.plusMenu = null + this.hoveredLifeline = null + this.plusHovered = false + } + + // ----- geometry helpers ----- + + private clientToSvg(x: number, y: number): { x: number; y: number } | null { + if (!this.svg) return null + const ctm = this.svg.getScreenCTM() + if (!ctm) return null + const pt = new DOMPoint(x, y).matrixTransform(ctm.inverse()) + return { x: pt.x, y: pt.y } + } + + private entityCenter(entityId: string): { x: number; y: number } | null { + const el = this.entityElement(entityId) + if (!el) return null + const rect = el.getBoundingClientRect() + return this.clientToSvg(rect.left + rect.width / 2, rect.top + rect.height / 2) + } + + /** Validate code through mermaid.parse and publish diagnostics (does not render). */ + async validate(): Promise { + try { + await this.mermaid.parse(this.editor.code) + return true + } catch (err) { + const message = err instanceof Error ? err.message.split('\n')[0] : String(err) + this.editor.setDiagnostics([{ message, span: null, severity: 'error', source: 'mermaid' }]) + return false + } + } + + destroy() { + if (this.renderTimer) clearTimeout(this.renderTimer) + if (this.lifelineClearTimer) clearTimeout(this.lifelineClearTimer) + if (this.inPlaceSession?.liveTimer) clearTimeout(this.inPlaceSession.liveTimer) + this.inPlaceSession = null + this.svgTransformObserver?.disconnect() + this.svgTransformObserver = null + this.disposers.forEach((d) => d()) + this.closeInlineEditor(false) + this.cancelConnect() + this.clearLifelineUi() + this.popover.hide() + this.container.classList.remove('mw-canvas', 'mw-tool-connect', 'mw-readonly', 'mw-panzoom', 'mw-panning') + this.svgHost.remove() + this.overlayHost.remove() + this.errorBadge.remove() + this.zoomControls?.remove() + } +} diff --git a/packages/dom/test/inplace.test.ts b/packages/dom/test/inplace.test.ts new file mode 100644 index 0000000..e46d156 --- /dev/null +++ b/packages/dom/test/inplace.test.ts @@ -0,0 +1,226 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MermaidWysiwygEditor } from '@visimer/core' +import { MermaidCanvasView, type MermaidLike } from '../src' + +// jsdom has no CSS.escape (browsers do) +if (typeof (globalThis as { CSS?: unknown }).CSS === 'undefined') { + ;(globalThis as { CSS?: { escape(s: string): string } }).CSS = { + escape: (s: string) => s.replace(/[^a-zA-Z0-9_-]/g, (c) => `\\${c}`), + } +} + +const CODE = 'flowchart TD\n A[Write a doc] --> B[End]\n' + +/** + * Fake mermaid that emits the structural skeleton correlateFlowchart matches + * on (g.node ids, edge path ids, .edgeLabels) with foreignObject HTML labels, + * so the in-place editing loop runs against the same DOM shape as production. + * Set `gate` to hold the next render un-resolved — that models the async gap + * between a live commit and the SVG swap landing. + */ +function makeFakeMermaid() { + let release: (() => void) | null = null + const fake: MermaidLike & { gate(): void; release(): void } = { + initialize() {}, + async render(_id: string, code: string) { + if (release === undefined) { + // unreachable; keeps TS happy about the closure shape + } + if (gateNext) { + gateNext = false + await new Promise((r) => { + release = r + }) + } + const nodes = [...code.matchAll(/(\w+)\[([^\]]*)\]/g)] + const edges = [...code.matchAll(/(\w+)\[[^\]]*\]\s*-->\s*(\w+)/g)] + const svg = [ + '', + '', + ...nodes.map( + ([, id, label], i) => + `` + + `

${label}

` + + `
`, + ), + '
', + '', + ...edges.map(([, s, t], i) => ``), + '', + '', + ...edges.map(() => ''), + '', + '
', + ].join('') + return { svg } + }, + async parse() { + return {} + }, + gate() { + gateNext = true + }, + release() { + release?.() + release = null + }, + } + let gateNext = false + return fake +} + +function labelOf(container: HTMLElement, entity: string): HTMLElement { + const p = container.querySelector(`[data-mw-entity="${entity}"] .nodeLabel p`) + if (!p) throw new Error(`no label for ${entity}`) + return p +} + +/** simulate typing: replace the label text and fire the input event */ +function typeInto(label: HTMLElement, text: string) { + label.textContent = text + label.dispatchEvent(new Event('input')) +} + +describe('in-place label editing survives the live-commit re-render', () => { + let editor: MermaidWysiwygEditor + let container: HTMLElement + let view: MermaidCanvasView + let mermaid: ReturnType + + beforeEach(async () => { + vi.useFakeTimers() + editor = new MermaidWysiwygEditor({ code: CODE }) + container = document.createElement('div') + document.body.appendChild(container) + mermaid = makeFakeMermaid() + view = new MermaidCanvasView({ editor, container, mermaid, debounceMs: 0 }) + await view.render() + }) + + afterEach(() => { + view.destroy() + container.remove() + vi.useRealTimers() + }) + + it('live-commits typing after the debounce', async () => { + view.editEntityLabel('node:A') + const label = labelOf(container, 'node:A') + expect(label.getAttribute('contenteditable')).toBe('true') + + typeInto(label, 'Write a docX') + await vi.advanceTimersByTimeAsync(450) + expect(editor.code).toContain('A[Write a docX]') + }) + + it('never swaps the label out from under an active session', async () => { + view.editEntityLabel('node:A') + const label = labelOf(container, 'node:A') + typeInto(label, 'Write a docX') + // the live commit fires and schedules a re-render, but the canvas must + // hold it while the session is typing — like any textbox, the element + // under the caret never changes + await vi.advanceTimersByTimeAsync(2000) + expect(editor.code).toContain('A[Write a docX]') + const sameLabel = labelOf(container, 'node:A') + expect(sameLabel).toBe(label) + expect(sameLabel.getAttribute('contenteditable')).toBe('true') + + // finishing the session applies the held render + label.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })) + await vi.advanceTimersByTimeAsync(500) + const newLabel = labelOf(container, 'node:A') + expect(newLabel).not.toBe(label) + expect(newLabel.textContent).toBe('Write a docX') + expect(newLabel.getAttribute('contenteditable')).toBeNull() + }) + + it('carries typed text and caret across a swap that was already in flight', async () => { + // a render is mid-flight (started before the session opened) … + mermaid.gate() + editor.dispatch({ type: 'renameNode', id: 'B', label: 'Finish' }) + await vi.advanceTimersByTimeAsync(1) + + // … when the user starts editing and types + view.editEntityLabel('node:A') + const label = labelOf(container, 'node:A') + typeInto(label, 'Write a docXY') + // caret mid-text (offset 5, after "Write") + const sel = window.getSelection()! + const range = document.createRange() + range.setStart(label.firstChild!, 5) + range.collapse(true) + sel.removeAllRanges() + sel.addRange(range) + + // the in-flight swap lands mid-session + mermaid.release() + for (let i = 0; i < 5; i++) await Promise.resolve() + await vi.advanceTimersByTimeAsync(0) + + const newLabel = labelOf(container, 'node:A') + expect(newLabel).not.toBe(label) + // typed text carried over, session re-attached + expect(newLabel.textContent).toBe('Write a docXY') + expect(newLabel.getAttribute('contenteditable')).toBe('true') + // caret back at its absolute offset + const after = window.getSelection()! + expect(newLabel.contains(after.anchorNode)).toBe(true) + const measure = document.createRange() + measure.selectNodeContents(newLabel) + measure.setEnd(after.anchorNode!, after.anchorOffset) + expect(measure.toString().length).toBe(5) + + // and the carried delta commits through the normal debounce + await vi.advanceTimersByTimeAsync(450) + expect(editor.code).toContain('A[Write a docXY]') + }) + + it('editing another entity first commits the open session', async () => { + view.editEntityLabel('node:A') + const labelA = labelOf(container, 'node:A') + typeInto(labelA, 'Write a docZ') + + // switch to node B before the live commit debounce fires + view.editEntityLabel('node:B') + expect(editor.code).toContain('A[Write a docZ]') + expect(labelA.getAttribute('contenteditable')).toBeNull() + expect(labelOf(container, 'node:B').getAttribute('contenteditable')).toBe('true') + await vi.advanceTimersByTimeAsync(1000) + expect(editor.code).toContain('A[Write a docZ]') + }) + + it('a second double-click on the same node does not restart the session', async () => { + view.editEntityLabel('node:A') + const label = labelOf(container, 'node:A') + typeInto(label, 'Write a docQ') + + // same entity again (e.g. double-click while already editing) + view.editEntityLabel('node:A') + expect(labelOf(container, 'node:A')).toBe(label) + // the pending live edit still commits exactly once + await vi.advanceTimersByTimeAsync(450) + expect(editor.code).toContain('A[Write a docQ]') + await vi.advanceTimersByTimeAsync(1000) + expect(editor.code.match(/Write a docQ/g)?.length).toBe(1) + }) + + it('Escape mid-session reverts live-committed intermediate states', async () => { + view.editEntityLabel('node:A') + const label = labelOf(container, 'node:A') + typeInto(label, 'Half typed') + await vi.advanceTimersByTimeAsync(500) + expect(editor.code).toContain('A[Half typed]') + + const current = labelOf(container, 'node:A') + expect(current.getAttribute('contenteditable')).toBe('true') + current.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })) + await vi.advanceTimersByTimeAsync(500) + expect(editor.code).toContain('A[Write a doc]') + expect(editor.code).not.toContain('Half typed') + // the canvas label must revert too — the reverted code can equal the last + // rendered code, in which case no re-render will repaint it + expect(labelOf(container, 'node:A').textContent).toBe('Write a doc') + }) +}) diff --git a/packages/dom/tsconfig.json b/packages/dom/tsconfig.json new file mode 100644 index 0000000..f200f34 --- /dev/null +++ b/packages/dom/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src"] +} diff --git a/packages/monaco/README.md b/packages/monaco/README.md new file mode 100644 index 0000000..07afbfb --- /dev/null +++ b/packages/monaco/README.md @@ -0,0 +1,31 @@ +# @visimer/monaco + +Monaco binding for [visimer](https://github.com/inkeep/visimer). +You create and own the Monaco editor (workers, theme, options); `bindMonaco` +wires the two-way sync: typing flows into the engine, engine changes come back +as minimal edits, entity selection renders as decorations and follows the +caret, and undo/redo share the canvas history. + +This package has no dependency on `monaco-editor`, not even for types, so it +never pins your Monaco version. It binds against a small structural interface +that any real Monaco editor satisfies. + +```ts +import * as monaco from 'monaco-editor' +import { MermaidWysiwygEditor } from '@visimer/core' +import { bindMonaco, registerMermaidLanguage } from '@visimer/monaco' + +registerMermaidLanguage(monaco) // optional syntax highlighting + +const editor = new MermaidWysiwygEditor({ code: 'flowchart TD\n A --> B' }) +const me = monaco.editor.create(host, { value: editor.code, language: 'mermaid' }) +const binding = bindMonaco(editor, me) + +// style the selection highlight however you like: +// .mw-monaco-entity { background: rgba(105, 163, 255, 0.28); border-radius: 3px; } + +binding.dispose() // unhooks listeners and clears decorations +``` + +Pairs with [`@visimer/dom`](https://npmjs.com/package/@visimer/dom) +(interactive canvas). Docs: [github.com/inkeep/visimer](https://github.com/inkeep/visimer) diff --git a/packages/monaco/package.json b/packages/monaco/package.json new file mode 100644 index 0000000..46fe1dd --- /dev/null +++ b/packages/monaco/package.json @@ -0,0 +1,57 @@ +{ + "name": "@visimer/monaco", + "version": "0.1.0", + "description": "Monaco binding for visimer: two-way code sync, entity highlight decorations, shared undo authority. No monaco-editor dependency; bring your own instance.", + "type": "module", + "main": "./src/index.ts", + "types": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json", + "build": "tsup src/index.ts --format esm --dts --sourcemap --out-dir dist", + "test": "vitest run" + }, + "dependencies": { + "@visimer/core": "workspace:*" + }, + "devDependencies": { + "monaco-editor": "^0.55.1", + "typescript": "^5.6.3", + "vitest": "^2.1.9" + }, + "license": "MIT", + "files": [ + "dist", + "src" + ], + "publishConfig": { + "access": "public", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + } + }, + "author": "Inkeep", + "repository": { + "type": "git", + "url": "git+https://github.com/inkeep/visimer.git", + "directory": "packages/monaco" + }, + "homepage": "https://github.com/inkeep/visimer#readme", + "bugs": "https://github.com/inkeep/visimer/issues", + "keywords": [ + "mermaid", + "wysiwyg", + "diagram", + "editor", + "monaco", + "visual-editing" + ], + "sideEffects": false +} diff --git a/packages/monaco/src/index.ts b/packages/monaco/src/index.ts new file mode 100644 index 0000000..c3876c4 --- /dev/null +++ b/packages/monaco/src/index.ts @@ -0,0 +1,144 @@ +/** + * Monaco binding for visimer, built on core's editor-agnostic + * `bindTextPane` contract. + * + * The types below are structural on purpose: this package has no dependency + * on `monaco-editor`, not even for types, so it never pins your Monaco + * version or pulls a second copy into your bundle. Any real + * `IStandaloneCodeEditor` satisfies `MonacoEditorLike` (there is a + * compile-time conformance check against the real types in this package's + * test suite). + */ + +import { bindTextPane, type MermaidWysiwygEditor, type TextPaneBinding } from '@visimer/core' + +export { mermaidMonarchTokens, registerMermaidLanguage, type MonacoNamespaceLike } from './language' + +export interface MonacoRangeLike { + startLineNumber: number + startColumn: number + endLineNumber: number + endColumn: number +} + +export interface MonacoPositionLike { + lineNumber: number + column: number +} + +export interface MonacoDisposableLike { + dispose(): void +} + +export interface MonacoModelLike { + getValue(): string + setValue(text: string): void + applyEdits(edits: Array<{ range: MonacoRangeLike; text: string }>): unknown + getPositionAt(offset: number): MonacoPositionLike + getOffsetAt(position: MonacoPositionLike): number + onDidChangeContent(listener: (event: unknown) => void): MonacoDisposableLike +} + +export interface MonacoDecorationsCollectionLike { + set(decorations: Array<{ range: MonacoRangeLike; options: { inlineClassName: string } }>): unknown + clear(): void +} + +export interface MonacoEditorLike { + getModel(): MonacoModelLike | null + createDecorationsCollection(): MonacoDecorationsCollectionLike + onDidChangeCursorPosition(listener: (event: { position: MonacoPositionLike; reason: number }) => void): MonacoDisposableLike + onKeyDown(listener: (event: { + metaKey: boolean + ctrlKey: boolean + shiftKey: boolean + browserEvent: { key: string } + preventDefault(): void + stopPropagation(): void + }) => void): MonacoDisposableLike + revealPositionInCenter(position: MonacoPositionLike): void +} + +export interface BindMonacoOptions { + /** class applied to entity-highlight decorations; style it in your CSS (default 'mw-monaco-entity') */ + highlightClassName?: string + /** route Mod+Z / Mod+Shift+Z to the engine's unified history (default true) */ + bindUndoKeys?: boolean +} + +/** monaco.editor.CursorChangeReason.Explicit — mouse/keyboard caret moves, not edits */ +const CURSOR_EXPLICIT = 3 + +/** + * Bind an existing Monaco editor to a MermaidWysiwygEditor. + * + * You create and own the Monaco editor (workers, theme, options); this only + * wires the two-way sync: typing flows into the engine, engine changes come + * back as minimal edits, entity selection renders as inline decorations and + * follows the caret, and undo/redo share the canvas history stack. + * + * Returns the binding; call `dispose()` to unhook everything. + */ +export function bindMonaco( + editor: MermaidWysiwygEditor, + monacoEditor: MonacoEditorLike, + options: BindMonacoOptions = {}, +): TextPaneBinding { + const model = monacoEditor.getModel() + if (!model) throw new Error('bindMonaco: the Monaco editor has no model') + const highlightClassName = options.highlightClassName ?? 'mw-monaco-entity' + const decorations = monacoEditor.createDecorationsCollection() + + const range = (start: number, end: number): MonacoRangeLike => { + const s = model.getPositionAt(start) + const e = model.getPositionAt(end) + return { startLineNumber: s.lineNumber, startColumn: s.column, endLineNumber: e.lineNumber, endColumn: e.column } + } + + const binding = bindTextPane(editor, { + getText: () => model.getValue(), + applyEdits: (edits) => { + model.applyEdits(edits.map((e) => ({ range: range(e.start, e.end), text: e.text }))) + }, + setText: (text) => model.setValue(text), + setHighlights: (spans) => + decorations.set(spans.map((s) => ({ range: range(s.start, s.end), options: { inlineClassName: highlightClassName } }))), + revealPosition: (offset) => monacoEditor.revealPositionInCenter(model.getPositionAt(offset)), + }) + + const subs: MonacoDisposableLike[] = [ + model.onDidChangeContent(() => binding.notifyTextChange()), + monacoEditor.onDidChangeCursorPosition((ev) => { + if (ev.reason === CURSOR_EXPLICIT) binding.notifyCaretMove(model.getOffsetAt(ev.position)) + }), + ] + if (options.bindUndoKeys !== false) { + subs.push( + // matched on the browser event's key: monaco's own keyCode enum maps + // synthesized events to Unknown, and the key string is stable anyway + monacoEditor.onKeyDown((e) => { + if ((e.metaKey || e.ctrlKey) && e.browserEvent.key.toLowerCase() === 'z') { + e.preventDefault() + e.stopPropagation() + if (e.shiftKey) binding.redo() + else binding.undo() + } + }), + ) + } + + return { + get applying() { + return binding.applying + }, + notifyTextChange: () => binding.notifyTextChange(), + notifyCaretMove: (offset) => binding.notifyCaretMove(offset), + undo: () => binding.undo(), + redo: () => binding.redo(), + dispose() { + subs.forEach((d) => d.dispose()) + decorations.clear() + binding.dispose() + }, + } +} diff --git a/packages/monaco/src/language.ts b/packages/monaco/src/language.ts new file mode 100644 index 0000000..3a60aed --- /dev/null +++ b/packages/monaco/src/language.ts @@ -0,0 +1,38 @@ +/** Mermaid syntax highlighting for Monaco (Monarch tokens). */ + +/** the slice of the monaco namespace `registerMermaidLanguage` needs */ +export interface MonacoNamespaceLike { + languages: { + getLanguages(): Array<{ id: string }> + register(language: { id: string }): void + setMonarchTokensProvider(languageId: string, provider: unknown): unknown + } +} + +/** Monarch token rules for mermaid source; pass to `setMonarchTokensProvider` */ +export const mermaidMonarchTokens = { + defaultToken: '', + tokenizer: { + root: [ + [/%%.*$/, 'comment'], + [/"[^"]*"/, 'string'], + [ + /^\s*(flowchart|graph|sequenceDiagram|classDiagram-v2|classDiagram|stateDiagram-v2|stateDiagram|erDiagram|pie|gantt|journey|timeline|quadrantChart|requirementDiagram|gitGraph|C4Context|C4Container|C4Component|C4Dynamic|C4Deployment|mindmap|kanban|packet-beta|packet|sankey-beta|radar-beta|treemap-beta|xychart-beta|block-beta|architecture-beta|zenuml)\b/, + 'keyword', + ], + [ + /\b(participant|actor|boundary|control|entity|database|collections|queue|loop|alt|else|opt|par|critical|break|rect|end|subgraph|direction|title|section|class|state|note|autonumber|dateFormat|axisFormat)\b/, + 'keyword', + ], + [/x)o]?|:::?/, 'operator'], + [/\d+(\.\d+)?/, 'number'], + ], + }, +} + +/** Register the mermaid language with a monaco namespace (idempotent). */ +export function registerMermaidLanguage(monaco: MonacoNamespaceLike): void { + if (monaco.languages.getLanguages().some((l) => l.id === 'mermaid')) return + monaco.languages.register({ id: 'mermaid' }) + monaco.languages.setMonarchTokensProvider('mermaid', mermaidMonarchTokens) +} diff --git a/packages/monaco/test/binding.test.ts b/packages/monaco/test/binding.test.ts new file mode 100644 index 0000000..8d6b37c --- /dev/null +++ b/packages/monaco/test/binding.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from 'vitest' +import type * as monaco from 'monaco-editor' +import { MermaidWysiwygEditor } from '@visimer/core' +import { + bindMonaco, + registerMermaidLanguage, + type MonacoDecorationsCollectionLike, + type MonacoEditorLike, + type MonacoModelLike, + type MonacoNamespaceLike, + type MonacoPositionLike, + type MonacoRangeLike, +} from '../src' + +// Type-level drift canary: a real Monaco editor must satisfy the structural +// interface this package binds against. If a monaco-editor upgrade changes +// the shapes we consume, this stops compiling. +const _conformance: (e: monaco.editor.IStandaloneCodeEditor) => MonacoEditorLike = (e) => e +void _conformance + +const CODE = 'flowchart TD\n A[Start] --> B[End]\n' + +/** minimal in-memory Monaco editor implementing exactly the bound surface */ +function fakeMonaco(initial: string) { + let text = initial + const contentListeners: Array<(e: unknown) => void> = [] + const cursorListeners: Array<(e: { position: MonacoPositionLike; reason: number }) => void> = [] + const keyListeners: Array<(e: { + metaKey: boolean + ctrlKey: boolean + shiftKey: boolean + browserEvent: { key: string } + preventDefault(): void + stopPropagation(): void + }) => void> = [] + + const offsetOf = (p: MonacoPositionLike) => { + const lines = text.split('\n') + let off = 0 + for (let i = 0; i < p.lineNumber - 1; i++) off += lines[i].length + 1 + return off + p.column - 1 + } + const positionOf = (offset: number): MonacoPositionLike => { + const before = text.slice(0, offset) + const lines = before.split('\n') + return { lineNumber: lines.length, column: lines[lines.length - 1].length + 1 } + } + + const model: MonacoModelLike = { + getValue: () => text, + setValue: (t) => { + text = t + contentListeners.forEach((l) => l({})) + }, + applyEdits: (edits) => { + const resolved = edits + .map((e) => ({ start: offsetOf({ lineNumber: e.range.startLineNumber, column: e.range.startColumn }), end: offsetOf({ lineNumber: e.range.endLineNumber, column: e.range.endColumn }), text: e.text })) + .sort((a, b) => b.start - a.start) + for (const e of resolved) text = text.slice(0, e.start) + e.text + text.slice(e.end) + contentListeners.forEach((l) => l({})) + }, + getPositionAt: positionOf, + getOffsetAt: offsetOf, + onDidChangeContent: (l) => { + contentListeners.push(l) + return { dispose: () => contentListeners.splice(contentListeners.indexOf(l), 1) } + }, + } + + const state = { + highlights: [] as MonacoRangeLike[], + revealed: [] as MonacoPositionLike[], + } + const decorations: MonacoDecorationsCollectionLike = { + set: (decos) => { + state.highlights = decos.map((d) => d.range) + }, + clear: () => { + state.highlights = [] + }, + } + + const editor: MonacoEditorLike = { + getModel: () => model, + createDecorationsCollection: () => decorations, + onDidChangeCursorPosition: (l) => { + cursorListeners.push(l) + return { dispose: () => cursorListeners.splice(cursorListeners.indexOf(l), 1) } + }, + onKeyDown: (l) => { + keyListeners.push(l) + return { dispose: () => keyListeners.splice(keyListeners.indexOf(l), 1) } + }, + revealPositionInCenter: (p) => { + state.revealed.push(p) + }, + } + + return { + editor, + model, + state, + get text() { + return text + }, + moveCursor(offset: number, reason = 3) { + cursorListeners.forEach((l) => l({ position: positionOf(offset), reason })) + }, + pressUndo(shift = false) { + keyListeners.forEach((l) => + l({ metaKey: true, ctrlKey: false, shiftKey: shift, browserEvent: { key: 'z' }, preventDefault() {}, stopPropagation() {} }), + ) + }, + } +} + +describe('bindMonaco', () => { + it('applies engine ops to the model as minimal edits', () => { + const editor = new MermaidWysiwygEditor({ code: CODE }) + const fake = fakeMonaco(editor.code) + bindMonaco(editor, fake.editor) + editor.dispatch({ type: 'renameNode', id: 'A', label: 'Begin' }) + expect(fake.text).toBe(editor.code) + expect(fake.text).toContain('A[Begin]') + }) + + it('flows model edits into the engine without echoing back', () => { + const editor = new MermaidWysiwygEditor({ code: CODE }) + const fake = fakeMonaco(editor.code) + bindMonaco(editor, fake.editor) + const pos = fake.text.indexOf('B[End]') + fake.model.applyEdits([ + { + range: { + startLineNumber: fake.model.getPositionAt(pos).lineNumber, + startColumn: fake.model.getPositionAt(pos).column, + endLineNumber: fake.model.getPositionAt(pos + 6).lineNumber, + endColumn: fake.model.getPositionAt(pos + 6).column, + }, + text: 'B[Done]', + }, + ]) + expect(editor.code).toBe(fake.text) + expect(editor.code).toContain('B[Done]') + }) + + it('renders selection as decorations and reveals canvas selections', () => { + const editor = new MermaidWysiwygEditor({ code: CODE }) + const fake = fakeMonaco(editor.code) + bindMonaco(editor, fake.editor) + editor.setSelection(['node:B'], 'canvas') + expect(fake.state.highlights.length).toBeGreaterThan(0) + expect(fake.state.revealed.length).toBe(1) + }) + + it('explicit caret moves select the entity; edit-driven moves do not', () => { + const editor = new MermaidWysiwygEditor({ code: CODE }) + const fake = fakeMonaco(editor.code) + bindMonaco(editor, fake.editor) + fake.moveCursor(CODE.indexOf('A[Start]') + 1) + expect(editor.selection).toEqual(['node:A']) + fake.moveCursor(CODE.indexOf('B[End]') + 1, 0 /* NotSet: cursor moved by an edit */) + expect(editor.selection).toEqual(['node:A']) + }) + + it('routes undo keys to the engine history', () => { + const editor = new MermaidWysiwygEditor({ code: CODE }) + const fake = fakeMonaco(editor.code) + bindMonaco(editor, fake.editor) + editor.dispatch({ type: 'renameNode', id: 'A', label: 'Begin' }) + fake.pressUndo() + expect(fake.text).toBe(CODE) + fake.pressUndo(true) + expect(fake.text).toContain('A[Begin]') + }) + + it('dispose unhooks listeners and clears decorations', () => { + const editor = new MermaidWysiwygEditor({ code: CODE }) + const fake = fakeMonaco(editor.code) + const binding = bindMonaco(editor, fake.editor) + editor.setSelection(['node:A'], 'canvas') + expect(fake.state.highlights.length).toBeGreaterThan(0) + binding.dispose() + expect(fake.state.highlights.length).toBe(0) + editor.dispatch({ type: 'renameNode', id: 'A', label: 'Begin' }) + expect(fake.text).toBe(CODE) + }) + + it('registerMermaidLanguage is idempotent', () => { + const registered: string[] = [] + const ns: MonacoNamespaceLike = { + languages: { + getLanguages: () => registered.map((id) => ({ id })), + register: ({ id }) => registered.push(id), + setMonarchTokensProvider: () => {}, + }, + } + registerMermaidLanguage(ns) + registerMermaidLanguage(ns) + expect(registered).toEqual(['mermaid']) + }) +}) diff --git a/packages/monaco/tsconfig.json b/packages/monaco/tsconfig.json new file mode 100644 index 0000000..3365043 --- /dev/null +++ b/packages/monaco/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"] + }, + "include": ["src", "test"] +} diff --git a/packages/react/README.md b/packages/react/README.md new file mode 100644 index 0000000..8ae1c32 --- /dev/null +++ b/packages/react/README.md @@ -0,0 +1,21 @@ +# @visimer/react + +React bindings for [visimer](https://github.com/inkeep/visimer): +a drop-in visual Mermaid editor component. Click, drag, and type directly on the +diagram; the `code` prop stays in sync with minimal text edits. + +```tsx +import { useState } from 'react' +import mermaid from 'mermaid' +import { MermaidWysiwyg } from '@visimer/react' + +function App() { + const [code, setCode] = useState('flowchart TD\n A[Start] --> B{OK?}') + return +} +``` + +Also exports `MermaidCanvas` (bring your own editor instance) and +`useMermaidEditor` (own the engine, re-render on change/selection). + +Docs: [github.com/inkeep/visimer](https://github.com/inkeep/visimer) diff --git a/packages/react/package.json b/packages/react/package.json new file mode 100644 index 0000000..0063a89 --- /dev/null +++ b/packages/react/package.json @@ -0,0 +1,60 @@ +{ + "name": "@visimer/react", + "version": "0.1.0", + "description": "React bindings for visimer: a drop-in visual Mermaid editor component and hooks.", + "type": "module", + "main": "./src/index.tsx", + "types": "./src/index.tsx", + "exports": { + ".": "./src/index.tsx" + }, + "scripts": { + "typecheck": "tsc -p tsconfig.json", + "build": "tsup src/index.tsx --format esm --dts --sourcemap --out-dir dist --external react" + }, + "dependencies": { + "@visimer/core": "workspace:*", + "@visimer/dom": "workspace:*" + }, + "peerDependencies": { + "react": ">=18" + }, + "devDependencies": { + "@types/react": "^18.3.12", + "react": "^18.3.1", + "typescript": "^5.6.3" + }, + "author": "Inkeep", + "repository": { + "type": "git", + "url": "git+https://github.com/inkeep/visimer.git", + "directory": "packages/react" + }, + "homepage": "https://github.com/inkeep/visimer#readme", + "bugs": "https://github.com/inkeep/visimer/issues", + "keywords": [ + "mermaid", + "wysiwyg", + "diagram", + "editor", + "react", + "visual-editing" + ], + "sideEffects": false, + "license": "MIT", + "files": [ + "dist", + "src" + ], + "publishConfig": { + "access": "public", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + } + } + } +} diff --git a/packages/react/src/index.tsx b/packages/react/src/index.tsx new file mode 100644 index 0000000..a375e0d --- /dev/null +++ b/packages/react/src/index.tsx @@ -0,0 +1,186 @@ +import { useEffect, useRef, useState, useSyncExternalStore, type CSSProperties } from 'react' +import { MermaidWysiwygEditor } from '@visimer/core' +import { MermaidCanvasView, type MermaidLike, type Tool, type ViewHooks } from '@visimer/dom' + +export { MermaidWysiwygEditor } from '@visimer/core' +export { MermaidCanvasView } from '@visimer/dom' + +/** + * Own an editor instance in React. Re-renders on document and selection + * changes; dispatch ops or bind the code string to your own state. + */ +export function useMermaidEditor(initialCode: string) { + const [editor] = useState(() => new MermaidWysiwygEditor({ code: initialCode })) + const code = useSyncExternalStore( + (cb) => editor.on('change', cb), + () => editor.code, + ) + const selection = useSyncExternalStore( + (cb) => editor.on('selectionChange', cb), + () => editor.selection, + ) + return { editor, code, selection } +} + +export interface MermaidWysiwygProps { + /** mermaid source (controlled when `onCodeChange` is provided) */ + code: string + onCodeChange?: (code: string) => void + /** your mermaid instance (`import mermaid from 'mermaid'`) */ + mermaid: MermaidLike + /** passed to `mermaid.initialize` (theme, curves, securityLevel, …) */ + mermaidConfig?: Record + accentColor?: string + readOnly?: boolean + /** fit-to-canvas + drag-pan + pinch/ctrl-wheel zoom with corner controls */ + panZoom?: boolean + tool?: Tool + hooks?: ViewHooks + onSelectionChange?: (entityIds: string[]) => void + /** access the underlying editor and canvas view */ + onReady?: (editor: MermaidWysiwygEditor, view: MermaidCanvasView) => void + className?: string + style?: CSSProperties +} + +/** + * Drop-in visual Mermaid editor: full WYSIWYG canvas bound to a `code` prop. + * + * ```tsx + * const [code, setCode] = useState('flowchart TD\n A --> B') + * + * ``` + */ +export function MermaidWysiwyg(props: MermaidWysiwygProps) { + const { code, onCodeChange, mermaid, mermaidConfig, accentColor, readOnly, panZoom, tool, hooks, onSelectionChange, onReady, className, style } = props + const hostRef = useRef(null) + const editorRef = useRef(null) + const viewRef = useRef(null) + const onCodeChangeRef = useRef(onCodeChange) + const onSelectionChangeRef = useRef(onSelectionChange) + onCodeChangeRef.current = onCodeChange + onSelectionChangeRef.current = onSelectionChange + + useEffect(() => { + if (!hostRef.current) return + const editor = new MermaidWysiwygEditor({ code }) + const view = new MermaidCanvasView({ + editor, + container: hostRef.current, + mermaid, + mermaidConfig, + accentColor, + readOnly, + panZoom, + hooks, + }) + editorRef.current = editor + viewRef.current = view + const offChange = editor.on('change', ({ code: next, origin }) => { + if (origin !== 'external') onCodeChangeRef.current?.(next) + }) + const offSelection = editor.on('selectionChange', ({ entityIds }) => { + onSelectionChangeRef.current?.(entityIds) + }) + onReady?.(editor, view) + return () => { + offChange() + offSelection() + view.destroy() + editorRef.current = null + viewRef.current = null + } + // the view is created once; prop updates are applied by the effects below + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mermaid]) + + useEffect(() => { + const editor = editorRef.current + if (editor && code !== editor.code) editor.setCode(code, 'external') + }, [code]) + + useEffect(() => { + if (tool) viewRef.current?.setTool(tool) + }, [tool]) + + useEffect(() => { + viewRef.current?.setReadOnly(readOnly ?? false) + }, [readOnly]) + + useEffect(() => { + if (accentColor) viewRef.current?.setAccentColor(accentColor) + }, [accentColor]) + + useEffect(() => { + if (mermaidConfig) viewRef.current?.setMermaidConfig(mermaidConfig) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(mermaidConfig)]) + + useEffect(() => { + viewRef.current?.setHooks(hooks ?? {}) + }, [hooks]) + + return
+} + +export interface MermaidCanvasProps { + /** an editor you own (e.g. from `useMermaidEditor`) */ + editor: MermaidWysiwygEditor + mermaid: MermaidLike + mermaidConfig?: Record + accentColor?: string + readOnly?: boolean + /** fit-to-canvas + drag-pan + pinch/ctrl-wheel zoom with corner controls */ + panZoom?: boolean + hooks?: ViewHooks + onReady?: (view: MermaidCanvasView) => void + className?: string + style?: CSSProperties +} + +/** Canvas-only binding for an editor instance you manage yourself. */ +export function MermaidCanvas(props: MermaidCanvasProps) { + const { editor, mermaid, mermaidConfig, accentColor, readOnly, panZoom, hooks, onReady, className, style } = props + const hostRef = useRef(null) + const viewRef = useRef(null) + + useEffect(() => { + if (!hostRef.current) return + const view = new MermaidCanvasView({ + editor, + container: hostRef.current, + mermaid, + mermaidConfig, + accentColor, + readOnly, + panZoom, + hooks, + }) + onReady?.(view) + viewRef.current = view + return () => { + view.destroy() + viewRef.current = null + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editor, mermaid]) + + useEffect(() => { + viewRef.current?.setHooks(hooks ?? {}) + }, [hooks]) + + useEffect(() => { + viewRef.current?.setReadOnly(readOnly ?? false) + }, [readOnly]) + + useEffect(() => { + if (accentColor) viewRef.current?.setAccentColor(accentColor) + }, [accentColor]) + + useEffect(() => { + if (mermaidConfig) viewRef.current?.setMermaidConfig(mermaidConfig) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(mermaidConfig)]) + + return
+} diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json new file mode 100644 index 0000000..c091499 --- /dev/null +++ b/packages/react/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..962e6bb --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,5129 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@changesets/cli': + specifier: ^2.27.10 + version: 2.31.1 + tsup: + specifier: ^8.5.1 + version: 8.5.1(postcss@8.5.17)(typescript@5.9.3) + typescript: + specifier: ^5.6.3 + version: 5.9.3 + + apps/js-playground: + dependencies: + '@codemirror/commands': + specifier: ^6.6.1 + version: 6.10.4 + '@codemirror/language': + specifier: ^6.10.3 + version: 6.12.4 + '@codemirror/state': + specifier: ^6.4.1 + version: 6.7.1 + '@codemirror/view': + specifier: ^6.34.1 + version: 6.43.6 + '@lezer/highlight': + specifier: ^1.2.1 + version: 1.2.3 + '@mermaid-js/mermaid-zenuml': + specifier: ^0.2.0 + version: 0.2.3(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@18.3.31)(mermaid@11.16.0)(tailwindcss@4.3.2) + '@visimer/codemirror': + specifier: workspace:* + version: link:../../packages/codemirror + '@visimer/core': + specifier: workspace:* + version: link:../../packages/core + '@visimer/dom': + specifier: workspace:* + version: link:../../packages/dom + mermaid: + specifier: ^11.6.0 + version: 11.16.0 + devDependencies: + typescript: + specifier: ^5.6.3 + version: 5.9.3 + vite: + specifier: ^6.3.5 + version: 6.4.3 + + apps/playground: + dependencies: + '@codemirror/commands': + specifier: ^6.6.1 + version: 6.10.4 + '@codemirror/language': + specifier: ^6.10.3 + version: 6.12.4 + '@codemirror/state': + specifier: ^6.4.1 + version: 6.7.1 + '@codemirror/view': + specifier: ^6.34.1 + version: 6.43.6 + '@lezer/highlight': + specifier: ^1.2.1 + version: 1.2.3 + '@mermaid-js/mermaid-zenuml': + specifier: ^0.2.0 + version: 0.2.3(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@18.3.31)(mermaid@11.16.0)(tailwindcss@4.3.2) + '@visimer/codemirror': + specifier: workspace:* + version: link:../../packages/codemirror + '@visimer/core': + specifier: workspace:* + version: link:../../packages/core + '@visimer/dom': + specifier: workspace:* + version: link:../../packages/dom + '@visimer/monaco': + specifier: workspace:* + version: link:../../packages/monaco + '@visimer/react': + specifier: workspace:* + version: link:../../packages/react + mermaid: + specifier: ^11.6.0 + version: 11.16.0 + monaco-editor: + specifier: ^0.55.1 + version: 0.55.1 + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.3.12 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.3.1 + version: 18.3.7(@types/react@18.3.31) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.4.3) + typescript: + specifier: ^5.6.3 + version: 5.9.3 + vite: + specifier: ^6.3.5 + version: 6.4.3 + + apps/site: + dependencies: + '@visimer/codemirror': + specifier: workspace:* + version: link:../../packages/codemirror + '@visimer/core': + specifier: workspace:* + version: link:../../packages/core + '@visimer/dom': + specifier: workspace:* + version: link:../../packages/dom + '@visimer/react': + specifier: workspace:* + version: link:../../packages/react + mermaid: + specifier: ^11.6.0 + version: 11.16.0 + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + devDependencies: + '@types/react': + specifier: ^18.3.12 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.3.1 + version: 18.3.7(@types/react@18.3.31) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.4.3) + typescript: + specifier: ^5.6.3 + version: 5.9.3 + vite: + specifier: ^6.3.5 + version: 6.4.3 + + packages/codemirror: + dependencies: + '@visimer/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@codemirror/commands': + specifier: ^6.6.1 + version: 6.10.4 + '@codemirror/language': + specifier: ^6.10.3 + version: 6.12.4 + '@codemirror/state': + specifier: ^6.4.1 + version: 6.7.1 + '@codemirror/view': + specifier: ^6.34.1 + version: 6.43.6 + '@lezer/highlight': + specifier: ^1.2.1 + version: 1.2.3 + jsdom: + specifier: ^25.0.1 + version: 25.0.1 + typescript: + specifier: ^5.6.3 + version: 5.9.3 + vitest: + specifier: ^2.1.9 + version: 2.1.9(jsdom@25.0.1) + + packages/core: + devDependencies: + typescript: + specifier: ^5.6.3 + version: 5.9.3 + vitest: + specifier: ^2.1.9 + version: 2.1.9(jsdom@25.0.1) + + packages/dom: + dependencies: + '@visimer/core': + specifier: workspace:* + version: link:../core + devDependencies: + jsdom: + specifier: ^25.0.1 + version: 25.0.1 + typescript: + specifier: ^5.6.3 + version: 5.9.3 + vitest: + specifier: ^2.1.9 + version: 2.1.9(jsdom@25.0.1) + + packages/monaco: + dependencies: + '@visimer/core': + specifier: workspace:* + version: link:../core + devDependencies: + monaco-editor: + specifier: ^0.55.1 + version: 0.55.1 + typescript: + specifier: ^5.6.3 + version: 5.9.3 + vitest: + specifier: ^2.1.9 + version: 2.1.9(jsdom@25.0.1) + + packages/react: + dependencies: + '@visimer/core': + specifier: workspace:* + version: link:../core + '@visimer/dom': + specifier: workspace:* + version: link:../dom + devDependencies: + '@types/react': + specifier: ^18.3.12 + version: 18.3.31 + react: + specifier: ^18.3.1 + version: 18.3.1 + typescript: + specifier: ^5.6.3 + version: 5.9.3 + +packages: + + '@antfu/install-pkg@1.1.0': + resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.29.7': + resolution: {integrity: sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.29.7': + resolution: {integrity: sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@braintree/sanitize-url@7.1.2': + resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + + '@changesets/apply-release-plan@7.1.1': + resolution: {integrity: sha512-9qPCm/rLx/xoOFXIHGB229+4GOL76S4MC+7tyOuTsR6+1jYlfFDQORdvwR5hDA6y4FL2BPt3qpbcQIS+dW85LA==} + + '@changesets/assemble-release-plan@6.0.10': + resolution: {integrity: sha512-rSDcqdJ9KbVyjpBIuCidhvZNIiVt1XaIYp73ycVQRIA5n/j6wQaEk0ChRLMUQ1vkxZe51PTQ9OIhbg6HQMW45A==} + + '@changesets/changelog-git@0.2.1': + resolution: {integrity: sha512-x/xEleCFLH28c3bQeQIyeZf8lFXyDFVn1SgcBiR2Tw/r4IAWlk1fzxCEZ6NxQAjF2Nwtczoen3OA2qR+UawQ8Q==} + + '@changesets/cli@2.31.1': + resolution: {integrity: sha512-uO05WTcRBwuVOJVSW8Cmpqw6q0WDL53ajGCMyszutvOe5toOnunbpM4jZzf+qxBOz7i0AzopZ8diBuewjmF40w==} + hasBin: true + + '@changesets/config@3.1.4': + resolution: {integrity: sha512-pf0bvD/v6WI2cRlZ6hzpjtZdSlXDXMAJ+Iz7xfFzV4ZxJ8OGGAON+1qYc99ZPrijnt4xp3VGG7eNvAOGS24V1Q==} + + '@changesets/errors@0.2.0': + resolution: {integrity: sha512-6BLOQUscTpZeGljvyQXlWOItQyU71kCdGz7Pi8H8zdw6BI0g3m43iL4xKUVPWtG+qrrL9DTjpdn8eYuCQSRpow==} + + '@changesets/get-dependents-graph@2.1.4': + resolution: {integrity: sha512-ZsS00x6WvmHq3sQv8oCMwL0f/z3wbXCVuSVTJwCnnmbC/iBdNJGFx1EcbMG4PC6sXRyH69liM4A2WKXzn/kRPg==} + + '@changesets/get-release-plan@4.0.16': + resolution: {integrity: sha512-2K5Om6CrMPm45rtvckfzWo7e9jOVCKLCnXia5eUPaURH7/LWzri7pK1TycdzAuAtehLkW7VPbWLCSExTHmiI6g==} + + '@changesets/get-version-range-type@0.4.0': + resolution: {integrity: sha512-hwawtob9DryoGTpixy1D3ZXbGgJu1Rhr+ySH2PvTLHvkZuQ7sRT4oQwMh0hbqZH1weAooedEjRsbrWcGLCeyVQ==} + + '@changesets/git@3.0.4': + resolution: {integrity: sha512-BXANzRFkX+XcC1q/d27NKvlJ1yf7PSAgi8JG6dt8EfbHFHi4neau7mufcSca5zRhwOL8j9s6EqsxmT+s+/E6Sw==} + + '@changesets/logger@0.1.1': + resolution: {integrity: sha512-OQtR36ZlnuTxKqoW4Sv6x5YIhOmClRd5pWsjZsddYxpWs517R0HkyiefQPIytCVh4ZcC5x9XaG8KTdd5iRQUfg==} + + '@changesets/parse@0.4.3': + resolution: {integrity: sha512-ZDmNc53+dXdWEv7fqIUSgRQOLYoUom5Z40gmLgmATmYR9NbL6FJJHwakcCpzaeCy+1D0m0n7mT4jj2B/MQPl7A==} + + '@changesets/pre@2.0.2': + resolution: {integrity: sha512-HaL/gEyFVvkf9KFg6484wR9s0qjAXlZ8qWPDkTyKF6+zqjBe/I2mygg3MbpZ++hdi0ToqNUF8cjj7fBy0dg8Ug==} + + '@changesets/read@0.6.7': + resolution: {integrity: sha512-D1G4AUYGrBEk8vj8MGwf75k9GpN6XL3wg8i42P2jZZwFLXnlr2Pn7r9yuQNbaMCarP7ZQWNJbV6XLeysAIMhTA==} + + '@changesets/should-skip-package@0.1.2': + resolution: {integrity: sha512-qAK/WrqWLNCP22UDdBTMPH5f41elVDlsNyat180A33dWxuUDyNpg6fPi/FyTZwRriVjg0L8gnjJn2F9XAoF0qw==} + + '@changesets/types@4.1.0': + resolution: {integrity: sha512-LDQvVDv5Kb50ny2s25Fhm3d9QSZimsoUGBsUioj6MC3qbMUCuC8GPIvk/M6IvXx3lYhAs0lwWUQLb+VIEUCECw==} + + '@changesets/types@6.1.0': + resolution: {integrity: sha512-rKQcJ+o1nKNgeoYRHKOS07tAMNd3YSN0uHaJOZYjBAgxfV7TUE7JE+z4BzZdQwb5hKaYbayKN5KrYV7ODb2rAA==} + + '@changesets/write@0.4.0': + resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + + '@chevrotain/types@11.1.2': + resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + + '@codemirror/commands@6.10.4': + resolution: {integrity: sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==} + + '@codemirror/language@6.12.4': + resolution: {integrity: sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==} + + '@codemirror/state@6.7.1': + resolution: {integrity: sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==} + + '@codemirror/view@6.43.6': + resolution: {integrity: sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==} + + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.26.28': + resolution: {integrity: sha512-yORQuuAtVpiRjpMhdc0wJj06b9JFjrYF4qp96j++v2NBpbi6SEGF7donUJ3TMieerQ6qVkAv1tgr7L4r5roTqw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.27.20': + resolution: {integrity: sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw==} + peerDependencies: + react: '>=17.0.0' + react-dom: '>=17.0.0' + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@headlessui/react@2.2.10': + resolution: {integrity: sha512-5pVLNK9wlpxTUTy9GpgbX/SdcRh+HBnPktjM2wbiLTH4p+2EPHBO1aoSryUCuKUIItdDWO9ITlhUL8UnUN/oIA==} + engines: {node: '>=10'} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + + '@headlessui/tailwindcss@0.2.2': + resolution: {integrity: sha512-xNe42KjdyA4kfUKLLPGzME9zkH7Q3rOZ5huFihWNWOQFxnItxPB3/67yBI8/qBfY8nwBRx5GHn4VprsoluVMGw==} + engines: {node: '>=10'} + peerDependencies: + tailwindcss: ^3.0 || ^4.0 + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@iconify/utils@3.1.4': + resolution: {integrity: sha512-b1S7B1k9ohZ+iNTi2ATxbRYG9fTrJmUT0rc46bvVnNxqNRGW7dyo/vRREwyniI5IRN2RSJHDcm+s3BjWrSAjHw==} + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@internationalized/date@3.12.2': + resolution: {integrity: sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==} + + '@internationalized/number@3.6.7': + resolution: {integrity: sha512-3ji1fcrT+FPAK86UqEhB/psHixYo6niWPJtt7+qRaYFynt/BaJG8GhAPimtWUpEiVSTq8ZM8L5psMxGquiB/Vg==} + + '@internationalized/string@3.2.9': + resolution: {integrity: sha512-kzP/M/mbQxODlmOt4bIQZ2SBVUWUSqMLXooXixnX7noche8WHaQcA+nwFN1K2KCF/cp+LDUhcJsCicwkvhD1pg==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@lezer/common@1.5.2': + resolution: {integrity: sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==} + + '@lezer/highlight@1.2.3': + resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} + + '@lezer/lr@1.4.10': + resolution: {integrity: sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==} + + '@manypkg/find-root@1.1.0': + resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} + + '@manypkg/get-packages@1.1.3': + resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + + '@marijn/find-cluster-break@1.0.3': + resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==} + + '@mermaid-js/mermaid-zenuml@0.2.3': + resolution: {integrity: sha512-RGBtgL6fc+5Y2Jm9odOH9HRJ80BP4l6atBYnAK5bBzEowF0PU3UtvZRRcbFxImPGPuLIzqZq31ur8lVO0AoF3Q==} + peerDependencies: + mermaid: ^10 || ^11 + + '@mermaid-js/parser@1.2.0': + resolution: {integrity: sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA==} + + '@napi-rs/canvas-android-arm64@0.1.100': + resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@napi-rs/canvas-darwin-arm64@0.1.100': + resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@napi-rs/canvas-darwin-x64@0.1.100': + resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@napi-rs/canvas-linux-x64-musl@0.1.100': + resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@napi-rs/canvas@0.1.100': + resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==} + engines: {node: '>= 10'} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@react-aria/focus@3.22.1': + resolution: {integrity: sha512-CPxtkyrBi/HYY5P3lE/57sQ6qfa0lN8E55TOm89H0kNGv0lKt+/0zP7lWERzBjRr5IxBVrQX4gFEowBN52LPaA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@react-aria/interactions@3.28.1': + resolution: {integrity: sha512-Bqb+HrD5I5MHS2SKBhISYqo2SW8Y2dfzgF/Y1lIJq7xqLxheo9vzxPGEHhz+XzkgGfoqEJx8A6a3C7uiqS3HWA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@react-types/shared@3.36.0': + resolution: {integrity: sha512-DkP/H0C2YjjS7gZWKNqOmU8a16qHPjQNdzMwmTq9SzplM6Iw0kVMTZ0OIoe6FOgGqa+FwMsE2QbPjh/n3g/jXQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + '@rolldown/pluginutils@1.0.0-beta.27': + resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==} + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + + '@tanstack/react-virtual@3.14.5': + resolution: {integrity: sha512-4EKRXh7zBLkbKbFmG3AUVkircuHd+7OdT1pocJSepxtfBd3qnrJgJ5rtPkRYyo9fmyVb2+pI2xPy5oYvMLQy6A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.17.3': + resolution: {integrity: sha512-8Np/TFELpI0ySuJoVmjvOrQYXH/8sTX0Biv9szhFhY39xOdAAY+smrMxjxOum/ux3eM8MUJQsEJ0/R0UpvC8dw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-axis@3.0.6': + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + + '@types/d3-brush@3.0.6': + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + + '@types/d3-chord@3.0.6': + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + + '@types/d3-color@3.1.3': + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/d3-delaunay@6.0.4': + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + + '@types/d3-dispatch@3.0.7': + resolution: {integrity: sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA==} + + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + + '@types/d3-dsv@3.0.7': + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + + '@types/d3-ease@3.0.2': + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + + '@types/d3-fetch@3.0.7': + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + + '@types/d3-format@3.0.4': + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + + '@types/d3-geo@3.1.0': + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + + '@types/d3-hierarchy@3.1.7': + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + + '@types/d3-interpolate@3.0.4': + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + + '@types/d3-path@3.1.1': + resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==} + + '@types/d3-polygon@3.0.2': + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + + '@types/d3-quadtree@3.0.6': + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + + '@types/d3-random@3.0.4': + resolution: {integrity: sha512-UHYId5WTCx4L4YNel7NU00XUXXgvgpgZOvp10PuvsQENjMDXhh2RyFc0KBjO7B45ne4Ha1yVH7ii0vnzKkuzWA==} + + '@types/d3-scale-chromatic@3.1.0': + resolution: {integrity: sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ==} + + '@types/d3-scale@4.0.9': + resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + + '@types/d3-shape@3.1.8': + resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} + + '@types/d3-time-format@4.0.3': + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + + '@types/d3-time@3.0.4': + resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==} + + '@types/d3-timer@3.0.2': + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + + '@types/d3@7.4.3': + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/node@12.20.55': + resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + + '@types/prop-types@15.7.15': + resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + + '@types/react-dom@18.3.7': + resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} + peerDependencies: + '@types/react': ^18.0.0 + + '@types/react@18.3.31': + resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + + '@vitejs/plugin-react@4.7.0': + resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + '@vitest/expect@2.1.9': + resolution: {integrity: sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==} + + '@vitest/mocker@2.1.9': + resolution: {integrity: sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@2.1.9': + resolution: {integrity: sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==} + + '@vitest/runner@2.1.9': + resolution: {integrity: sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==} + + '@vitest/snapshot@2.1.9': + resolution: {integrity: sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==} + + '@vitest/spy@2.1.9': + resolution: {integrity: sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==} + + '@vitest/utils@2.1.9': + resolution: {integrity: sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==} + + '@zenuml/core@3.50.1': + resolution: {integrity: sha512-Q18BXvIfhRuGw0JfO4e+pltG04Phhv2E7n35EWTNSZKKH/HEkyE4Sh7KERb9dqaOfjOdRdKvLG0YekjK3fcDXw==} + engines: {node: '>=20'} + hasBin: true + peerDependencies: + playwright-core: 1.57.0 + peerDependenciesMeta: + playwright-core: + optional: true + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + antlr4@4.11.0: + resolution: {integrity: sha512-GUGlpE2JUjAN+G8G5vY+nOoeyNhHsXoIJwP1XF1oRw89vifA1K46T6SEkwLwr7drihN7I/lf0DIjKc4OZvBX8w==} + engines: {node: '>=14'} + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + baseline-browser-mapping@2.10.43: + resolution: {integrity: sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + better-path-resolve@1.0.0: + resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} + engines: {node: '>=4'} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.6: + resolution: {integrity: sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + caniuse-lite@1.0.30001805: + resolution: {integrity: sha512-52noaS3DubycKSXaU30TwPGIp+POyQSUVa5jBEq3vkRkY0kjyb3LQgvhU6WGyCcyXqVLWO0Cw0Q6BSdD0kUfVA==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-name@2.1.0: + resolution: {integrity: sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg==} + engines: {node: '>=12.20'} + + color-string@2.1.4: + resolution: {integrity: sha512-Bb6Cq8oq0IjDOe8wJmi4JeNn763Xs9cfrBcaylK1tPypWzyoy2G3l90v9k64kjphl/ZJjPIShFztenRomi8WTg==} + engines: {node: '>=18'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + commander@8.3.0: + resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} + engines: {node: '>= 12'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cose-base@1.0.3: + resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==} + + cose-base@2.2.0: + resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} + + crelt@1.0.7: + resolution: {integrity: sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + cytoscape-cose-bilkent@4.1.0: + resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape-fcose@2.2.0: + resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} + peerDependencies: + cytoscape: ^3.2.0 + + cytoscape@3.34.0: + resolution: {integrity: sha512-62rNSrioXw93uliKFBwjukeQyeWwH2PqDrTac31r2P6464u3AUvTk0xS4LVvT251g7IgkFunrI48ZEZGjywSOg==} + engines: {node: '>=0.10'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-axis@3.0.0: + resolution: {integrity: sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==} + engines: {node: '>=12'} + + d3-brush@3.0.0: + resolution: {integrity: sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + + d3-fetch@3.0.1: + resolution: {integrity: sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw==} + engines: {node: '>=12'} + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-polygon@3.0.1: + resolution: {integrity: sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + + d3@7.9.0: + resolution: {integrity: sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA==} + engines: {node: '>=12'} + + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dompurify@3.2.7: + resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + + dompurify@3.4.12: + resolution: {integrity: sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.389: + resolution: {integrity: sha512-cEto7aeOqBfU1D+c5py5pE+ooscKE75JifxLBdFUZsqAxRS6y7kebtxAZvICszSl05gPjYHDTjY+lXpyGvpJbg==} + + enquirer@2.4.1: + resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} + engines: {node: '>=8.6'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-toolkit@1.49.0: + resolution: {integrity: sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + extendable-error@0.1.7: + resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + + fs-extra@8.1.0: + resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==} + engines: {node: '>=6 <7 || >=8'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + hachure-fill@0.5.2: + resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + + html-to-image@1.11.13: + resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} + + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + + human-id@4.2.0: + resolution: {integrity: sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA==} + hasBin: true + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + is-subdir@1.2.0: + resolution: {integrity: sha512-2AT6j+gXe/1ueqbW6fLZJiIw3F8iXGJtt0yDrZaBhAZEG1raiTxKWU+IPqMCzQAXOUCKdA4UDMgacKH25XG2Cw==} + engines: {node: '>=4'} + + is-windows@1.0.2: + resolution: {integrity: sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jotai@2.20.1: + resolution: {integrity: sha512-dnuKfU/GLi8B28RRMjQ3AfoN7kfzP8o41+AX2FmITZqEMY8PHnjABq+VkEooomLwYaGjda+pgy0yFSjaHX/ZPg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@babel/core': '>=7.0.0' + '@babel/template': '>=7.0.0' + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + '@babel/template': + optional: true + '@types/react': + optional: true + react: + optional: true + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@3.15.0: + resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} + hasBin: true + + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + + katex@0.16.47: + resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} + hasBin: true + + khroma@2.1.0: + resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} + + layout-base@1.0.2: + resolution: {integrity: sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==} + + layout-base@2.0.1: + resolution: {integrity: sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg==} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + lodash.startcase@4.4.0: + resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + + marked@16.4.2: + resolution: {integrity: sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA==} + engines: {node: '>= 20'} + hasBin: true + + marked@4.3.0: + resolution: {integrity: sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==} + engines: {node: '>= 12'} + hasBin: true + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + mermaid@11.16.0: + resolution: {integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + monaco-editor@0.55.1: + resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.15: + resolution: {integrity: sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + + nwsapi@2.2.24: + resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + outdent@0.5.0: + resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} + + p-filter@2.1.0: + resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} + engines: {node: '>=8'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-map@2.1.0: + resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} + engines: {node: '>=6'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + package-manager-detector@0.2.11: + resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + + package-manager-detector@1.7.0: + resolution: {integrity: sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-data-parser@0.1.0: + resolution: {integrity: sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + points-on-curve@0.2.0: + resolution: {integrity: sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A==} + + points-on-path@0.2.1: + resolution: {integrity: sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g==} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss@8.5.17: + resolution: {integrity: sha512-J7EF+8X+CzRPaJPOv9Ck2wNWJvGnnl3PcNPAdGg6GTLjyVpyQ0yATMSXRFRV01BviT/9Gwuc3rjEyJbDJG9a4w==} + engines: {node: ^10 || ^12 || >=14} + + prettier@2.8.8: + resolution: {integrity: sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==} + engines: {node: '>=10.13.0'} + hasBin: true + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-aria@3.50.0: + resolution: {integrity: sha512-S0Os6QZk33fzUAKu1QLT9afoUaCBt1ZNdoiq0n2YMVgKIdNIQS8zxiZ8O9hYE6QyDkHKjD6q39LQZ+qaSAIgjw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + react-dom@18.3.1: + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react-refresh@0.17.0: + resolution: {integrity: sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==} + engines: {node: '>=0.10.0'} + + react-stately@3.48.0: + resolution: {integrity: sha512-ImicSAG+lTotAe5izcs1fz49Zk48w7pDusqYg04WaPhCoej8BJ24soMu3iLXIrsi273s4P1gZrYGrqReMfgEEA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1 + + react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + read-yaml-file@1.1.0: + resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} + engines: {node: '>=6'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + roughjs@4.6.6: + resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==} + + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + spawndamnit@3.0.1: + resolution: {integrity: sha512-MmnduQUuHCoFckZoWnXsTg7JaiLBJrKFj9UI2MbRPGaJeVpsLcVBu6P/IGZovziM/YBsellCmsprgNA+w0CzVg==} + + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + style-mod@4.1.3: + resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} + + stylis@4.4.0: + resolution: {integrity: sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tabbable@6.5.0: + resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} + + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + + tailwindcss@4.3.2: + resolution: {integrity: sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==} + + term-size@2.2.1: + resolution: {integrity: sha512-wK0Ri4fOGjv/XPy8SBHZChl8CM7uMc5VML7SqiQ0zG7+J5Vr+RMQDoHa2CNT6KHUnTGIXH34UDMkPzAUyapBZg==} + engines: {node: '>=8'} + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + + tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-dedent@2.3.0: + resolution: {integrity: sha512-JfJeIHke7y2egdGGgRAvpCwYFUsHlM2gPcrVOxFkznt/4uzQ7HFmvE63iFHVLBJNDuyDOQgijDK/tXH/f6Msjg==} + engines: {node: '>=6.10'} + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} + hasBin: true + + vite-node@2.1.9: + resolution: {integrity: sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@2.1.9: + resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 2.1.9 + '@vitest/ui': 2.1.9 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-keyname@2.2.8: + resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + +snapshots: + + '@antfu/install-pkg@1.1.0': + dependencies: + package-manager-detector: 1.7.0 + tinyexec: 1.2.4 + + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.6 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/runtime@7.29.7': {} + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@braintree/sanitize-url@7.1.2': {} + + '@changesets/apply-release-plan@7.1.1': + dependencies: + '@changesets/config': 3.1.4 + '@changesets/get-version-range-type': 0.4.0 + '@changesets/git': 3.0.4 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + detect-indent: 6.1.0 + fs-extra: 7.0.1 + lodash.startcase: 4.4.0 + outdent: 0.5.0 + prettier: 2.8.8 + resolve-from: 5.0.0 + semver: 7.8.5 + + '@changesets/assemble-release-plan@6.0.10': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + semver: 7.8.5 + + '@changesets/changelog-git@0.2.1': + dependencies: + '@changesets/types': 6.1.0 + + '@changesets/cli@2.31.1': + dependencies: + '@changesets/apply-release-plan': 7.1.1 + '@changesets/assemble-release-plan': 6.0.10 + '@changesets/changelog-git': 0.2.1 + '@changesets/config': 3.1.4 + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/get-release-plan': 4.0.16 + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.7 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@changesets/write': 0.4.0 + '@inquirer/external-editor': 1.0.3 + '@manypkg/get-packages': 1.1.3 + ansi-colors: 4.1.3 + enquirer: 2.4.1 + fs-extra: 7.0.1 + mri: 1.2.0 + package-manager-detector: 0.2.11 + picocolors: 1.1.1 + resolve-from: 5.0.0 + semver: 7.8.5 + spawndamnit: 3.0.1 + term-size: 2.2.1 + transitivePeerDependencies: + - '@types/node' + + '@changesets/config@3.1.4': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/get-dependents-graph': 2.1.4 + '@changesets/logger': 0.1.1 + '@changesets/should-skip-package': 0.1.2 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + micromatch: 4.0.8 + + '@changesets/errors@0.2.0': + dependencies: + extendable-error: 0.1.7 + + '@changesets/get-dependents-graph@2.1.4': + dependencies: + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + picocolors: 1.1.1 + semver: 7.8.5 + + '@changesets/get-release-plan@4.0.16': + dependencies: + '@changesets/assemble-release-plan': 6.0.10 + '@changesets/config': 3.1.4 + '@changesets/pre': 2.0.2 + '@changesets/read': 0.6.7 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + + '@changesets/get-version-range-type@0.4.0': {} + + '@changesets/git@3.0.4': + dependencies: + '@changesets/errors': 0.2.0 + '@manypkg/get-packages': 1.1.3 + is-subdir: 1.2.0 + micromatch: 4.0.8 + spawndamnit: 3.0.1 + + '@changesets/logger@0.1.1': + dependencies: + picocolors: 1.1.1 + + '@changesets/parse@0.4.3': + dependencies: + '@changesets/types': 6.1.0 + js-yaml: 4.3.0 + + '@changesets/pre@2.0.2': + dependencies: + '@changesets/errors': 0.2.0 + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + fs-extra: 7.0.1 + + '@changesets/read@0.6.7': + dependencies: + '@changesets/git': 3.0.4 + '@changesets/logger': 0.1.1 + '@changesets/parse': 0.4.3 + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + p-filter: 2.1.0 + picocolors: 1.1.1 + + '@changesets/should-skip-package@0.1.2': + dependencies: + '@changesets/types': 6.1.0 + '@manypkg/get-packages': 1.1.3 + + '@changesets/types@4.1.0': {} + + '@changesets/types@6.1.0': {} + + '@changesets/write@0.4.0': + dependencies: + '@changesets/types': 6.1.0 + fs-extra: 7.0.1 + human-id: 4.2.0 + prettier: 2.8.8 + + '@chevrotain/types@11.1.2': {} + + '@codemirror/commands@6.10.4': + dependencies: + '@codemirror/language': 6.12.4 + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.6 + '@lezer/common': 1.5.2 + + '@codemirror/language@6.12.4': + dependencies: + '@codemirror/state': 6.7.1 + '@codemirror/view': 6.43.6 + '@lezer/common': 1.5.2 + '@lezer/highlight': 1.2.3 + '@lezer/lr': 1.4.10 + style-mod: 4.1.3 + + '@codemirror/state@6.7.1': + dependencies: + '@marijn/find-cluster-break': 1.0.3 + + '@codemirror/view@6.43.6': + dependencies: + '@codemirror/state': 6.7.1 + crelt: 1.0.7 + style-mod: 4.1.3 + w3c-keyname: 2.2.8 + + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/react-dom@2.1.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/dom': 1.8.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@floating-ui/react@0.26.28(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/react-dom': 2.1.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@floating-ui/utils': 0.2.12 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + tabbable: 6.5.0 + + '@floating-ui/react@0.27.20(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/react-dom': 2.1.9(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@floating-ui/utils': 0.2.12 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + tabbable: 6.5.0 + + '@floating-ui/utils@0.2.12': {} + + '@headlessui/react@2.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@floating-ui/react': 0.26.28(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@react-aria/focus': 3.22.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@react-aria/interactions': 3.28.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@tanstack/react-virtual': 3.14.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + use-sync-external-store: 1.6.0(react@19.2.7) + + '@headlessui/tailwindcss@0.2.2(tailwindcss@4.3.2)': + dependencies: + tailwindcss: 4.3.2 + + '@iconify/types@2.0.0': {} + + '@iconify/utils@3.1.4': + dependencies: + '@antfu/install-pkg': 1.1.0 + '@iconify/types': 2.0.0 + import-meta-resolve: 4.2.0 + + '@inquirer/external-editor@1.0.3': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.3 + + '@internationalized/date@3.12.2': + dependencies: + '@swc/helpers': 0.5.23 + + '@internationalized/number@3.6.7': + dependencies: + '@swc/helpers': 0.5.23 + + '@internationalized/string@3.2.9': + dependencies: + '@swc/helpers': 0.5.23 + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@lezer/common@1.5.2': {} + + '@lezer/highlight@1.2.3': + dependencies: + '@lezer/common': 1.5.2 + + '@lezer/lr@1.4.10': + dependencies: + '@lezer/common': 1.5.2 + + '@manypkg/find-root@1.1.0': + dependencies: + '@babel/runtime': 7.29.7 + '@types/node': 12.20.55 + find-up: 4.1.0 + fs-extra: 8.1.0 + + '@manypkg/get-packages@1.1.3': + dependencies: + '@babel/runtime': 7.29.7 + '@changesets/types': 4.1.0 + '@manypkg/find-root': 1.1.0 + fs-extra: 8.1.0 + globby: 11.1.0 + read-yaml-file: 1.1.0 + + '@marijn/find-cluster-break@1.0.3': {} + + '@mermaid-js/mermaid-zenuml@0.2.3(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@18.3.31)(mermaid@11.16.0)(tailwindcss@4.3.2)': + dependencies: + '@zenuml/core': 3.50.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@18.3.31)(tailwindcss@4.3.2) + mermaid: 11.16.0 + transitivePeerDependencies: + - '@babel/core' + - '@babel/template' + - '@types/react' + - playwright-core + - tailwindcss + + '@mermaid-js/parser@1.2.0': + dependencies: + '@chevrotain/types': 11.1.2 + + '@napi-rs/canvas-android-arm64@0.1.100': + optional: true + + '@napi-rs/canvas-darwin-arm64@0.1.100': + optional: true + + '@napi-rs/canvas-darwin-x64@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm-gnueabihf@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-arm64-musl@0.1.100': + optional: true + + '@napi-rs/canvas-linux-riscv64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-x64-gnu@0.1.100': + optional: true + + '@napi-rs/canvas-linux-x64-musl@0.1.100': + optional: true + + '@napi-rs/canvas-win32-arm64-msvc@0.1.100': + optional: true + + '@napi-rs/canvas-win32-x64-msvc@0.1.100': + optional: true + + '@napi-rs/canvas@0.1.100': + optionalDependencies: + '@napi-rs/canvas-android-arm64': 0.1.100 + '@napi-rs/canvas-darwin-arm64': 0.1.100 + '@napi-rs/canvas-darwin-x64': 0.1.100 + '@napi-rs/canvas-linux-arm-gnueabihf': 0.1.100 + '@napi-rs/canvas-linux-arm64-gnu': 0.1.100 + '@napi-rs/canvas-linux-arm64-musl': 0.1.100 + '@napi-rs/canvas-linux-riscv64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-gnu': 0.1.100 + '@napi-rs/canvas-linux-x64-musl': 0.1.100 + '@napi-rs/canvas-win32-arm64-msvc': 0.1.100 + '@napi-rs/canvas-win32-x64-msvc': 0.1.100 + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@react-aria/focus@3.22.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@swc/helpers': 0.5.23 + react: 19.2.7 + react-aria: 3.50.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react-dom: 19.2.7(react@19.2.7) + + '@react-aria/interactions@3.28.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@react-types/shared': 3.36.0(react@19.2.7) + '@swc/helpers': 0.5.23 + react: 19.2.7 + react-aria: 3.50.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + react-dom: 19.2.7(react@19.2.7) + + '@react-types/shared@3.36.0(react@19.2.7)': + dependencies: + react: 19.2.7 + + '@rolldown/pluginutils@1.0.0-beta.27': {} + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + + '@tanstack/react-virtual@3.14.5(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@tanstack/virtual-core': 3.17.3 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + + '@tanstack/virtual-core@3.17.3': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.7 + + '@types/d3-array@3.2.2': {} + + '@types/d3-axis@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-brush@3.0.6': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-chord@3.0.6': {} + + '@types/d3-color@3.1.3': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/d3-delaunay@6.0.4': {} + + '@types/d3-dispatch@3.0.7': {} + + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-dsv@3.0.7': {} + + '@types/d3-ease@3.0.2': {} + + '@types/d3-fetch@3.0.7': + dependencies: + '@types/d3-dsv': 3.0.7 + + '@types/d3-force@3.0.10': {} + + '@types/d3-format@3.0.4': {} + + '@types/d3-geo@3.1.0': + dependencies: + '@types/geojson': 7946.0.16 + + '@types/d3-hierarchy@3.1.7': {} + + '@types/d3-interpolate@3.0.4': + dependencies: + '@types/d3-color': 3.1.3 + + '@types/d3-path@3.1.1': {} + + '@types/d3-polygon@3.0.2': {} + + '@types/d3-quadtree@3.0.6': {} + + '@types/d3-random@3.0.4': {} + + '@types/d3-scale-chromatic@3.1.0': {} + + '@types/d3-scale@4.0.9': + dependencies: + '@types/d3-time': 3.0.4 + + '@types/d3-selection@3.0.11': {} + + '@types/d3-shape@3.1.8': + dependencies: + '@types/d3-path': 3.1.1 + + '@types/d3-time-format@4.0.3': {} + + '@types/d3-time@3.0.4': {} + + '@types/d3-timer@3.0.2': {} + + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + + '@types/d3@7.4.3': + dependencies: + '@types/d3-array': 3.2.2 + '@types/d3-axis': 3.0.6 + '@types/d3-brush': 3.0.6 + '@types/d3-chord': 3.0.6 + '@types/d3-color': 3.1.3 + '@types/d3-contour': 3.0.6 + '@types/d3-delaunay': 6.0.4 + '@types/d3-dispatch': 3.0.7 + '@types/d3-drag': 3.0.7 + '@types/d3-dsv': 3.0.7 + '@types/d3-ease': 3.0.2 + '@types/d3-fetch': 3.0.7 + '@types/d3-force': 3.0.10 + '@types/d3-format': 3.0.4 + '@types/d3-geo': 3.1.0 + '@types/d3-hierarchy': 3.1.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-path': 3.1.1 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.4 + '@types/d3-scale': 4.0.9 + '@types/d3-scale-chromatic': 3.1.0 + '@types/d3-selection': 3.0.11 + '@types/d3-shape': 3.1.8 + '@types/d3-time': 3.0.4 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + + '@types/estree@1.0.9': {} + + '@types/geojson@7946.0.16': {} + + '@types/node@12.20.55': {} + + '@types/prop-types@15.7.15': {} + + '@types/react-dom@18.3.7(@types/react@18.3.31)': + dependencies: + '@types/react': 18.3.31 + + '@types/react@18.3.31': + dependencies: + '@types/prop-types': 15.7.15 + csstype: 3.2.3 + + '@types/trusted-types@2.0.7': + optional: true + + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + '@vitejs/plugin-react@4.7.0(vite@6.4.3)': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.3 + transitivePeerDependencies: + - supports-color + + '@vitest/expect@2.1.9': + dependencies: + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + tinyrainbow: 1.2.0 + + '@vitest/mocker@2.1.9(vite@5.4.21)': + dependencies: + '@vitest/spy': 2.1.9 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 5.4.21 + + '@vitest/pretty-format@2.1.9': + dependencies: + tinyrainbow: 1.2.0 + + '@vitest/runner@2.1.9': + dependencies: + '@vitest/utils': 2.1.9 + pathe: 1.1.2 + + '@vitest/snapshot@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + magic-string: 0.30.21 + pathe: 1.1.2 + + '@vitest/spy@2.1.9': + dependencies: + tinyspy: 3.0.2 + + '@vitest/utils@2.1.9': + dependencies: + '@vitest/pretty-format': 2.1.9 + loupe: 3.2.1 + tinyrainbow: 1.2.0 + + '@zenuml/core@3.50.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@18.3.31)(tailwindcss@4.3.2)': + dependencies: + '@floating-ui/react': 0.27.20(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@headlessui/react': 2.2.10(react-dom@19.2.7(react@19.2.7))(react@19.2.7) + '@headlessui/tailwindcss': 0.2.2(tailwindcss@4.3.2) + antlr4: 4.11.0 + class-variance-authority: 0.7.1 + clsx: 2.1.1 + color-string: 2.1.4 + dompurify: 3.4.12 + highlight.js: 11.11.1 + html-to-image: 1.11.13 + jotai: 2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@18.3.31)(react@19.2.7) + marked: 4.3.0 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + tailwind-merge: 3.6.0 + optionalDependencies: + '@napi-rs/canvas': 0.1.100 + transitivePeerDependencies: + - '@babel/core' + - '@babel/template' + - '@types/react' + - tailwindcss + + acorn@8.17.0: {} + + agent-base@7.1.4: {} + + ansi-colors@4.1.3: {} + + ansi-regex@5.0.1: {} + + antlr4@4.11.0: {} + + any-promise@1.3.0: {} + + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + array-union@2.1.0: {} + + assertion-error@2.0.1: {} + + asynckit@0.4.0: {} + + baseline-browser-mapping@2.10.43: {} + + better-path-resolve@1.0.0: + dependencies: + is-windows: 1.0.2 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.6: + dependencies: + baseline-browser-mapping: 2.10.43 + caniuse-lite: 1.0.30001805 + electron-to-chromium: 1.5.389 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.6) + + bundle-require@5.1.0(esbuild@0.27.7): + dependencies: + esbuild: 0.27.7 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + caniuse-lite@1.0.30001805: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chardet@2.2.0: {} + + check-error@2.1.3: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + clsx@2.1.1: {} + + color-name@2.1.0: {} + + color-string@2.1.4: + dependencies: + color-name: 2.1.0 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@4.1.1: {} + + commander@7.2.0: {} + + commander@8.3.0: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} + + convert-source-map@2.0.0: {} + + cose-base@1.0.3: + dependencies: + layout-base: 1.0.2 + + cose-base@2.2.0: + dependencies: + layout-base: 2.0.1 + + crelt@1.0.7: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + + csstype@3.2.3: {} + + cytoscape-cose-bilkent@4.1.0(cytoscape@3.34.0): + dependencies: + cose-base: 1.0.3 + cytoscape: 3.34.0 + + cytoscape-fcose@2.2.0(cytoscape@3.34.0): + dependencies: + cose-base: 2.2.0 + cytoscape: 3.34.0 + + cytoscape@3.34.0: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-axis@3.0.0: {} + + d3-brush@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-ease@3.0.1: {} + + d3-fetch@3.0.1: + dependencies: + d3-dsv: 3.0.1 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-polygon@3.0.1: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-selection@3.0.0: {} + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + + d3@7.9.0: + dependencies: + d3-array: 3.2.4 + d3-axis: 3.0.0 + d3-brush: 3.0.0 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-dsv: 3.0.1 + d3-ease: 3.0.1 + d3-fetch: 3.0.1 + d3-force: 3.0.0 + d3-format: 3.1.2 + d3-geo: 3.1.1 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-path: 3.1.0 + d3-polygon: 3.0.1 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + d3-timer: 3.0.1 + d3-transition: 3.0.1(d3-selection@3.0.0) + d3-zoom: 3.0.0 + + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + + dayjs@1.11.21: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decimal.js@10.6.0: {} + + deep-eql@5.0.2: {} + + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + + delayed-stream@1.0.0: {} + + detect-indent@6.1.0: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dompurify@3.2.7: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + dompurify@3.4.12: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.389: {} + + enquirer@2.4.1: + dependencies: + ansi-colors: 4.1.3 + strip-ansi: 6.0.1 + + entities@6.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@1.7.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + es-toolkit@1.49.0: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + escalade@3.2.0: {} + + esprima@4.0.1: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + extendable-error@0.1.7: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.62.2 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fs-extra@7.0.1: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fs-extra@8.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + hachure-fill@0.5.2: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + highlight.js@11.11.1: {} + + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + + html-to-image@1.11.13: {} + + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + human-id@4.2.0: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + import-meta-resolve@4.2.0: {} + + internmap@1.0.1: {} + + internmap@2.0.3: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-potential-custom-element-name@1.0.1: {} + + is-subdir@1.2.0: + dependencies: + better-path-resolve: 1.0.0 + + is-windows@1.0.2: {} + + isexe@2.0.0: {} + + jotai@2.20.1(@babel/core@7.29.7)(@babel/template@7.29.7)(@types/react@18.3.31)(react@19.2.7): + optionalDependencies: + '@babel/core': 7.29.7 + '@babel/template': 7.29.7 + '@types/react': 18.3.31 + react: 19.2.7 + + joycon@3.1.1: {} + + js-tokens@4.0.0: {} + + js-yaml@3.15.0: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + jsdom@25.0.1: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.6 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.24 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + jsonfile@4.0.0: + optionalDependencies: + graceful-fs: 4.2.11 + + katex@0.16.47: + dependencies: + commander: 8.3.0 + + khroma@2.1.0: {} + + layout-base@1.0.2: {} + + layout-base@2.0.1: {} + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + + lodash-es@4.18.1: {} + + lodash.startcase@4.4.0: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + loupe@3.2.1: {} + + lru-cache@10.4.3: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + marked@14.0.0: {} + + marked@16.4.2: {} + + marked@4.3.0: {} + + math-intrinsics@1.1.0: {} + + merge2@1.4.1: {} + + mermaid@11.16.0: + dependencies: + '@braintree/sanitize-url': 7.1.2 + '@iconify/utils': 3.1.4 + '@mermaid-js/parser': 1.2.0 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.34.0 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.34.0) + cytoscape-fcose: 2.2.0(cytoscape@3.34.0) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.21 + dompurify: 3.4.12 + es-toolkit: 1.49.0 + katex: 0.16.47 + khroma: 2.1.0 + marked: 16.4.2 + roughjs: 4.6.6 + stylis: 4.4.0 + ts-dedent: 2.3.0 + uuid: 14.0.1 + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mlly@1.8.2: + dependencies: + acorn: 8.17.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + monaco-editor@0.55.1: + dependencies: + dompurify: 3.2.7 + marked: 14.0.0 + + mri@1.2.0: {} + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.15: {} + + node-releases@2.0.51: {} + + nwsapi@2.2.24: {} + + object-assign@4.1.1: {} + + outdent@0.5.0: {} + + p-filter@2.1.0: + dependencies: + p-map: 2.1.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-map@2.1.0: {} + + p-try@2.2.0: {} + + package-manager-detector@0.2.11: + dependencies: + quansync: 0.2.11 + + package-manager-detector@1.7.0: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-data-parser@0.1.0: {} + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-type@4.0.0: {} + + pathe@1.1.2: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.5: {} + + pify@4.0.1: {} + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + points-on-curve@0.2.0: {} + + points-on-path@0.2.1: + dependencies: + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + + postcss-load-config@6.0.1(postcss@8.5.17): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + postcss: 8.5.17 + + postcss@8.5.17: + dependencies: + nanoid: 3.3.15 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier@2.8.8: {} + + punycode@2.3.1: {} + + quansync@0.2.11: {} + + queue-microtask@1.2.3: {} + + react-aria@3.50.0(react-dom@19.2.7(react@19.2.7))(react@19.2.7): + dependencies: + '@internationalized/date': 3.12.2 + '@internationalized/number': 3.6.7 + '@internationalized/string': 3.2.9 + '@react-types/shared': 3.36.0(react@19.2.7) + '@swc/helpers': 0.5.23 + aria-hidden: 1.2.6 + clsx: 2.1.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + react-stately: 3.48.0(react@19.2.7) + use-sync-external-store: 1.6.0(react@19.2.7) + + react-dom@18.3.1(react@18.3.1): + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react-refresh@0.17.0: {} + + react-stately@3.48.0(react@19.2.7): + dependencies: + '@internationalized/date': 3.12.2 + '@internationalized/number': 3.6.7 + '@internationalized/string': 3.2.9 + '@react-types/shared': 3.36.0(react@19.2.7) + '@swc/helpers': 0.5.23 + react: 19.2.7 + use-sync-external-store: 1.6.0(react@19.2.7) + + react@18.3.1: + dependencies: + loose-envify: 1.4.0 + + react@19.2.7: {} + + read-yaml-file@1.1.0: + dependencies: + graceful-fs: 4.2.11 + js-yaml: 3.15.0 + pify: 4.0.1 + strip-bom: 3.0.0 + + readdirp@4.1.2: {} + + resolve-from@5.0.0: {} + + reusify@1.1.0: {} + + robust-predicates@3.0.3: {} + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + roughjs@4.6.6: + dependencies: + hachure-fill: 0.5.2 + path-data-parser: 0.1.0 + points-on-curve: 0.2.0 + points-on-path: 0.2.1 + + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + rw@1.3.3: {} + + safer-buffer@2.1.2: {} + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.23.2: + dependencies: + loose-envify: 1.4.0 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.8.5: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + slash@3.0.0: {} + + source-map-js@1.2.1: {} + + source-map@0.7.6: {} + + spawndamnit@3.0.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + sprintf-js@1.0.3: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-bom@3.0.0: {} + + style-mod@4.1.3: {} + + stylis@4.4.0: {} + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.17 + ts-interface-checker: 0.1.13 + + symbol-tree@3.2.4: {} + + tabbable@6.5.0: {} + + tailwind-merge@3.6.0: {} + + tailwindcss@4.3.2: {} + + term-size@2.2.1: {} + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@1.1.1: {} + + tinyrainbow@1.2.0: {} + + tinyspy@3.0.2: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + + tree-kill@1.2.2: {} + + ts-dedent@2.3.0: {} + + ts-interface-checker@0.1.13: {} + + tslib@2.8.1: {} + + tsup@8.5.1(postcss@8.5.17)(typescript@5.9.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(postcss@8.5.17) + resolve-from: 5.0.0 + rollup: 4.62.2 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.17 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + universalify@0.1.2: {} + + update-browserslist-db@1.2.3(browserslist@4.28.6): + dependencies: + browserslist: 4.28.6 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-sync-external-store@1.6.0(react@19.2.7): + dependencies: + react: 19.2.7 + + uuid@14.0.1: {} + + vite-node@2.1.9: + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 1.1.2 + vite: 5.4.21 + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + vite@5.4.21: + dependencies: + esbuild: 0.21.5 + postcss: 8.5.17 + rollup: 4.62.2 + optionalDependencies: + fsevents: 2.3.3 + + vite@6.4.3: + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.17 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vitest@2.1.9(jsdom@25.0.1): + dependencies: + '@vitest/expect': 2.1.9 + '@vitest/mocker': 2.1.9(vite@5.4.21) + '@vitest/pretty-format': 2.1.9 + '@vitest/runner': 2.1.9 + '@vitest/snapshot': 2.1.9 + '@vitest/spy': 2.1.9 + '@vitest/utils': 2.1.9 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 1.1.2 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinypool: 1.1.1 + tinyrainbow: 1.2.0 + vite: 5.4.21 + vite-node: 2.1.9 + why-is-node-running: 2.3.0 + optionalDependencies: + jsdom: 25.0.1 + transitivePeerDependencies: + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + + w3c-keyname@2.2.8: {} + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + ws@8.21.1: {} + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + + yallist@3.1.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..6f00273 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +packages: + - packages/* + - apps/* diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..b98fe92 --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUncheckedIndexedAccess": false, + "skipLibCheck": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "resolveJsonModule": true, + "noEmit": true + } +}