feat(web): mermaid diagram lightbox on click (#741)

* feat(web): mermaid diagram lightbox on click

Click rendered mermaid blocks in chat to open a zoomable full-screen viewer.
Re-renders from source in the modal with the current theme. Closes #737.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): fit mermaid lightbox to viewport on open

Auto-scale diagrams to fill the viewer instead of opening at intrinsic
mermaid size. Reset returns to fit; zoom label is relative to fit (100%).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): fit mermaid lightbox to device screen not inner panel

Use visualViewport for fit scale, full-screen pan layer, and a floating
toolbar so the diagram can use the whole display.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): show mermaid lightbox by reusing inline SVG

Second mermaid.render on open often left a 0×0 SVG while fit scale was
computed from the loading placeholder. Reuse the inline SVG in the modal
and measure viewBox with retried fit-to-screen.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): uniquify mermaid SVG ids in lightbox clone

Inlining the same mermaid markup twice duplicates element ids and breaks
url(#ref) resolution in the modal copy. Prefix ids and hrefs for lightbox only.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): give mermaid lightbox SVG explicit dimensions

Mermaid emits width="100%" with max-width in px; that collapses to 0×0
inside the centered lightbox layer. Derive width/height from viewBox for
the uniquified lightbox clone.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): render mermaid lightbox via isolated SVG data URL

String id rewrites broke mermaid's embedded CSS so only labels appeared
zoomed. Rasterize the inline SVG to a data-URL img instead of duplicating
markup in the DOM.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): lightbox re-renders SVG for sequence diagrams

Data-URL images drop or blank some mermaid diagram types (sequence).
Re-render with a modal-specific id into inline SVG on a code-bg panel,
and add sequence theme variables for dark/light.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): mermaid lightbox uses inline SVG in shadow DOM

Reuse the inline render in an isolated shadow root so sequence CSS stays
intact, and fit the viewport from viewBox dimensions instead of the loading
placeholder or width="100%" layout.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(web): Playwright lightbox coverage per mermaid diagram type

Add e2e harness and a script that opens the lightbox for each diagram
kind (flowchart through kanban). Fit uses inline getBBox() so compact
charts like gitGraph fill the viewport.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(web): bounded Playwright via webServer, fix gantt fit sizing

Playwright owns Vite lifecycle (no agent-spawned dev server). Fit uses
viewBox unless viewBox padding is excessive (gitGraph); wide charts use
width-based coverage in e2e.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(web): gitignore Playwright test-results

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): address PR 741 bot feedback (typecheck, fit floor, gitignore)

Guard lightbox open when svg is null; allow fit scale down to 0.01 while
keeping 0.25 minimum for manual zoom; ignore Playwright test-results/ correctly.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(web): Playwright asserts click expands diagram vs inline

Measure inline vs lightbox bounding box after click; require visible
growth (area ratio or max dimension) plus dialog + shadow SVG content.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(web): Playwright against live HAPI session for mermaid lightbox

Add seed script for a dedicated chat session, live hub Playwright suite
(HAPI_LIVE=1), and dogfood doc. Live tests fail until driver serves shadow-DOM
lightbox (catches gray-box regression on stale bundles).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): undo wrapper transform in lightbox fit; carry fit floor in zoom

Resolves PR #741 review threads (HAPI Bot Major):

1. measureSvgIntrinsicSize / measureContentSize prefer intrinsic dimensions
   (viewBox -> width/height attrs -> img.naturalSize) before getBoundingClientRect.
   When the rect is the only signal, divide by scaleRef.current so the 50/200ms
   refit retries stop compounding with the wrapper's scale(...) transform.
   Large diagrams no longer jump tiny or oversize after async render completes.

2. Interactive zoom (wheel/keys/buttons/pinch) now clamps with
   Math.min(MIN_SCALE, baseScaleRef.current). A diagram fitted below the
   normal 25% floor stays reachable instead of snapping back to 25% and
   clipping. Zoom-out button disabled threshold uses the same min.

3. Add Vitest coverage for both helpers (intrinsic precedence, scale-aware
   rect fallback, divide-by-zero guard) so regressions surface without
   needing the full Playwright stack.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): mermaid seed refuses to wipe non-fixture sessions

HAPI Bot Major (PR #741): SESSION_ID is documented as overridable,
and the script unconditionally deletes every message for the target
session before seeding fixtures. If pointed at a real session id,
that's silent data loss.

Refuse to proceed when an existing session id has a tag other than
'mermaid-lightbox-e2e'. New ids and the canonical fixture session
still seed normally; real sessions throw before any DELETE runs.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): normalize mermaid svg for lightbox shadow root

Mermaid emits width="100%" on every diagram. Inside a shadow root whose
host has no explicit size, that collapses to zero in Chromium for most
diagram types - only ones that ship pixel attrs (e.g. journey) happen to
render. Operator confirmed on the live driver: every diagram except
journey opened to a grey rounded square.

MermaidLightboxSvg now runs normalizeMermaidSvgForStandaloneDisplay before
injecting (strips width/height="100%", bakes viewBox dims as pixels) and
sets :host{display:inline-block} so the host sizes to the SVG. Inline svg
in chat is unchanged - only the lightbox copy is normalized.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): keep mermaid lightbox content below the toolbar

Operator screenshot showed the diagram top (e.g. pie 'Pets' title)
clipped behind the toolbar bar. Two causes:

1. getScreenFitSize used the full viewport height, so the fit scale
   sized the diagram to fill an area the toolbar overlapped.
2. The viewport (drag/zoom area) was inset-0; content centered on the
   full viewport center, not the visible region's center, pushing the
   top behind the toolbar.

Measure the toolbar with a ResizeObserver, subtract its height from
the fit calculation (clamped at zero), and start the viewport region
below the toolbar (top: toolbarHeight). Fit scale recomputes whenever
toolbar height changes.

Adds Vitest coverage for getScreenFitSize reserved-top math.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): guard ResizeObserver before constructing it

HAPI Bot Major (PR #741): Vitest jsdom does not polyfill ResizeObserver,
so the toolbar measure effect throws ReferenceError when the existing
mermaid-diagram React tests open the lightbox. Same code path is also
brittle in any browser/webview without the API.

Fall back to plain window 'resize' listener when ResizeObserver is
absent. Toolbar height won't auto-update on element resize without it,
but the lightbox still renders and the resize listener catches the
common viewport-rotation case.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(scripts): live mermaid playwright wrapper runs from repo root

HAPI Bot Minor (PR #741): the wrapper sets cwd to scripts/, but the
test:mermaid-lightbox:live npm script lives in the repo-root
package.json, so spawning npm there exited before Playwright started.
Switch cwd to the repo root and drop the unused WEB_DIR constant.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): accept signed viewBox values in mermaid lightbox normalize

HAPI Bot Minor (PR #741): the viewBox regex only matched digits, dots,
and spaces, so a valid viewBox with negative origin (e.g. '-8 -8 640 480')
returned null. normalizeMermaidSvgForStandaloneDisplay then became a
no-op and left width='100%', re-introducing the zero-sized lightbox
render this PR is meant to fix for the affected diagrams.

Switch to the bot's suggested regex (signed numbers, single or double
quotes, comma or space separators) and reject NaN parts. Adds Vitest
coverage for signed origins, single quotes, comma separators, the
malformed/no-viewBox null paths, and an end-to-end normalize test that
fails against the old regex.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): align @playwright/test on 1.60.0 across workspaces

HAPI Bot Major (PR #741): web/package.json pinned @playwright/test at
1.49.1 while the root workspace and bun.lock were on 1.60.0. The
mismatch surfaced after rebasing onto upstream/main, where the root had
already moved to 1.60.0 while my web devDependency lagged from an older
commit. A frozen install would reject the lockfile and the new web e2e
script could resolve a different Playwright than root scripts.

Bump the web devDependency to 1.60.0 and regenerate bun.lock so all
workspaces share one Playwright version.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): move mermaid playwright fixtures out of public

HAPI Bot Minor (PR #741): the e2e and smoke fixtures lived under
web/public, so Vite copied them verbatim into web/dist and the hub
asset generator embedded them in production bundles. Both pages
import Vite dev-only paths (/@react-refresh and /src/dev/...), so
the production /mermaid-lightbox-{e2e,smoke}.html routes would 404
on those imports.

Move both fixtures to web/e2e-fixtures/ to match the existing
scratchlist-fixture pattern (relative ../src/dev import, served by
Vite at /e2e-fixtures/...) and update the Playwright spec to hit the
new path. Build now ships 112 PWA precache entries instead of 114
(both fixtures excluded from dist).

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
HeavyGee
2026-07-11 11:07:12 +08:00
committed by GitHub
co-authored by Cursor
parent a82dd49049
commit 65e1708c78
26 changed files with 1874 additions and 36 deletions
+1
View File
@@ -132,6 +132,7 @@
"workbox-window": "^7.4.0",
},
"devDependencies": {
"@playwright/test": "1.60.0",
"@tailwindcss/postcss": "^4.1.18",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
+56
View File
@@ -0,0 +1,56 @@
# Mermaid lightbox dogfood (Playwright)
Two Playwright targets:
| Target | What it exercises | Command |
|--------|-------------------|---------|
| **Component (Vite)** | `MermaidDiagram` in isolation on dev server | `npm run test:mermaid-lightbox:playwright` |
| **Live session (hub)** | Real chat thread, click-to-zoom | `npm run test:mermaid-lightbox:live` |
## Live session (production-shaped)
**Session URL (after seed):**
`{HAPI_URL}/sessions/a7370000-0000-4000-8000-000000000737`
Default `HAPI_URL` for live tests: `http://127.0.0.1:3006` (daily driver).
For tailnet: `HAPI_URL=https://hapi.tail9944ee.ts.net` (seed **that** hub's DB first).
### 1. Seed fixtures (hub DB)
On the machine that owns `HAPI_DB_PATH` (usually `~/.hapi/hapi.db`):
```bash
bun run seed:mermaid-lightbox:session
```
Inserts 15 assistant messages (one per diagram type). Re-run to replace messages in that session.
### 2. Deploy web with your branch
```bash
hapi-driver-rebuild --build-web
# activate soup when ready (restarts hub)
```
Hard-refresh the browser after web changes.
### 3. Run live Playwright
```bash
HAPI_LIVE=1 HAPI_URL=http://127.0.0.1:3006 npm run test:mermaid-lightbox:live
```
Requires `~/.hapi/settings.json` `cliApiToken` (or `HAPI_ACCESS_TOKEN`).
**Pass criteria:** dialog opens, SVG in **shadow root** (`[data-mermaid-lightbox]`), expands vs inline, sequence has multiple actors/lines.
If tests report `legacy` or `empty` lightbox, the served web bundle predates the shadow-DOM fix — rebuild driver.
## Isolation page (not chat)
Only for component regression; **not** the same as chat:
`http://127.0.0.1:5173/mermaid-lightbox-e2e.html?case=sequence` (Vite dev, not on tailnet dist unless you add the HTML to a build).
Diagram sources: `web/src/dev/mermaid-lightbox-cases.ts`
+3
View File
@@ -25,6 +25,9 @@
"test:shared": "cd shared && bun run test",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:mermaid-lightbox:playwright": "timeout 600 node scripts/dev/mermaid-lightbox-playwright.mjs",
"test:mermaid-lightbox:live": "timeout 900 env HAPI_LIVE=1 playwright test -c web/playwright.live.config.ts",
"seed:mermaid-lightbox:session": "bun run scripts/dev/mermaid-lightbox-seed-session-db.ts",
"clean-session": "bun run hub/scripts/cleanup-sessions.ts",
"release-all": "cd cli && bun run release-all"
},
@@ -0,0 +1,20 @@
#!/usr/bin/env node
/** Bounded wrapper: Playwright against a real HAPI chat session (no Vite). */
import { spawnSync } from 'node:child_process'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
const npmBin = process.env.NPM_BIN ?? 'npm'
const result = spawnSync(
npmBin,
['run', 'test:mermaid-lightbox:live'],
{
cwd: REPO_ROOT,
stdio: 'inherit',
env: { ...process.env, PATH: process.env.PATH, HAPI_LIVE: '1' },
},
)
process.exit(result.status === null ? 1 : result.status)
@@ -0,0 +1,26 @@
#!/usr/bin/env node
/**
* Bounded wrapper for mermaid lightbox Playwright (web/e2e).
* Vite lifecycle is owned by web/playwright.config.ts webServer — not this process.
*
* Usage (from repo root):
* npm run test:mermaid-lightbox:playwright
*/
import { spawnSync } from 'node:child_process'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const WEB_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '../../web')
const npmBin = process.env.NPM_BIN ?? 'npm'
const result = spawnSync(
npmBin,
['run', 'test:mermaid-lightbox:e2e'],
{
cwd: WEB_DIR,
stdio: 'inherit',
env: { ...process.env, PATH: process.env.PATH },
},
)
process.exit(result.status === null ? 1 : result.status)
@@ -0,0 +1,85 @@
/**
* Seed assistant messages with mermaid fixtures into a hub SQLite DB.
* Run on the host that owns HAPI_DB_PATH (usually the hub machine).
*
* HAPI_DB_PATH=~/.hapi/hapi.db SESSION_ID=<uuid> bun run scripts/dev/mermaid-lightbox-seed-session-db.ts
*/
import { Database } from 'bun:sqlite'
import { randomUUID } from 'node:crypto'
import { homedir } from 'node:os'
import { join } from 'node:path'
import {
MERMAID_LIGHTBOX_CASE_IDS,
MERMAID_LIGHTBOX_CASES,
} from '../../web/src/dev/mermaid-lightbox-cases'
const dbPath = process.env.HAPI_DB_PATH ?? join(homedir(), '.hapi', 'hapi.db')
/** Stable id for mermaid Playwright live session (create if missing). */
const sessionId = process.env.SESSION_ID ?? 'a7370000-0000-4000-8000-000000000737'
const sessionTag = 'mermaid-lightbox-e2e'
function agentMermaidEnvelope(caseId: string, code: string) {
const text = `<!-- mermaid-e2e:${caseId} -->\n\`\`\`mermaid\n${code.trim()}\n\`\`\``
return {
role: 'agent',
content: {
type: 'output',
data: {
type: 'assistant',
uuid: randomUUID(),
parentUuid: null,
isSidechain: false,
message: {
content: [{ type: 'text', text }],
},
},
},
}
}
const db = new Database(dbPath)
const now = Date.now()
const existing = db.prepare('SELECT id, tag FROM sessions WHERE id = ?').get(sessionId) as
| { id: string; tag: string | null }
| undefined
if (existing && existing.tag !== sessionTag) {
throw new Error(
`Refusing to seed mermaid fixtures into session ${sessionId}: tag is `
+ `${JSON.stringify(existing.tag)}, expected ${JSON.stringify(sessionTag)}. `
+ `Unset SESSION_ID or use a session created by this script.`,
)
}
if (!existing) {
db.prepare(`
INSERT INTO sessions (
id, tag, namespace, created_at, updated_at, active, seq
) VALUES (?, ?, 'default', ?, ?, 0, 0)
`).run(sessionId, sessionTag, now, now)
console.log(`created session ${sessionId} (${sessionTag})`)
}
const insert = db.prepare(`
INSERT INTO messages (id, session_id, content, created_at, seq, local_id, invoked_at, scheduled_at)
VALUES (?, ?, ?, ?, ?, NULL, ?, NULL)
`)
db.prepare('DELETE FROM messages WHERE session_id = ?').run(sessionId)
let seqRow = db.prepare('SELECT COALESCE(MAX(seq), 0) AS maxSeq FROM messages WHERE session_id = ?').get(sessionId) as {
maxSeq: number
}
for (const caseId of MERMAID_LIGHTBOX_CASE_IDS) {
const code = MERMAID_LIGHTBOX_CASES[caseId]
const envelope = agentMermaidEnvelope(caseId, code)
const seq = (seqRow.maxSeq ?? 0) + 1
seqRow = { maxSeq: seq }
const messageId = randomUUID()
insert.run(messageId, sessionId, JSON.stringify(envelope), now, seq, now)
console.log(`seeded ${caseId} @ seq ${seq}`)
}
db.prepare('UPDATE sessions SET updated_at = ? WHERE id = ?').run(now, sessionId)
console.log(`Done. Open: /sessions/${sessionId}`)
+1
View File
@@ -1,3 +1,4 @@
node_modules/
dist/
dev-dist/
test-results/
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Mermaid lightbox e2e</title>
<style>
body { margin: 0; background: #1a1d21; color: #edf1f5; font-family: system-ui, sans-serif; }
#root { padding: 24px; max-width: 960px; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="../src/dev/mermaid-lightbox-e2e.tsx"></script>
</body>
</html>
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Mermaid lightbox smoke</title>
<style>
body { margin: 0; background: #1a1d21; color: #edf1f5; font-family: system-ui, sans-serif; }
#root { padding: 24px; max-width: 720px; }
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="../src/dev/mermaid-lightbox-smoke.tsx"></script>
</body>
</html>
+82
View File
@@ -0,0 +1,82 @@
import { readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import type { Page } from '@playwright/test'
export function getHapiBaseUrl(): string {
return (process.env.HAPI_URL ?? 'http://127.0.0.1:3006').replace(/\/$/, '')
}
export function getMermaidTestSessionId(): string {
return process.env.SESSION_ID ?? 'a7370000-0000-4000-8000-000000000737'
}
export function readCliAccessToken(): string {
if (process.env.HAPI_ACCESS_TOKEN?.trim()) {
return process.env.HAPI_ACCESS_TOKEN.trim()
}
const settingsPath = process.env.HAPI_SETTINGS_PATH ?? join(homedir(), '.hapi', 'settings.json')
const settings = JSON.parse(readFileSync(settingsPath, 'utf8')) as { cliApiToken?: string }
if (!settings.cliApiToken) {
throw new Error(`Missing cliApiToken in ${settingsPath}`)
}
return settings.cliApiToken
}
export async function installHapiAuth(page: Page, baseUrl: string, accessToken: string) {
await page.addInitScript(({ token, url }) => {
localStorage.setItem(`hapi_access_token::${url}`, token)
}, { token: accessToken, url: baseUrl })
}
export async function scrollChatToBottom(page: Page) {
for (let i = 0; i < 24; i += 1) {
const found = await page.locator('[data-mermaid-diagram][data-rendered="true"]').count()
if (found > 0) break
await page.evaluate(() => {
const scrollers = [...document.querySelectorAll('*')].filter(
(el) => el.scrollHeight > el.clientHeight + 80,
)
scrollers.sort((a, b) => b.scrollHeight - a.scrollHeight)
const target = scrollers[0]
if (target) target.scrollTop = target.scrollHeight
window.scrollTo(0, document.body.scrollHeight)
})
await page.waitForTimeout(400)
}
}
export type LiveLightboxMetrics = {
inlineW: number
inlineH: number
lightboxW: number
lightboxH: number
hasShadowSvg: boolean
shapeTotal: number
coverage: number
}
export async function readLiveLightboxMetrics(page: Page): Promise<LiveLightboxMetrics> {
return page.evaluate(() => {
const inlineSvg = document.querySelector('[data-mermaid-diagram][data-rendered="true"] svg')
const inlineBox = inlineSvg?.getBoundingClientRect()
const host = document.querySelector('[data-mermaid-lightbox]')
const lightboxSvg = host?.shadowRoot?.querySelector('svg')
const lightboxBox = lightboxSvg?.getBoundingClientRect()
const vw = window.visualViewport?.width ?? window.innerWidth
const vh = window.visualViewport?.height ?? window.innerHeight
const shapes =
(lightboxSvg?.querySelectorAll('rect').length ?? 0)
+ (lightboxSvg?.querySelectorAll('path').length ?? 0)
+ (lightboxSvg?.querySelectorAll('line').length ?? 0)
return {
inlineW: inlineBox?.width ?? 0,
inlineH: inlineBox?.height ?? 0,
lightboxW: lightboxBox?.width ?? 0,
lightboxH: lightboxBox?.height ?? 0,
hasShadowSvg: Boolean(lightboxSvg),
shapeTotal: shapes,
coverage: Math.max((lightboxBox?.width ?? 0) / vw, (lightboxBox?.height ?? 0) / vh),
}
})
}
+107
View File
@@ -0,0 +1,107 @@
import { expect, test } from '@playwright/test'
import { MERMAID_LIGHTBOX_CASE_IDS } from '../src/dev/mermaid-lightbox-cases'
import {
getHapiBaseUrl,
getMermaidTestSessionId,
installHapiAuth,
readCliAccessToken,
readLiveLightboxMetrics,
scrollChatToBottom,
} from './helpers/hapi-live'
const liveEnabled = process.env.HAPI_LIVE === '1'
const MIN_COVERAGE = Number(process.env.MERMAID_E2E_MIN_COVERAGE ?? '0.35')
const MIN_EXPAND_RATIO = Number(process.env.MERMAID_E2E_MIN_EXPAND_RATIO ?? '1.25')
test.describe.configure({ mode: 'serial' })
test.describe('mermaid lightbox (live HAPI session)', () => {
test.skip(!liveEnabled, 'Set HAPI_LIVE=1 to run against a real hub session')
test.beforeAll(() => {
readCliAccessToken()
})
for (const caseId of MERMAID_LIGHTBOX_CASE_IDS) {
test(`live session lightbox: ${caseId}`, async ({ page }) => {
const baseUrl = getHapiBaseUrl()
const sessionId = getMermaidTestSessionId()
const token = readCliAccessToken()
await installHapiAuth(page, baseUrl, token)
await page.goto(`${baseUrl}/sessions/${sessionId}`, {
waitUntil: 'domcontentloaded',
timeout: 60_000,
})
await page.waitForTimeout(2000)
await scrollChatToBottom(page)
const diagramIndex = MERMAID_LIGHTBOX_CASE_IDS.indexOf(caseId)
const rendered = page.locator('[data-mermaid-diagram][data-rendered="true"]')
await expect(
rendered,
`Expected ${MERMAID_LIGHTBOX_CASE_IDS.length} seeded diagrams. Run: bun run seed:mermaid-lightbox:session`,
).toHaveCount(MERMAID_LIGHTBOX_CASE_IDS.length, { timeout: 20_000 })
const diagram = rendered.nth(diagramIndex)
await diagram.scrollIntoViewIfNeeded()
const before = await diagram.evaluate((el) => {
const svg = el.querySelector('svg')
const box = svg?.getBoundingClientRect()
return { inlineW: box?.width ?? 0, inlineH: box?.height ?? 0 }
})
await diagram.click({ timeout: 15_000 })
await page.waitForSelector('[role="dialog"]', { timeout: 10_000 })
const lightboxKind = await page.waitForFunction(() => {
const dialog = document.querySelector('[role="dialog"]')
if (!dialog) return 'no-dialog'
const shadowSvg = dialog.querySelector('[data-mermaid-lightbox]')?.shadowRoot?.querySelector('svg')
if (shadowSvg) {
const box = shadowSvg.getBoundingClientRect()
if (box.width > 0 && box.height > 0) return 'shadow'
}
const legacySvg = dialog.querySelector('.rounded-lg svg')
if (legacySvg) {
const box = legacySvg.getBoundingClientRect()
if (box.width > 0 && box.height > 0) return 'legacy'
}
return 'empty'
}, { timeout: 15_000 }).then((h) => h.jsonValue() as Promise<string>)
expect(
lightboxKind,
`${caseId}: expected shadow-DOM lightbox (rebuild driver web after feat/mermaid-lightbox-737)`,
).toBe('shadow')
const after = await readLiveLightboxMetrics(page)
const areaRatio =
before.inlineW > 0 && before.inlineH > 0
? (after.lightboxW * after.lightboxH) / (before.inlineW * before.inlineH)
: 0
expect(after.hasShadowSvg, `${caseId}: shadow SVG in live chat`).toBe(true)
expect(after.shapeTotal, `${caseId}: diagram shapes`).toBeGreaterThan(0)
expect(after.coverage, `${caseId}: viewport coverage`).toBeGreaterThanOrEqual(MIN_COVERAGE)
expect(
areaRatio >= MIN_EXPAND_RATIO || after.lightboxW > before.inlineW * 1.05,
`${caseId}: expand ${areaRatio.toFixed(2)}x inline ${Math.round(before.inlineW)}x${Math.round(before.inlineH)} → lightbox ${Math.round(after.lightboxW)}x${Math.round(after.lightboxH)}`,
).toBe(true)
if (caseId === 'sequence') {
const seqShapes = await page.evaluate(() => {
const svg = document.querySelector('[data-mermaid-lightbox]')?.shadowRoot?.querySelector('svg')
return {
rect: svg?.querySelectorAll('rect').length ?? 0,
line: svg?.querySelectorAll('line').length ?? 0,
}
})
expect(seqShapes.rect >= 2 || seqShapes.line >= 2, `${caseId}: sequence content`).toBe(true)
}
await page.keyboard.press('Escape')
await page.waitForSelector('[role="dialog"]', { state: 'detached', timeout: 5000 }).catch(() => undefined)
})
}
})
+156
View File
@@ -0,0 +1,156 @@
import { expect, test } from '@playwright/test'
import { MERMAID_LIGHTBOX_CASE_IDS } from '../src/dev/mermaid-lightbox-cases'
const MIN_COVERAGE = Number(process.env.MERMAID_E2E_MIN_COVERAGE ?? '0.35')
const MIN_SVG_PX = Number(process.env.MERMAID_E2E_MIN_SVG_PX ?? '200')
type LightboxMetrics = {
hasShadowSvg: boolean
usesDataUrlImg: boolean
svgW: number
svgH: number
coverageW: number
coverageH: number
shapeTotal: number
shapes: { rect: number; path: number; line: number }
}
type ExpandMetrics = {
inlineW: number
inlineH: number
lightboxW: number
lightboxH: number
areaRatio: number
}
async function readExpandMetrics(page: import('@playwright/test').Page): Promise<ExpandMetrics> {
return page.evaluate(() => {
const inlineSvg = document.querySelector('[data-mermaid-diagram][data-rendered="true"] svg')
const inlineBox = inlineSvg?.getBoundingClientRect()
const host = document.querySelector('[data-mermaid-lightbox]')
const lightboxSvg = host?.shadowRoot?.querySelector('svg')
const lightboxBox = lightboxSvg?.getBoundingClientRect()
const inlineArea = (inlineBox?.width ?? 0) * (inlineBox?.height ?? 0)
const lightboxArea = (lightboxBox?.width ?? 0) * (lightboxBox?.height ?? 0)
return {
inlineW: inlineBox?.width ?? 0,
inlineH: inlineBox?.height ?? 0,
lightboxW: lightboxBox?.width ?? 0,
lightboxH: lightboxBox?.height ?? 0,
areaRatio: inlineArea > 0 ? lightboxArea / inlineArea : 0,
}
})
}
async function readLightboxMetrics(page: import('@playwright/test').Page): Promise<LightboxMetrics> {
return page.evaluate(() => {
const dialog = document.querySelector('[role="dialog"]')
const host = dialog?.querySelector('[data-mermaid-lightbox]')
const svg = host?.shadowRoot?.querySelector('svg')
const box = svg?.getBoundingClientRect()
const vw = window.visualViewport?.width ?? window.innerWidth
const vh = window.visualViewport?.height ?? window.innerHeight
const shapes = {
rect: svg?.querySelectorAll('rect').length ?? 0,
path: svg?.querySelectorAll('path').length ?? 0,
line: svg?.querySelectorAll('line').length ?? 0,
}
const shapeTotal =
shapes.rect
+ shapes.path
+ shapes.line
+ (svg?.querySelectorAll('text').length ?? 0)
+ (svg?.querySelectorAll('circle').length ?? 0)
return {
hasShadowSvg: Boolean(svg),
usesDataUrlImg: Boolean(dialog?.querySelector('img[src^="data:image/svg"]')),
svgW: box?.width ?? 0,
svgH: box?.height ?? 0,
coverageW: (box?.width ?? 0) / vw,
coverageH: (box?.height ?? 0) / vh,
shapeTotal,
shapes,
}
})
}
function assertMetrics(caseId: string, metrics: LightboxMetrics) {
expect(metrics.hasShadowSvg, `${caseId}: shadow-root svg`).toBe(true)
expect(metrics.usesDataUrlImg, `${caseId}: data-url img regression`).toBe(false)
expect(
metrics.svgW >= MIN_SVG_PX || metrics.svgH >= MIN_SVG_PX,
`${caseId}: svg ${Math.round(metrics.svgW)}x${Math.round(metrics.svgH)}px`,
).toBe(true)
const coverage = Math.max(metrics.coverageW, metrics.coverageH)
const wideShort = caseId === 'gantt' || caseId === 'kanban'
if (wideShort) {
expect(
metrics.coverageW >= MIN_COVERAGE || metrics.svgW >= 600,
`${caseId}: wide chart width cov ${(metrics.coverageW * 100).toFixed(0)}%`,
).toBe(true)
} else {
expect(coverage, `${caseId}: viewport coverage ${(coverage * 100).toFixed(0)}%`).toBeGreaterThanOrEqual(
MIN_COVERAGE,
)
}
expect(metrics.shapeTotal, `${caseId}: shape count`).toBeGreaterThan(0)
if (caseId === 'sequence') {
expect(
metrics.shapes.rect >= 2 || metrics.shapes.line >= 2,
`${caseId}: sequence actors/lines`,
).toBe(true)
}
}
/** Lightbox should be visibly larger than the inline chat preview after click. */
const MIN_EXPAND_AREA_RATIO = Number(process.env.MERMAID_E2E_MIN_EXPAND_RATIO ?? '1.4')
for (const caseId of MERMAID_LIGHTBOX_CASE_IDS) {
test(`mermaid lightbox: ${caseId}`, async ({ page }) => {
await page.goto(`/e2e-fixtures/mermaid-lightbox-e2e.html?case=${encodeURIComponent(caseId)}`)
await page.waitForSelector('[data-mermaid-diagram][data-rendered="true"]', { timeout: 20_000 })
const beforeExpand = await readExpandMetrics(page)
expect(beforeExpand.lightboxW, `${caseId}: dialog closed before click`).toBe(0)
await page.locator('[data-mermaid-diagram][data-rendered="true"]').click()
await page.waitForSelector('[role="dialog"]', { timeout: 10_000 })
await page.waitForFunction(() => {
const host = document.querySelector('[data-mermaid-lightbox]')
const svg = host?.shadowRoot?.querySelector('svg')
const box = svg?.getBoundingClientRect()
return Boolean(box && box.width > 0 && box.height > 0)
}, { timeout: 15_000 })
await page
.waitForFunction(
(minCoverage) => {
const host = document.querySelector('[data-mermaid-lightbox]')
const svg = host?.shadowRoot?.querySelector('svg')
const box = svg?.getBoundingClientRect()
if (!box || box.width <= 0) return false
const vw = window.visualViewport?.width ?? window.innerWidth
const vh = window.visualViewport?.height ?? window.innerHeight
return Math.max(box.width / vw, box.height / vh) >= minCoverage
},
MIN_COVERAGE,
{ timeout: 8_000 },
)
.catch(() => undefined)
const expand = await readExpandMetrics(page)
const inlineMax = Math.max(expand.inlineW, expand.inlineH)
const lightboxMax = Math.max(expand.lightboxW, expand.lightboxH)
const expandedVisibly =
expand.areaRatio >= MIN_EXPAND_AREA_RATIO || lightboxMax > inlineMax * 1.05
expect(expandedVisibly, `${caseId}: expand inline ${Math.round(expand.inlineW)}x${Math.round(expand.inlineH)} → lightbox ${Math.round(expand.lightboxW)}x${Math.round(expand.lightboxH)}`).toBe(true)
const metrics = await readLightboxMetrics(page)
assertMetrics(caseId, metrics)
test.info().annotations.push({
type: 'expand',
description: `${caseId}: inline ${Math.round(expand.inlineW)}x${Math.round(expand.inlineH)} → lightbox ${Math.round(expand.lightboxW)}x${Math.round(expand.lightboxH)} (${expand.areaRatio.toFixed(1)}x area)`,
})
})
}
+4 -2
View File
@@ -9,7 +9,8 @@
"build": "vite build && cp dist/index.html dist/404.html",
"typecheck": "tsc --noEmit",
"preview": "vite preview",
"test": "vitest run"
"test": "vitest run",
"test:mermaid-lightbox:e2e": "playwright test e2e/mermaid-lightbox.spec.ts"
},
"dependencies": {
"@assistant-ui/react": "^0.11.53",
@@ -67,6 +68,7 @@
"typescript": "^5.9.3",
"unified": "^11.0.5",
"vite": "^7.3.0",
"vitest": "^4.0.16"
"vitest": "^4.0.16",
"@playwright/test": "1.60.0"
}
}
+33
View File
@@ -0,0 +1,33 @@
import { defineConfig, devices } from '@playwright/test'
const chromePath = process.env.PLAYWRIGHT_CHROME_PATH
export default defineConfig({
testDir: './e2e',
timeout: 45_000,
expect: { timeout: 15_000 },
fullyParallel: false,
workers: 1,
reporter: [['list']],
use: {
...devices['Desktop Chrome'],
baseURL: 'http://127.0.0.1:5173',
viewport: { width: 1440, height: 900 },
...(chromePath
? {
launchOptions: {
executablePath: chromePath,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
},
}
: {}),
},
webServer: {
command: 'npm run dev',
url: 'http://127.0.0.1:5173',
reuseExistingServer: !process.env.CI,
timeout: 120_000,
stdout: 'pipe',
stderr: 'pipe',
},
})
+26
View File
@@ -0,0 +1,26 @@
import { defineConfig, devices } from '@playwright/test'
const chromePath = process.env.PLAYWRIGHT_CHROME_PATH
/** Live hub session tests — no Vite; hits HAPI_URL with HAPI_LIVE=1. */
export default defineConfig({
testDir: './e2e',
testMatch: 'mermaid-lightbox-session.spec.ts',
timeout: 120_000,
expect: { timeout: 20_000 },
fullyParallel: false,
workers: 1,
reporter: [['list']],
use: {
...devices['Desktop Chrome'],
viewport: { width: 1440, height: 1100 },
...(chromePath
? {
launchOptions: {
executablePath: chromePath,
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
},
}
: {}),
},
})
+155
View File
@@ -0,0 +1,155 @@
import { describe, expect, it } from 'vitest'
import { getScreenFitSize, measureContentSize, measureSvgIntrinsicSize } from './ZoomableLightbox'
type Rect = { width: number; height: number }
function makeSvg(opts: {
viewBox?: { width: number; height: number }
widthAttr?: string | null
heightAttr?: string | null
rect?: Rect | null
}): SVGSVGElement {
const svg = {
viewBox: opts.viewBox
? { baseVal: { width: opts.viewBox.width, height: opts.viewBox.height } }
: { baseVal: { width: 0, height: 0 } },
getAttribute(name: string) {
if (name === 'width') return opts.widthAttr ?? null
if (name === 'height') return opts.heightAttr ?? null
return null
},
getBoundingClientRect() {
return opts.rect ?? { width: 0, height: 0 }
},
}
return svg as unknown as SVGSVGElement
}
function makeContent(opts: {
img?: { naturalWidth: number; naturalHeight: number; rect?: Rect } | null
svg?: SVGSVGElement | null
rect?: Rect | null
}): HTMLElement {
const queryResults = new Map<string, unknown>()
if (opts.img) {
queryResults.set('img', {
naturalWidth: opts.img.naturalWidth,
naturalHeight: opts.img.naturalHeight,
getBoundingClientRect: () => opts.img?.rect ?? { width: 0, height: 0 },
})
}
if (opts.svg) queryResults.set('svg', opts.svg)
const content = {
querySelector(selector: string) {
return queryResults.get(selector) ?? null
},
getBoundingClientRect() {
return opts.rect ?? { width: 0, height: 0 }
},
}
return content as unknown as HTMLElement
}
describe('measureSvgIntrinsicSize', () => {
it('prefers viewBox over the (possibly transformed) bounding rect', () => {
const svg = makeSvg({
viewBox: { width: 1200, height: 900 },
rect: { width: 60, height: 45 },
})
expect(measureSvgIntrinsicSize(svg, 0.05)).toEqual({ width: 1200, height: 900 })
})
it('falls back to width/height attributes when viewBox is missing', () => {
const svg = makeSvg({ widthAttr: '640', heightAttr: '480' })
expect(measureSvgIntrinsicSize(svg)).toEqual({ width: 640, height: 480 })
})
it('divides bounding rect by the current scale to undo wrapper transform', () => {
const svg = makeSvg({ rect: { width: 200, height: 100 } })
expect(measureSvgIntrinsicSize(svg, 0.5)).toEqual({ width: 400, height: 200 })
})
it('returns null when no source is usable', () => {
const svg = makeSvg({})
expect(measureSvgIntrinsicSize(svg)).toBeNull()
})
})
describe('getScreenFitSize', () => {
const originalVisualViewport = Object.getOwnPropertyDescriptor(window, 'visualViewport')
const originalInnerWidth = window.innerWidth
const originalInnerHeight = window.innerHeight
function setViewport(width: number, height: number) {
Object.defineProperty(window, 'visualViewport', {
configurable: true,
value: { width, height },
})
}
function clearViewport() {
Object.defineProperty(window, 'visualViewport', { configurable: true, value: null })
Object.defineProperty(window, 'innerWidth', { configurable: true, value: originalInnerWidth })
Object.defineProperty(window, 'innerHeight', { configurable: true, value: originalInnerHeight })
}
function restore() {
if (originalVisualViewport) {
Object.defineProperty(window, 'visualViewport', originalVisualViewport)
}
}
it('subtracts the reserved top region (toolbar) from height', () => {
setViewport(1000, 800)
try {
expect(getScreenFitSize(40)).toEqual({ width: 1000, height: 760 })
} finally {
restore()
}
})
it('clamps reserved height at zero (no negative regions)', () => {
setViewport(800, 100)
try {
expect(getScreenFitSize(200)).toEqual({ width: 800, height: 0 })
} finally {
restore()
}
})
it('falls back to window inner size when visualViewport is unavailable', () => {
clearViewport()
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1280 })
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 900 })
try {
expect(getScreenFitSize(60)).toEqual({ width: 1280, height: 840 })
} finally {
restore()
}
})
})
describe('measureContentSize', () => {
it('prefers img.naturalSize over its bounding rect', () => {
const content = makeContent({
img: { naturalWidth: 800, naturalHeight: 600, rect: { width: 80, height: 60 } },
})
expect(measureContentSize(content, 0.1)).toEqual({ width: 800, height: 600 })
})
it('uses svg intrinsic size when no img is present', () => {
const svg = makeSvg({ viewBox: { width: 500, height: 250 } })
const content = makeContent({ svg })
expect(measureContentSize(content, 0.5)).toEqual({ width: 500, height: 250 })
})
it('divides the host rect by current scale as the last fallback', () => {
const content = makeContent({ rect: { width: 100, height: 50 } })
expect(measureContentSize(content, 0.5)).toEqual({ width: 200, height: 100 })
})
it('treats non-positive scale as identity to avoid divide-by-zero', () => {
const content = makeContent({ rect: { width: 100, height: 100 } })
expect(measureContentSize(content, 0)).toEqual({ width: 100, height: 100 })
})
})
+480
View File
@@ -0,0 +1,480 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type PointerEvent, type ReactNode, type WheelEvent } from 'react'
import { CloseIcon } from '@/components/icons'
const MIN_SCALE = 0.25
/** Floor for fit-to-screen only; dense diagrams can need under 25% to fit. */
const MIN_FIT_SCALE = 0.01
const MAX_SCALE = 8
const SCALE_STEP = 0.25
const BACKDROP_CLICK_MAX_MOVEMENT = 4
/** Edge margin when fitting to the device screen (not the inner panel only). */
const SCREEN_FIT_PADDING_PX = 12
export function getScreenFitSize(reservedTopPx = 0): { width: number; height: number } {
const viewport = window.visualViewport
const width = viewport ? viewport.width : window.innerWidth
const fullHeight = viewport ? viewport.height : window.innerHeight
const reserved = Math.max(0, reservedTopPx)
return { width, height: Math.max(0, fullHeight - reserved) }
}
type Point = { x: number; y: number }
function clampScale(value: number, minScale = MIN_SCALE): number {
return Math.min(MAX_SCALE, Math.max(minScale, value))
}
function getPointDistance(a: Point, b: Point): number {
return Math.hypot(a.x - b.x, a.y - b.y)
}
function getPointCenter(a: Point, b: Point): Point {
return {
x: (a.x + b.x) / 2,
y: (a.y + b.y) / 2,
}
}
/**
* Measure SVG intrinsic size, ignoring the wrapper's `scale(...)` transform.
* Order: viewBox -> width/height attrs -> bounding rect divided by current scale.
*/
export function measureSvgIntrinsicSize(
svg: SVGSVGElement,
currentScale = 1,
): { width: number; height: number } | null {
const viewBox = svg.viewBox?.baseVal
if (viewBox && viewBox.width > 0 && viewBox.height > 0) {
return { width: viewBox.width, height: viewBox.height }
}
const widthAttr = svg.getAttribute('width') ?? ''
const heightAttr = svg.getAttribute('height') ?? ''
const parsedWidth = Number.parseFloat(widthAttr)
const parsedHeight = Number.parseFloat(heightAttr)
if (parsedWidth > 0 && parsedHeight > 0) {
return { width: parsedWidth, height: parsedHeight }
}
const safeScale = currentScale > 0 ? currentScale : 1
const box = svg.getBoundingClientRect()
if (box.width > 0 && box.height > 0) {
return { width: box.width / safeScale, height: box.height / safeScale }
}
return null
}
/**
* Measure rendered content size, ignoring any ancestor `scale(...)` transform.
* Prefer intrinsic dimensions (img.naturalSize, SVG viewBox/attrs) before the
* bounding rect, which otherwise compounds with `scaleRef.current` on retry.
*/
export function measureContentSize(
content: HTMLElement,
currentScale = 1,
): { width: number; height: number } | null {
const safeScale = currentScale > 0 ? currentScale : 1
const img = content.querySelector('img')
if (img) {
if (img.naturalWidth > 0 && img.naturalHeight > 0) {
return { width: img.naturalWidth, height: img.naturalHeight }
}
const box = img.getBoundingClientRect()
if (box.width > 0 && box.height > 0) {
return { width: box.width / safeScale, height: box.height / safeScale }
}
}
const svg = content.querySelector('svg')
if (svg) {
const intrinsic = measureSvgIntrinsicSize(svg, safeScale)
if (intrinsic) return intrinsic
}
const rect = content.getBoundingClientRect()
if (rect.width > 0 && rect.height > 0) {
return { width: rect.width / safeScale, height: rect.height / safeScale }
}
return null
}
export type ZoomableLightboxProps = {
open: boolean
onClose: () => void
title?: string
ariaLabel: string
children: ReactNode
/** When set, re-fit viewport when this value changes (e.g. after async SVG load). */
fitContentKey?: string | number | null
/** Intrinsic content size for fit (e.g. mermaid viewBox) when layout is not measurable yet. */
fitContentSize?: { width: number; height: number } | null
/** Compute initial scale to fill the device screen (default true). */
fitOnOpen?: boolean
}
export function ZoomableLightbox(props: ZoomableLightboxProps) {
const {
open,
onClose,
title,
ariaLabel,
children,
fitContentKey = null,
fitContentSize = null,
fitOnOpen = true,
} = props
const [scale, setScale] = useState(1)
const [offset, setOffset] = useState({ x: 0, y: 0 })
const [toolbarHeight, setToolbarHeight] = useState(0)
const scaleRef = useRef(scale)
const offsetRef = useRef(offset)
const baseScaleRef = useRef(1)
const viewportRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
const toolbarRef = useRef<HTMLDivElement>(null)
const activePointersRef = useRef(new Map<number, Point>())
const dragRef = useRef<{ pointerId: number; startX: number; startY: number; originX: number; originY: number } | null>(null)
const pinchRef = useRef<{ startDistance: number; startScale: number; startCenter: Point; origin: Point } | null>(null)
const backdropPressRef = useRef<{ pointerId: number; x: number; y: number } | null>(null)
const updateScale = useCallback((next: number | ((current: number) => number)) => {
setScale((current) => {
const value = typeof next === 'function' ? next(current) : next
scaleRef.current = value
return value
})
}, [])
const updateOffset = useCallback((next: Point) => {
offsetRef.current = next
setOffset(next)
}, [])
const applyFitScale = useCallback(() => {
if (!fitOnOpen) {
baseScaleRef.current = 1
updateScale(1)
updateOffset({ x: 0, y: 0 })
return
}
const content = contentRef.current
if (!content) return
const contentSize = fitContentSize ?? measureContentSize(content, scaleRef.current)
if (!contentSize) return
const screen = getScreenFitSize(toolbarHeight)
const pad = SCREEN_FIT_PADDING_PX * 2
const fitWidth = (screen.width - pad) / contentSize.width
const fitHeight = (screen.height - pad) / contentSize.height
const fitScale = clampScale(Math.min(fitWidth, fitHeight), MIN_FIT_SCALE)
baseScaleRef.current = fitScale
updateScale(fitScale)
updateOffset({ x: 0, y: 0 })
}, [fitContentSize, fitOnOpen, toolbarHeight, updateOffset, updateScale])
const resetView = useCallback(() => {
updateScale(baseScaleRef.current)
updateOffset({ x: 0, y: 0 })
}, [updateOffset, updateScale])
const closeViewer = useCallback(() => {
onClose()
activePointersRef.current.clear()
dragRef.current = null
pinchRef.current = null
backdropPressRef.current = null
baseScaleRef.current = 1
updateScale(1)
updateOffset({ x: 0, y: 0 })
}, [onClose, updateOffset, updateScale])
/**
* Lower bound for interactive zoom. Carries the fit floor when the diagram
* was opened below MIN_SCALE (e.g. 0.05); otherwise sticks at MIN_SCALE.
*/
const getMinInteractiveScale = useCallback(() => {
return Math.min(MIN_SCALE, baseScaleRef.current)
}, [])
const zoomBy = useCallback((delta: number) => {
updateScale((current) => clampScale(current + delta, getMinInteractiveScale()))
}, [getMinInteractiveScale, updateScale])
const handleWheel = useCallback((event: WheelEvent<HTMLDivElement>) => {
event.preventDefault()
const delta = event.deltaY < 0 ? SCALE_STEP : -SCALE_STEP
zoomBy(delta)
}, [zoomBy])
const beginPinch = useCallback(() => {
const pointers = Array.from(activePointersRef.current.values())
if (pointers.length < 2) return
const [first, second] = pointers
pinchRef.current = {
startDistance: getPointDistance(first, second),
startScale: scaleRef.current,
startCenter: getPointCenter(first, second),
origin: offsetRef.current,
}
dragRef.current = null
}, [])
const handlePointerDown = useCallback((event: PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return
event.currentTarget.setPointerCapture(event.pointerId)
activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
backdropPressRef.current = event.target === event.currentTarget
? { pointerId: event.pointerId, x: event.clientX, y: event.clientY }
: null
if (activePointersRef.current.size >= 2) {
backdropPressRef.current = null
beginPinch()
return
}
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
originX: offsetRef.current.x,
originY: offsetRef.current.y,
}
}, [beginPinch])
const handlePointerMove = useCallback((event: PointerEvent<HTMLDivElement>) => {
if (!activePointersRef.current.has(event.pointerId)) return
activePointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
if (activePointersRef.current.size >= 2 && pinchRef.current) {
const pointers = Array.from(activePointersRef.current.values())
const [first, second] = pointers
const distance = getPointDistance(first, second)
const center = getPointCenter(first, second)
const pinch = pinchRef.current
const nextScale = pinch.startDistance > 0
? clampScale(
pinch.startScale * (distance / pinch.startDistance),
getMinInteractiveScale(),
)
: pinch.startScale
updateScale(nextScale)
updateOffset({
x: pinch.origin.x + center.x - pinch.startCenter.x,
y: pinch.origin.y + center.y - pinch.startCenter.y,
})
return
}
const drag = dragRef.current
if (!drag || drag.pointerId !== event.pointerId) return
updateOffset({
x: drag.originX + event.clientX - drag.startX,
y: drag.originY + event.clientY - drag.startY,
})
}, [getMinInteractiveScale, updateOffset, updateScale])
const handlePointerUp = useCallback((event: PointerEvent<HTMLDivElement>) => {
const backdropPress = backdropPressRef.current
const moved = backdropPress
? Math.hypot(event.clientX - backdropPress.x, event.clientY - backdropPress.y)
: Number.POSITIVE_INFINITY
const shouldCloseFromBackdrop = event.type === 'pointerup'
&& backdropPress?.pointerId === event.pointerId
&& event.target === event.currentTarget
&& activePointersRef.current.size === 1
&& moved <= BACKDROP_CLICK_MAX_MOVEMENT
activePointersRef.current.delete(event.pointerId)
if (backdropPress?.pointerId === event.pointerId) {
backdropPressRef.current = null
}
if (dragRef.current?.pointerId === event.pointerId) {
dragRef.current = null
}
pinchRef.current = null
const remainingPointer = activePointersRef.current.entries().next().value as [number, Point] | undefined
if (remainingPointer) {
dragRef.current = {
pointerId: remainingPointer[0],
startX: remainingPointer[1].x,
startY: remainingPointer[1].y,
originX: offsetRef.current.x,
originY: offsetRef.current.y,
}
}
if (shouldCloseFromBackdrop) {
closeViewer()
}
}, [closeViewer])
useLayoutEffect(() => {
if (!open) return
if (fitOnOpen && !fitContentKey) return
let frame = 0
let attempt = 0
const maxAttempts = 16
const scheduleFit = () => {
frame = requestAnimationFrame(() => {
const content = contentRef.current
const hadSize = fitContentSize ?? (content ? measureContentSize(content) : null)
applyFitScale()
attempt += 1
if (!hadSize && attempt < maxAttempts) {
scheduleFit()
}
})
}
scheduleFit()
const retry = window.setTimeout(scheduleFit, 50)
const lateRetry = window.setTimeout(scheduleFit, 200)
return () => {
cancelAnimationFrame(frame)
window.clearTimeout(retry)
window.clearTimeout(lateRetry)
}
}, [fitContentKey, fitContentSize, fitOnOpen, open, applyFitScale])
useLayoutEffect(() => {
if (!open) return undefined
const toolbar = toolbarRef.current
if (!toolbar) return undefined
const apply = () => {
const next = toolbar.getBoundingClientRect().height
setToolbarHeight((current) => (Math.abs(current - next) < 0.5 ? current : next))
}
apply()
window.addEventListener('resize', apply)
if (typeof ResizeObserver === 'undefined') {
return () => window.removeEventListener('resize', apply)
}
const resize = new ResizeObserver(apply)
resize.observe(toolbar)
return () => {
resize.disconnect()
window.removeEventListener('resize', apply)
}
}, [open])
useEffect(() => {
if (!open) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
closeViewer()
}
if (event.key === '0') {
resetView()
}
if (event.key === '+' || event.key === '=') {
zoomBy(SCALE_STEP)
}
if (event.key === '-') {
zoomBy(-SCALE_STEP)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [closeViewer, open, resetView, zoomBy])
if (!open) return null
const baseScale = baseScaleRef.current
const zoomLabel = baseScale > 0
? `${Math.round((scale / baseScale) * 100)}%`
: `${Math.round(scale * 100)}%`
const minInteractiveScale = Math.min(MIN_SCALE, baseScale)
return (
<div
className="fixed inset-0 z-50 h-[100dvh] w-full bg-black text-white"
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
>
<div
ref={viewportRef}
className="absolute inset-x-0 bottom-0 cursor-grab touch-none overflow-hidden active:cursor-grabbing"
style={{ top: `${toolbarHeight}px` }}
onWheel={handleWheel}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerUp}
onDoubleClick={resetView}
>
<div
ref={contentRef}
className="absolute left-1/2 top-1/2 select-none"
style={{
transform: `translate(calc(-50% + ${offset.x}px), calc(-50% + ${offset.y}px)) scale(${scale})`,
transformOrigin: 'center center',
}}
>
{children}
</div>
</div>
<div
ref={toolbarRef}
className="pointer-events-none absolute inset-x-0 top-0 z-10 pt-[env(safe-area-inset-top,0px)]"
onPointerDown={(event) => event.stopPropagation()}
>
<div className="pointer-events-auto flex items-center gap-2 border-b border-white/10 bg-black/70 px-3 py-2 backdrop-blur-sm">
<div className="min-w-0 flex-1 truncate text-sm font-medium">{title ?? ariaLabel}</div>
<button
type="button"
onClick={() => zoomBy(-SCALE_STEP)}
className="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20 disabled:opacity-40"
disabled={scale <= minInteractiveScale}
title="Zoom out"
>
</button>
<button
type="button"
onClick={resetView}
className="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20"
title="Fit to screen"
>
{zoomLabel}
</button>
<button
type="button"
onClick={() => zoomBy(SCALE_STEP)}
className="rounded bg-white/10 px-3 py-1 text-sm hover:bg-white/20 disabled:opacity-40"
disabled={scale >= MAX_SCALE}
title="Zoom in"
>
+
</button>
<button
type="button"
onClick={closeViewer}
className="flex h-8 w-8 items-center justify-center rounded bg-white/10 hover:bg-white/20"
title="Close"
>
<CloseIcon className="h-4 w-4" />
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,101 @@
import type React from 'react'
import { describe, expect, it } from 'vitest'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram'
import { I18nProvider } from '@/lib/i18n-context'
function installSvgBBoxPolyfill() {
const bbox = () => ({
x: 0,
y: 0,
width: 120,
height: 24,
top: 0,
left: 0,
right: 120,
bottom: 24,
toJSON() {
return {}
},
})
for (const proto of [Element.prototype, HTMLElement.prototype, SVGElement.prototype]) {
if (proto && !('getBBox' in proto)) {
Object.defineProperty(proto, 'getBBox', {
configurable: true,
value: bbox,
})
}
}
}
const sequenceDiagram = `sequenceDiagram
participant U as Operator
participant C as Chat
participant M as Mermaid
U->>C: Send message
C->>M: Render SVG
U->>M: Click diagram
M-->>U: Lightbox + zoom`
const defaultComponents = {
Pre: (props: React.ComponentProps<'pre'>) => <pre {...props} />,
Code: (props: React.ComponentProps<'code'>) => <code {...props} />,
}
async function expectLightboxShowsDiagram(code: string) {
installSvgBBoxPolyfill()
render(
<I18nProvider>
<MermaidDiagram
code={code}
language="mermaid"
components={defaultComponents}
/>
</I18nProvider>,
)
await waitFor(
() => {
expect(document.querySelector('[data-mermaid-diagram][data-rendered="true"] svg')).toBeTruthy()
},
{ timeout: 10000 },
)
fireEvent.click(document.querySelector('[data-mermaid-diagram][data-rendered="true"]') as HTMLButtonElement)
await waitFor(
() => {
const dialog = screen.getByRole('dialog', { name: 'Diagram' })
const host = dialog.querySelector('[data-mermaid-lightbox]')
expect(host).toBeTruthy()
const lightboxSvg = host?.shadowRoot?.querySelector('svg')
expect(lightboxSvg).toBeTruthy()
expect(lightboxSvg?.querySelector('path, line, rect')).toBeTruthy()
if (code.includes('sequenceDiagram')) {
const actorRects = lightboxSvg?.querySelectorAll('rect.actor, g.actor rect, rect').length ?? 0
expect(actorRects).toBeGreaterThanOrEqual(3)
}
},
{ timeout: 10000 },
)
}
describe('MermaidDiagram live render', () => {
it(
'renders real mermaid source to svg in jsdom',
async () => {
await expectLightboxShowsDiagram('flowchart LR\n Hub --> WebUI\n WebUI --> SVG')
},
20_000,
)
it(
'renders sequence diagrams in the lightbox',
async () => {
await expectLightboxShowsDiagram(sequenceDiagram)
},
20_000,
)
})
@@ -1,5 +1,7 @@
import type { ComponentProps } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { I18nProvider } from '@/lib/i18n-context'
const mermaidMocks = vi.hoisted(() => ({
initializeMock: vi.fn(),
@@ -20,16 +22,16 @@ vi.mock('mermaid', () => ({
import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram'
import { MARKDOWN_COMPONENTS_BY_LANGUAGE } from '@/components/assistant-ui/markdown-text'
function renderMermaid(code: string) {
const defaultComponents = {
Pre: (props: ComponentProps<'pre'>) => <pre {...props} />,
Code: (props: ComponentProps<'code'>) => <code {...props} />,
}
function renderDiagram(props: ComponentProps<typeof MermaidDiagram>) {
return render(
<MermaidDiagram
code={code}
language="mermaid"
components={{
Pre: (props) => <pre {...props} />,
Code: (props) => <code {...props} />,
}}
/>
<I18nProvider>
<MermaidDiagram {...props} />
</I18nProvider>,
)
}
@@ -41,7 +43,7 @@ describe('MermaidDiagram', () => {
mermaidMocks.parseMock.mockResolvedValue({ diagramType: 'flowchart-v2' })
mermaidMocks.renderMock.mockReset()
mermaidMocks.renderMock.mockResolvedValue({
svg: '<svg data-testid="mock-mermaid"></svg>'
svg: '<svg data-testid="mock-mermaid"></svg>',
})
})
@@ -51,7 +53,11 @@ describe('MermaidDiagram', () => {
})
it('is wired into the shared markdown language overrides and renders svg output', async () => {
renderMermaid('graph TD\nA --> B')
renderDiagram({
code: 'graph TD\nA --> B',
language: 'mermaid',
components: defaultComponents,
})
await waitFor(() => {
const diagram = document.querySelector('[data-mermaid-diagram][data-rendered="true"]')
@@ -73,7 +79,11 @@ describe('MermaidDiagram', () => {
document.documentElement.dataset.theme = 'dark'
mermaidMocks.parseMock.mockResolvedValueOnce(false)
renderMermaid('graph TD\nA --')
renderDiagram({
code: 'graph TD\nA --',
language: 'mermaid',
components: defaultComponents,
})
await waitFor(() => {
const fallback = document.querySelector('.aui-mermaid-fallback')
@@ -90,7 +100,11 @@ describe('MermaidDiagram', () => {
mermaidMocks.renderMock.mockRejectedValueOnce(new Error('render failed'))
const code = 'gantt\ndateFormat YYYY-MM-DD\nsection A\nTask :a, 2024-01-01'
renderMermaid(code)
renderDiagram({
code,
language: 'mermaid',
components: defaultComponents,
})
await waitFor(() => {
const fallback = document.querySelector('.aui-mermaid-fallback')
@@ -104,4 +118,46 @@ describe('MermaidDiagram', () => {
}))
})
it('opens a zoomable lightbox when the rendered diagram is clicked', async () => {
renderDiagram({
code: 'graph TD\nA --> B',
language: 'mermaid',
components: defaultComponents,
})
await waitFor(() => {
expect(document.querySelector('[data-mermaid-diagram][data-rendered="true"]')).toBeTruthy()
})
fireEvent.click(document.querySelector('[data-mermaid-diagram][data-rendered="true"]') as HTMLButtonElement)
await waitFor(() => {
const dialog = screen.getByRole('dialog', { name: 'Diagram' })
const host = dialog.querySelector('[data-mermaid-lightbox]')
expect(host?.shadowRoot?.querySelector('[data-testid="mock-mermaid"]')).toBeTruthy()
})
expect(mermaidMocks.renderMock).toHaveBeenCalledTimes(1)
expect(mermaidMocks.renderMock).toHaveBeenCalledWith(
expect.stringContaining('mermaid-'),
'graph TD\nA --> B',
)
expect(document.querySelector('[data-mermaid-lightbox]')).toBeTruthy()
})
it('does not expose a lightbox trigger when rendering fails', async () => {
mermaidMocks.renderMock.mockRejectedValue(new Error('syntax'))
renderDiagram({
code: 'not valid mermaid',
language: 'mermaid',
components: defaultComponents,
})
await waitFor(() => {
expect(document.querySelector('[data-mermaid-diagram][data-rendered="false"]')).toBeTruthy()
})
expect(screen.queryByRole('button', { name: 'Open diagram full screen' })).toBeNull()
})
})
@@ -1,6 +1,16 @@
import type { SyntaxHighlighterProps } from '@assistant-ui/react-markdown'
import { useEffect, useId, useState, type ComponentPropsWithoutRef } from 'react'
import {
useEffect,
useId,
useRef,
useState,
type ComponentPropsWithoutRef,
type Ref,
type SyntheticEvent,
} from 'react'
import { ZoomableLightbox } from '@/components/ZoomableLightbox'
import { cn } from '@/lib/utils'
import { useTranslation } from '@/lib/use-translation'
let initializedTheme: 'light' | 'dark' | null = null
let mermaidPromise: Promise<typeof import('mermaid').default> | null = null
@@ -43,6 +53,15 @@ async function ensureMermaid(theme: 'light' | 'dark') {
clusterBkg: '#2d3440',
clusterBorder: '#6d8fd6',
edgeLabelBackground: '#2a2f35',
actorBkg: '#323843',
actorBorder: '#6d8fd6',
actorTextColor: '#edf1f5',
signalColor: '#94a3b8',
labelBoxBkgColor: '#323843',
labelTextColor: '#edf1f5',
loopTextColor: '#edf1f5',
noteBkgColor: '#2d3440',
noteTextColor: '#edf1f5',
}
: {
primaryColor: '#f8fbff',
@@ -57,6 +76,15 @@ async function ensureMermaid(theme: 'light' | 'dark') {
clusterBkg: '#eef4ff',
clusterBorder: '#b8cdfd',
edgeLabelBackground: '#f5f6f7',
actorBkg: '#f8fbff',
actorBorder: '#b8cdfd',
actorTextColor: '#2d333b',
signalColor: '#94a3b8',
labelBoxBkgColor: '#f8fbff',
labelTextColor: '#2d333b',
loopTextColor: '#2d333b',
noteBkgColor: '#eef4ff',
noteTextColor: '#2d333b',
},
})
@@ -64,24 +92,176 @@ async function ensureMermaid(theme: 'light' | 'dark') {
return mermaid
}
export async function renderMermaidSvg(
code: string,
elementId: string,
theme: 'light' | 'dark',
): Promise<string | null> {
const mermaid = await ensureMermaid(theme)
const isValid = await mermaid.parse(code, { suppressErrors: true })
if (!isValid) return null
const result = await mermaid.render(elementId, code)
return result.svg
}
export function getMermaidSvgLayoutSize(svg: string): { width: number; height: number } | null {
const viewBoxMatch = svg.match(/\bviewBox=(['"])([^'"]+)\1/i)
if (!viewBoxMatch) return null
const parts = viewBoxMatch[2].trim().split(/[\s,]+/).map(Number)
if (parts.length < 4 || parts.some(Number.isNaN) || parts[2] <= 0 || parts[3] <= 0) return null
return { width: parts[2], height: parts[3] }
}
/** Mermaid often emits width="100%"; normalize before rasterizing for the lightbox. */
export function normalizeMermaidSvgForStandaloneDisplay(svg: string): string {
let result = svg
const viewBoxSize = getMermaidSvgLayoutSize(result)
if (!viewBoxSize) return result
const { width, height } = viewBoxSize
result = result.replace(/\swidth="100%"/gi, '')
result = result.replace(/\sheight="100%"/gi, '')
if (/\sstyle="/i.test(result)) {
result = result.replace(
/(<svg[^>]*?\sstyle=")([^"]*)(")/i,
(_full, prefix: string, style: string, suffix: string) => {
const cleaned = style
.replace(/(?:^|;)\s*max-width:\s*[^;]+/gi, '')
.replace(/(?:^|;)\s*width:\s*[^;]+/gi, '')
.replace(/(?:^|;)\s*height:\s*[^;]+/gi, '')
.replace(/^;+|;+$/g, '')
.replace(/;\s*;/g, ';')
.trim()
const nextStyle = cleaned
? `${cleaned};width:${width}px;height:${height}px`
: `width:${width}px;height:${height}px`
return `${prefix}${nextStyle}${suffix}`
},
)
} else {
result = result.replace(
/<svg/i,
`<svg width="${width}" height="${height}"`,
)
}
if (!/\bwidth="/i.test(result.split('>')[0] ?? '')) {
result = result.replace(/<svg/i, `<svg width="${width}" height="${height}"`)
}
return result
}
function MermaidFallback(props: ComponentPropsWithoutRef<'pre'> & { code: string }) {
const { code, className, ...rest } = props
return (
<pre
{...rest}
className={cn(
'aui-mermaid-fallback m-0 overflow-x-auto rounded-b-xl bg-[var(--app-code-bg)] p-4 text-sm text-[var(--app-fg)]',
props.className
className
)}
>
<code>{props.code}</code>
<code>{code}</code>
</pre>
)
}
function MermaidSvgContent(props: { svg: string; className?: string; hostRef?: Ref<HTMLDivElement> }) {
return (
<div
ref={props.hostRef}
className={cn('min-w-fit [&_svg]:mx-auto [&_svg]:h-auto [&_svg]:max-w-full', props.className)}
dangerouslySetInnerHTML={{ __html: props.svg }}
/>
)
}
/** Prefer viewBox layout; use getBBox when Mermaid pads the viewBox (e.g. gitGraph). */
export function resolveMermaidLightboxFitSize(
svgElement: SVGSVGElement | null,
svgString: string,
): { width: number; height: number } | null {
const fromViewBox = getMermaidSvgLayoutSize(svgString)
if (!svgElement) return fromViewBox
try {
const bbox = svgElement.getBBox()
if (bbox.width <= 0 || bbox.height <= 0) return fromViewBox
if (!fromViewBox) return { width: bbox.width, height: bbox.height }
const viewBoxArea = fromViewBox.width * fromViewBox.height
const bboxArea = bbox.width * bbox.height
if (viewBoxArea > bboxArea * 2) {
return { width: bbox.width, height: bbox.height }
}
} catch {
// getBBox unavailable (some test environments)
}
return fromViewBox
}
/**
* Shadow root isolates duplicate mermaid ids from the inline diagram in the page.
*
* Mermaid emits `width="100%"` on every diagram. Inside a shadow root whose host
* has no explicit size, that collapses to zero in Chromium for most diagram types
* (only ones that ship pixel attrs - e.g. `journey` - happen to render). Strip
* the relative size and bake explicit pixels from the viewBox before injecting,
* and give the host an inline-block layout so it sizes to the SVG.
*/
function MermaidLightboxSvg(props: { svg: string }) {
const hostRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const host = hostRef.current
if (!host) return
const root = host.shadowRoot ?? host.attachShadow({ mode: 'open' })
const normalized = normalizeMermaidSvgForStandaloneDisplay(props.svg)
root.innerHTML = [
'<style>',
':host{display:inline-block;line-height:0}',
'svg{display:block;max-width:none;max-height:none}',
'</style>',
normalized,
].join('')
}, [props.svg])
return <div ref={hostRef} className="aui-mermaid-lightbox-host" data-mermaid-lightbox />
}
function readMermaidE2eCaseId(code: string): string | undefined {
return code.match(/<!--\s*mermaid-e2e:([\w-]+)\s*-->/i)?.[1]
}
export function MermaidDiagram(props: SyntaxHighlighterProps) {
const { t } = useTranslation()
const e2eCaseId = readMermaidE2eCaseId(props.code)
const [theme, setTheme] = useState<'light' | 'dark'>(() => resolveTheme())
const [renderError, setRenderError] = useState(false)
const [svg, setSvg] = useState<string | null>(null)
const [lightboxOpen, setLightboxOpen] = useState(false)
const [lightboxFitSize, setLightboxFitSize] = useState<{ width: number; height: number } | null>(null)
const inlineHostRef = useRef<HTMLDivElement>(null)
const id = useId().replace(/:/g, '-')
const openLabel = t('mermaid.openFullscreen')
const viewerLabel = t('mermaid.viewerTitle')
const stopEvent = (event: SyntheticEvent) => {
event.stopPropagation()
}
const openLightbox = (event: SyntheticEvent) => {
event.preventDefault()
event.stopPropagation()
if (!svg) return
const inlineSvg = inlineHostRef.current?.querySelector('svg') ?? null
setLightboxFitSize(resolveMermaidLightboxFitSize(inlineSvg, svg))
setLightboxOpen(true)
}
useEffect(() => {
if (typeof document === 'undefined') return undefined
@@ -104,18 +284,14 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) {
const render = async () => {
try {
const mermaid = await ensureMermaid(theme)
const isValid = await mermaid.parse(props.code, { suppressErrors: true })
const nextSvg = await renderMermaidSvg(props.code, `mermaid-${id}`, theme)
if (cancelled) return
if (!isValid) {
if (!nextSvg) {
setSvg(null)
setRenderError(true)
return
}
const result = await mermaid.render(`mermaid-${id}`, props.code)
if (cancelled) return
setSvg(result.svg)
setSvg(nextSvg)
setRenderError(false)
} catch {
if (cancelled) return
@@ -131,20 +307,43 @@ export function MermaidDiagram(props: SyntaxHighlighterProps) {
}
}, [id, props.code, theme])
const lightboxLayoutSize = lightboxFitSize ?? (svg ? getMermaidSvgLayoutSize(svg) : null)
if (renderError || !svg) {
return <MermaidFallback code={props.code} data-mermaid-diagram data-rendered="false" />
}
return (
<div
data-mermaid-diagram
data-rendered="true"
className="aui-mermaid-diagram overflow-x-auto rounded-b-xl bg-[var(--app-code-bg)] px-4 py-3"
>
<div
className="min-w-fit [&_svg]:mx-auto [&_svg]:h-auto [&_svg]:max-w-full"
dangerouslySetInnerHTML={{ __html: svg }}
/>
</div>
<>
<button
type="button"
aria-label={openLabel}
title={openLabel}
onPointerDown={stopEvent}
onMouseDown={stopEvent}
onTouchStart={stopEvent}
onClick={openLightbox}
className="aui-mermaid-diagram w-full cursor-zoom-in overflow-x-auto rounded-b-xl bg-[var(--app-code-bg)] px-4 py-3 text-left focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--app-link)]"
data-mermaid-diagram
data-rendered="true"
data-mermaid-e2e-case={e2eCaseId}
data-mermaid-source={encodeURIComponent(props.code)}
>
<MermaidSvgContent svg={svg} hostRef={inlineHostRef} />
</button>
<ZoomableLightbox
open={lightboxOpen}
onClose={() => setLightboxOpen(false)}
title={viewerLabel}
ariaLabel={viewerLabel}
fitContentKey={lightboxOpen ? svg : null}
fitContentSize={lightboxLayoutSize}
>
<div className="rounded-lg bg-[var(--app-code-bg)] px-3 py-3">
<MermaidLightboxSvg svg={svg} />
</div>
</ZoomableLightbox>
</>
)
}
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import {
getMermaidSvgLayoutSize,
normalizeMermaidSvgForStandaloneDisplay,
} from '@/components/assistant-ui/mermaid-diagram'
describe('getMermaidSvgLayoutSize', () => {
it('reads simple unsigned viewBox', () => {
expect(getMermaidSvgLayoutSize('<svg viewBox="0 0 200 80"></svg>')).toEqual({ width: 200, height: 80 })
})
it('accepts signed origin values (negative offsets)', () => {
expect(getMermaidSvgLayoutSize('<svg viewBox="-8 -8 640 480"></svg>')).toEqual({ width: 640, height: 480 })
})
it('accepts single-quoted attribute and comma separators', () => {
expect(getMermaidSvgLayoutSize("<svg viewBox='0,0,300,150'></svg>")).toEqual({ width: 300, height: 150 })
})
it('rejects malformed viewBox (NaN, missing dim, zero size)', () => {
expect(getMermaidSvgLayoutSize('<svg viewBox="0 0 NaN 100"></svg>')).toBeNull()
expect(getMermaidSvgLayoutSize('<svg viewBox="0 0 200"></svg>')).toBeNull()
expect(getMermaidSvgLayoutSize('<svg viewBox="0 0 0 100"></svg>')).toBeNull()
})
it('returns null when no viewBox attribute exists', () => {
expect(getMermaidSvgLayoutSize('<svg width="100"></svg>')).toBeNull()
})
})
describe('normalizeMermaidSvgForStandaloneDisplay', () => {
it('replaces width="100%" with explicit viewBox dimensions', () => {
const svg = '<svg id="root" viewBox="0 0 200 80" width="100%" style="max-width: 320px;"><rect width="10"/></svg>'
const prepared = normalizeMermaidSvgForStandaloneDisplay(svg)
expect(prepared).not.toContain('width="100%"')
expect(prepared).toContain('width:200px')
expect(prepared).toContain('height:80px')
})
it('normalizes width/height for SVGs with negative viewBox origins', () => {
const svg = '<svg viewBox="-50 -50 800 600" width="100%" height="100%"><g/></svg>'
const prepared = normalizeMermaidSvgForStandaloneDisplay(svg)
expect(prepared).not.toContain('width="100%"')
expect(prepared).toContain('width="800"')
expect(prepared).toContain('height="600"')
})
})
+96
View File
@@ -0,0 +1,96 @@
/** Minimal valid sources for Playwright lightbox coverage (one case per diagram kind). */
export const MERMAID_LIGHTBOX_CASES = {
flowchart: `flowchart LR
Hub --> WebUI
WebUI --> Lightbox`,
sequence: `sequenceDiagram
participant U as Operator
participant C as Chat
participant M as Mermaid
U->>C: Send message
C->>M: Render SVG
M-->>U: Lightbox`,
class: `classDiagram
Animal <|-- Duck
Animal : +int age
Duck : +swim()`,
state: `stateDiagram-v2
[*] --> Still
Still --> Moving
Moving --> Still`,
er: `erDiagram
CUSTOMER ||--o{ ORDER : places
ORDER ||--|{ LINE-ITEM : contains`,
journey: `journey
title Checkout
section Browse
Open site: 5: User
Add item: 3: User`,
gantt: `gantt
title Plan
dateFormat YYYY-MM-DD
section Build
Ship feature :2024-06-01, 3d`,
pie: `pie title Pets
"Dogs" : 386
"Cats" : 214`,
quadrant: `quadrantChart
title Reach and engagement
x-axis Low Reach --> High Reach
y-axis Low Engagement --> High Engagement
quadrant-1 We should expand
Product A: [0.3, 0.6]
Product B: [0.45, 0.23]`,
requirement: `requirementDiagram
requirement test_req {
id: 1
text: the tested requirement.
risk: high
verifymethod: test
}`,
gitGraph: `gitGraph
commit
branch develop
checkout develop
commit
checkout main
merge develop`,
c4: `C4Context
title System
Person(user, "User")
System(app, "Application")
Rel(user, app, "Uses")`,
mindmap: `mindmap
root((HAPI))
Chat
Hub
Web`,
timeline: `timeline
title History
2024 : Alpha
2025 : Beta`,
kanban: `kanban
title Board
column Todo
task1[Task 1]
column Done
task2[Task 2]`,
} as const
export type MermaidLightboxCaseId = keyof typeof MERMAID_LIGHTBOX_CASES
export const MERMAID_LIGHTBOX_CASE_IDS = Object.keys(MERMAID_LIGHTBOX_CASES) as MermaidLightboxCaseId[]
+39
View File
@@ -0,0 +1,39 @@
import { createRoot } from 'react-dom/client'
import { I18nProvider } from '@/lib/i18n-context'
import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram'
import {
MERMAID_LIGHTBOX_CASE_IDS,
MERMAID_LIGHTBOX_CASES,
type MermaidLightboxCaseId,
} from '@/dev/mermaid-lightbox-cases'
const root = document.getElementById('root')
if (!root) {
throw new Error('missing #root')
}
const params = new URLSearchParams(window.location.search)
const caseId = params.get('case') as MermaidLightboxCaseId | null
const code = caseId && caseId in MERMAID_LIGHTBOX_CASES
? MERMAID_LIGHTBOX_CASES[caseId]
: MERMAID_LIGHTBOX_CASES.flowchart
document.title = `Mermaid lightbox e2e: ${caseId ?? 'flowchart'}`
createRoot(root).render(
<I18nProvider>
<div data-mermaid-e2e-case={caseId ?? 'flowchart'}>
<MermaidDiagram
code={code}
language="mermaid"
components={{
Pre: (props) => <pre {...props} />,
Code: (props) => <code {...props} />,
}}
/>
</div>
</I18nProvider>,
)
// Expose case list for Playwright discovery without importing TS in Node.
;(window as Window & { __MERMAID_E2E_CASES__?: string[] }).__MERMAID_E2E_CASES__ = [...MERMAID_LIGHTBOX_CASE_IDS]
+21
View File
@@ -0,0 +1,21 @@
import { createRoot } from 'react-dom/client'
import { I18nProvider } from '@/lib/i18n-context'
import { MermaidDiagram } from '@/components/assistant-ui/mermaid-diagram'
const root = document.getElementById('root')
if (!root) {
throw new Error('missing #root')
}
createRoot(root).render(
<I18nProvider>
<MermaidDiagram
code={'flowchart LR\n Click --> Lightbox\n Lightbox --> Visible'}
language="mermaid"
components={{
Pre: (props) => <pre {...props} />,
Code: (props) => <code {...props} />,
}}
/>
</I18nProvider>,
)
+6
View File
@@ -196,6 +196,12 @@ export default {
'session.export.toast.success.body': 'Downloaded {filename}',
'session.export.toast.error.title': 'Export failed',
// Mermaid diagrams
'mermaid.openFullscreen': 'Open diagram full screen',
'mermaid.viewerTitle': 'Diagram',
'mermaid.loading': 'Loading diagram…',
'mermaid.renderError': 'Could not render diagram.',
// Common buttons
'button.cancel': 'Cancel',
'button.save': 'Save',
+6
View File
@@ -200,6 +200,12 @@ export default {
'session.export.toast.success.body': '已下载 {filename}',
'session.export.toast.error.title': '导出失败',
// Mermaid diagrams
'mermaid.openFullscreen': '全屏查看图表',
'mermaid.viewerTitle': '图表',
'mermaid.loading': '正在加载图表…',
'mermaid.renderError': '无法渲染图表。',
// Common buttons
'button.cancel': '取消',
'button.save': '保存',