fix: keep Mermaid diagram edges reachable and add PNG export

Fix oversized diagram alignment in the large-diagram viewer and provide complete PNG downloads independent of zoom and pan. Cover the full window and shared native drag handling, with regression checks for both themes and narrow windows.

Validation: 58 focused tests passed, including 9 Electron scenarios and 10 PNG downloads; typecheck and lint passed. Full-suite runs did not complete.

Release note: Mermaid 大图放大后可浏览全部边缘,并可从弹窗导出完整 PNG 图片。
This commit is contained in:
mesalogo
2026-09-23 11:53:09 +08:00
parent be84534e77
commit 785b1ee468
14 changed files with 427 additions and 13 deletions
+2
View File
@@ -106,6 +106,8 @@ records are listed separately and do not introduce another feature status.
stash or discard. Managed SSH operations require Agent `0.11.23`.
- [x] **Rich responses**: Supports GitHub Flavored Markdown, LaTeX math,
constrained Mermaid diagrams, and static in-conversation HTML previews.
Mermaid's large-diagram viewer keeps all edges reachable when zoomed and
exports the complete diagram as PNG, independently of zoom and pan.
Complete HTML and HTML code blocks can be previewed in place after an Agent
reply finishes, with icon actions to reveal the source or open a full-screen
preview. The feature is on by default and can be disabled under Platform
+1 -1
View File
@@ -30,7 +30,7 @@
- [x] **HTTP OCR 与附件持久预览**:在“设置 / 文档解析”配置 HTTP PaddleOCR-VL,供聊天文档和知识库导入共用。保存原件与插图,提供正文/图片/详情预览、显式图片文字提取,以及选图加入指定会话草稿。普通图片默认走图片输入,发送与入队检查实际模型和 Runtime。Windows、直连视觉输入和共享 Linux Host 托管 OpenCode 已有真实验证;部署高级 OCR 模型及其他 Runtime 组合需分别测试。本机推理监控只管理本地服务,不新增应用中心入口,详见[范围与证据](./docs/features/document-processing/README.md)。
- [x] **工作区单文件差异**:变更文件可分别查看已暂存和未暂存差异,覆盖删除、重命名和未跟踪文件;刷新会重读当前差异、当前浏览目录和已展开目录,超过 50 项变更可继续加载。
- [x] **工作区文件与 Git 管理**:支持文件和目录的新建、重命名、移动、删除、属性查看,以及本地/远程分支搜索与切换、新建分支、显式 Fetch、提交历史和单文件提交差异。工具按文件/Git 视图分组,分支搜索使用统一紧凑表单,子目录保留文字导航而根目录不再显示孤立图标。提交历史贴底,首次展开占 Git 内容区一半,支持调整比例、独立滚动和统一刷新;切换项目自动检测 Git,不沿用其他项目状态。移动限于工作区内,删除须确认,分支冲突不自动 stash 或丢弃改动;托管 SSH 操作需要 Agent `0.11.23`。
- [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式、受控 Mermaid 图表和会话内 HTML 静态预览。Agent 完成回复后,完整 HTML 与 HTML 代码块可在原位置预览,并通过图标按钮查看源码或打开全屏预览;该能力默认开启,可在“平台功能 / 通用设置”关闭,预览不执行脚本、联网、表单提交或窗口操作。
- [x] **富文本回答**:支持 GitHub Flavored Markdown、LaTeX 数学公式、受控 Mermaid 图表和会话内 HTML 静态预览。Mermaid 大图放大后可滚动到各个边缘,并可导出完整 PNG,不受当前缩放和拖动位置影响。Agent 完成回复后,完整 HTML 与 HTML 代码块可在原位置预览,并通过图标按钮查看源码或打开全屏预览;该能力默认开启,可在“平台功能 / 通用设置”关闭,预览不执行脚本、联网、表单提交或窗口操作。
- [x] **AI 回复与完整会话复制**:已完成的 AI 回复可从消息底部复制不含推理、工具日志和引用元数据的 Markdown 原文;完整会话复制复用同一条经过 Preload/Main 校验的剪贴板路径。
- [x] **助手工作栏、多终端与可调布局**:工作栏开关位于明暗主题按钮旁;右侧工作栏使用持久“+”能力目录和应用 Tab,任务中心与工作区为不可关闭的单实例,浏览器可打开多个独立实例。任务中心可切换当前项目和所有项目,范围随布局保存;无项目任务在所有项目中标注“未绑定项目”。用户可为当前本机或托管 SSH 项目打开多个独立终端,获得有界输出、调整尺寸、结束与显式重连。关闭终端 Tab 会结束 Shell,应用重启只恢复已结束的 Tab 描述,不自动重启 Shell。主侧栏和魔法笔记编辑器的 AI 评论栏支持鼠标与键盘调宽;AI 评论栏记住显示状态及宽度,窄窗口下移至编辑器底部。
- [ ] **项目 Agent Space**(规划中):在 Project 中统一角色、知识、Skills/MCP、模型、审批策略、预算和超时,并支持模板复用。
+3
View File
@@ -642,6 +642,9 @@ GoodBuddy 是可调整窗口大小的桌面应用。响应式设计优先保证
全屏预览遵循统一 Modal 的背景隔离、初始焦点、Tab 循环、Escape 关闭和焦点恢复规则,
并沿用同一套清理与 Sandbox/CSP 边界。关闭平台功能后必须显示 HTML 源码,不能隐藏内容
或保留旧预览。
- Mermaid 大图支持缩放和拖动;图形超过画布时,四个边缘都必须能通过滚动到达,不能因居中而裁掉顶部或左侧。
弹窗工具栏提供“导出 PNG”,保存完整图形,不受当前缩放和滚动位置影响;背景使用当前主题的画布底色。
导出按原图四周留出 `16px`,以 2 倍分辨率绘制,任一边最多 `4096px`。导出期间禁用重复点击,失败在按钮旁提示重试。
- 本地会话的更多操作提供“在新会话中继续”。创建前必须先持久化当前稳定消息;有活动运行
或待发送内容时不可创建。新会话复制聊天内容与当前 Runtime/知识检索选择,重置压缩状态,
使用新的会话和消息 ID,且不复制 Task、队列、运行、记忆或成果归属。
+1 -1
View File
@@ -20,7 +20,7 @@
[UI 设计系统](../../UI-DESIGN.md)。
**防复发**:`WorkspacePrimitives.test.tsx` 的
"releases the native drag region for every full-window overlay" 从 `styles.css` 自动发现
"releases the native drag region for every full-window overlay" 从 Renderer 顶层全部 CSS 文件自动发现
全部 `position: fixed` 遮罩并逐个校验,新增遗漏会直接失败。
**历史**:`e9679fe`、`ca4a58c` 各修了一部分被人工点到的弹窗,同一批改动又新增了两个未覆盖
@@ -464,6 +464,42 @@ flowchart LR
).toBe(false)
})
it('releases SVG URLs after a failed PNG export and allows retry', async () => {
const createObjectURL = vi.fn(() => 'blob:mermaid-export')
const revokeObjectURL = vi.fn()
let rejectDecode: (reason: Error) => void = () => undefined
const decode = vi.fn(() => new Promise<void>((_resolve, reject) => {
rejectDecode = reject
}))
vi.stubGlobal('URL', class extends URL {
static createObjectURL = createObjectURL
static revokeObjectURL = revokeObjectURL
})
vi.stubGlobal('Image', class {
src = ''
decode = decode
})
try {
render(<MarkdownRenderer>{'```mermaid\nflowchart LR\nA --> B\n```'}</MarkdownRenderer>)
fireEvent.click(await screen.findByRole('button', { name: '打开大图' }))
const button = screen.getByRole('button', { name: '导出 PNG' })
fireEvent.click(button)
expect(button).toBeDisabled()
expect(button).toHaveAttribute('aria-busy', 'true')
await act(async () => rejectDecode(new Error('Decode failed')))
expect(button).toBeEnabled()
expect(button).toHaveAccessibleDescription('导出失败,请点击“导出 PNG”重试。')
expect(revokeObjectURL).toHaveBeenCalledWith('blob:mermaid-export')
fireEvent.click(button)
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
expect(decode).toHaveBeenCalledTimes(2)
await act(async () => rejectDecode(new Error('Decode failed again')))
expect(revokeObjectURL).toHaveBeenCalledTimes(2)
} finally {
vi.unstubAllGlobals()
}
})
it('closes Mermaid controls when streamed source changes', async () => {
const { rerender } = render(
<MarkdownRenderer>{`\`\`\`mermaid
+83
View File
@@ -1,6 +1,7 @@
import DOMPurify from 'dompurify'
import {
Code2,
Download,
Maximize2,
RotateCcw,
X,
@@ -247,6 +248,55 @@ function clampViewerZoom(zoom: number): number {
return Math.min(MAX_VIEWER_ZOOM, Math.max(MIN_VIEWER_ZOOM, zoom))
}
async function downloadMermaidPng(svg: string, surface: HTMLElement): Promise<void> {
const root = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement
const width = Number.parseFloat(root.getAttribute('width') ?? '')
const height = Number.parseFloat(root.getAttribute('height') ?? '')
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
throw new Error('Invalid diagram dimensions')
}
const styles = getComputedStyle(surface)
root.style.color = styles.color
const sourceUrl = URL.createObjectURL(new Blob(
[new XMLSerializer().serializeToString(root)],
{ type: 'image/svg+xml;charset=utf-8' }
))
try {
const image = new Image()
image.src = sourceUrl
await image.decode()
const padding = 16
const scale = Math.min(2, MAX_MERMAID_DIMENSION / (width + padding * 2),
MAX_MERMAID_DIMENSION / (height + padding * 2))
const canvas = document.createElement('canvas')
canvas.width = Math.ceil((width + padding * 2) * scale)
canvas.height = Math.ceil((height + padding * 2) * scale)
const context = canvas.getContext('2d')
if (!context) throw new Error('Canvas is unavailable')
context.fillStyle = styles.backgroundColor
context.fillRect(0, 0, canvas.width, canvas.height)
context.scale(scale, scale)
context.drawImage(image, padding, padding, width, height)
const png = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error('PNG encoding failed')), 'image/png')
})
const downloadUrl = URL.createObjectURL(png)
const link = document.createElement('a')
link.href = downloadUrl
link.download = 'mermaid-diagram.png'
document.body.append(link)
try {
link.click()
} finally {
link.remove()
// Keep the URL alive until Chromium has started the download.
window.setTimeout(() => URL.revokeObjectURL(downloadUrl), 1_000)
}
} finally {
URL.revokeObjectURL(sourceUrl)
}
}
function MermaidViewer({
onClose,
svg
@@ -254,7 +304,9 @@ function MermaidViewer({
const { i18n, t } = useTranslation('app')
const titleId = useId()
const hintId = useId()
const exportErrorId = useId()
const dialogRef = useRef<HTMLElement>(null)
const canvasRef = useRef<HTMLDivElement>(null)
const closeRef = useRef<HTMLButtonElement>(null)
const dragRef = useRef<{
pointerId: number
@@ -265,6 +317,8 @@ function MermaidViewer({
} | undefined>(undefined)
const [dragging, setDragging] = useState(false)
const [zoom, setZoom] = useState(1)
const [exporting, setExporting] = useState(false)
const [exportFailed, setExportFailed] = useState(false)
const pendingWheelZoomRef = useRef(0)
const zoomFrameRef = useRef<number | undefined>(undefined)
const percentFormatter = useMemo(
@@ -387,6 +441,29 @@ function MermaidViewer({
>
<RotateCcw aria-hidden="true" size={16} />
</button>
<button
aria-label={t('markdown.mermaidExportPng')}
aria-busy={exporting}
aria-describedby={exportFailed ? exportErrorId : undefined}
className="icon-button"
disabled={exporting}
onClick={async () => {
if (!canvasRef.current || exporting) return
setExporting(true)
setExportFailed(false)
try {
await downloadMermaidPng(svg, canvasRef.current)
} catch {
setExportFailed(true)
} finally {
setExporting(false)
}
}}
title={t(exporting ? 'markdown.mermaidExporting' : 'markdown.mermaidExportPng')}
type="button"
>
<Download aria-hidden="true" size={17} />
</button>
<button
aria-label={t('markdown.mermaidCloseViewer')}
className="icon-button"
@@ -398,6 +475,11 @@ function MermaidViewer({
<X aria-hidden="true" size={18} />
</button>
</div>
{exportFailed && (
<small className="mermaid-viewer__export-error" id={exportErrorId} role="alert">
{t('markdown.mermaidExportError')}
</small>
)}
</header>
<div
aria-label={t('markdown.mermaidViewerCanvas')}
@@ -444,6 +526,7 @@ function MermaidViewer({
)
}}
role="region"
ref={canvasRef}
tabIndex={0}
>
<div
@@ -6,7 +6,7 @@ import {
render,
screen
} from '@testing-library/react'
import { readFileSync } from 'node:fs'
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
@@ -164,7 +164,10 @@ describe('WorkspacePrimitives', () => {
// region, otherwise the native titlebar keeps the pointer and its controls
// stop responding to hover and clicks.
const overlays = [
...stylesheet.matchAll(
...readdirSync(join(process.cwd(), 'src', 'renderer', 'src'))
.filter((name) => name.endsWith('.css'))
.map((name) => readFileSync(join(process.cwd(), 'src', 'renderer', 'src', name), 'utf8'))
.join('\n').matchAll(
/^(\.[\w-]*(?:backdrop|modal))\s*\{([^}]*)\}/gmu
)
].filter(([, , body]) => /position:\s*fixed/u.test(body!))
@@ -787,6 +787,9 @@ export const app = {
mermaidResetZoom: 'Reset zoom',
mermaidZoomLevel: 'Current zoom level',
mermaidCloseViewer: 'Close large Mermaid diagram',
mermaidExportPng: 'Export PNG',
mermaidExporting: 'Exporting PNG…',
mermaidExportError: 'Export failed. Click Export PNG to retry.',
htmlPreview: 'HTML preview',
htmlPreviewNotice:
'Static safe preview with scripts and network access disabled',
@@ -731,6 +731,9 @@ export const app = {
mermaidResetZoom: '重置缩放',
mermaidZoomLevel: '当前缩放比例',
mermaidCloseViewer: '关闭 Mermaid 大图',
mermaidExportPng: '导出 PNG',
mermaidExporting: '正在导出 PNG…',
mermaidExportError: '导出失败,请点击“导出 PNG”重试。',
htmlPreview: 'HTML 预览',
htmlPreviewNotice: '静态安全预览,脚本和网络已禁用',
htmlPreviewFrame: 'Agent 回复 HTML 静态预览',
+14 -9
View File
@@ -84,18 +84,19 @@
.mermaid-viewer-backdrop {
position: fixed;
z-index: 120;
z-index: var(--z-dialog);
display: grid;
padding: var(--space-4);
background: var(--overlay-backdrop);
inset: 38px 0 0;
inset: 0;
place-items: center;
}
.mermaid-viewer {
display: grid;
width: min(1280px, 100%);
height: min(860px, calc(100vh - 70px));
height: min(860px, 100%);
min-width: 0;
min-height: 0;
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
@@ -108,6 +109,7 @@
display: flex;
min-width: 0;
align-items: center;
flex-wrap: wrap;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border-subtle);
justify-content: space-between;
@@ -147,7 +149,7 @@
}
.mermaid-viewer__canvas {
display: grid;
display: flex;
min-width: 0;
min-height: 0;
padding: var(--space-6);
@@ -155,8 +157,8 @@
background: var(--surface-subtle);
cursor: grab;
overscroll-behavior: contain;
place-items: center;
touch-action: none;
border-radius: 0 0 var(--radius-card) var(--radius-card);
}
.mermaid-viewer__canvas--dragging {
@@ -171,12 +173,19 @@
.mermaid-viewer__diagram {
display: grid;
flex: 0 0 auto;
margin: auto;
min-width: max-content;
min-height: max-content;
transform-origin: center;
place-items: center;
}
.mermaid-viewer__header .mermaid-viewer__export-error {
flex-basis: 100%;
color: var(--danger-strong);
}
.mermaid-viewer__diagram > svg {
display: block;
max-width: none !important;
@@ -189,10 +198,6 @@
padding: var(--space-2);
}
.mermaid-viewer {
height: calc(100vh - 54px);
}
.mermaid-viewer__header {
align-items: flex-start;
flex-direction: column;
+1
View File
@@ -3876,6 +3876,7 @@ textarea:focus-visible {
.remote-directory-picker-backdrop,
.html-preview-viewer-backdrop,
.image-viewer-backdrop,
.mermaid-viewer-backdrop,
.release-notes-backdrop,
.settings-backdrop,
.channel-qr-backdrop,
+59
View File
@@ -0,0 +1,59 @@
// @vitest-environment node
import { spawn } from 'node:child_process'
import { copyFile, mkdtemp, rm } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import react from '@vitejs/plugin-react'
import { createServer } from 'vite'
import { expect, it } from 'vitest'
it('keeps real Mermaid modal edges reachable and downloads complete PNGs', async () => {
const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-mermaid-'))
const server = await createServer({
configFile: false,
root: resolve('.'),
cacheDir: join(directory, 'vite'),
plugins: [react(), {
name: 'mermaid-regression',
configureServer(server) {
server.middlewares.use('/mermaid.html', async (_request, response) => {
response.setHeader('Content-Type', 'text/html; charset=utf-8')
response.end(await server.transformIndexHtml('/mermaid.html',
'<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="root"></div><script type="module" src="/tests/support/mermaid-viewer-regression.tsx"></script></body></html>'))
})
}
}],
server: { host: '127.0.0.1', port: 0, watch: null }
})
try {
await server.listen()
const env: NodeJS.ProcessEnv = { ...process.env,
GOODBUDDY_MERMAID_URL: server.resolvedUrls!.local[0] + 'mermaid.html',
GOODBUDDY_MERMAID_DIRECTORY: directory }
delete env.ELECTRON_RUN_AS_NODE
const driver = join(directory, 'driver.mjs')
await copyFile(resolve('tests/support/mermaid-viewer-driver.mjs'), driver)
const child = spawn(createRequire(import.meta.url)('electron'), [driver], {
env, stdio: ['ignore', 'pipe', 'pipe']
})
let output = ''
child.stdout.on('data', data => { output += data })
child.stderr.on('data', data => { output += data })
const timeout = setTimeout(() => child.kill(), 240_000)
try {
const code = await new Promise((resolve, reject) => {
child.once('exit', resolve)
child.once('error', reject)
})
expect(code, output).toBe(0)
process.stdout.write(output.split(/\r?\n/u).filter(line => line.startsWith('Mermaid')).join('\n') + '\n')
} finally {
clearTimeout(timeout)
if (child.exitCode === null) child.kill()
}
} finally {
await server.close()
await rm(directory, { recursive: true, force: true })
}
}, 270_000)
+186
View File
@@ -0,0 +1,186 @@
import { app, BrowserWindow, nativeImage } from 'electron'
import assert from 'node:assert/strict'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { URLSearchParams } from 'node:url'
import process from 'node:process'
import console from 'node:console'
import { setTimeout } from 'node:timers/promises'
const directory = process.env.GOODBUDDY_MERMAID_DIRECTORY
const artifacts = process.env.GOODBUDDY_MERMAID_ARTIFACTS || directory
app.setPath('userData', join(directory, 'profile'))
app.commandLine.appendSwitch('force-device-scale-factor', '1')
app.whenReady().then(async () => {
await mkdir(artifacts, { recursive: true })
const win = new BrowserWindow({
show: false, width: 1440, height: 1000, useContentSize: true,
webPreferences: { sandbox: true, contextIsolation: true, nodeIntegration: false, backgroundThrottling: false }
})
const errors = []
win.webContents.on('console-message', event => {
if (event.level === 'error') errors.push(event.message)
})
const js = code => win.webContents.executeJavaScript(code)
const wait = async code => {
for (let attempt = 0; attempt < 600; attempt++) {
if (await js(code)) return
await setTimeout(25)
}
throw new Error(`Timeout: ${code}\n${errors.join('\n')}`)
}
// Hidden Chromium windows can throttle animation frames even with backgroundThrottling disabled.
const settle = () => setTimeout(25)
const click = async selector => {
const point = await js(`(() => {
const e = document.querySelector(${JSON.stringify(selector)});
if (!e || e.disabled) throw new Error('Missing/disabled control: ' + ${JSON.stringify(selector)});
const r = e.getBoundingClientRect(), x = r.x + r.width / 2, y = r.y + r.height / 2;
if (!e.contains(document.elementFromPoint(x, y))) throw new Error('Control not mouse reachable: ' + ${JSON.stringify(selector)});
return { x: Math.round(x), y: Math.round(y) };
})()`)
win.webContents.sendInputEvent({ type: 'mouseDown', ...point, button: 'left', clickCount: 1 })
win.webContents.sendInputEvent({ type: 'mouseUp', ...point, button: 'left', clickCount: 1 })
await settle()
}
const download = async (name, label, dimensions) => {
assert(await js(`!!document.querySelector('.mermaid-viewer button[aria-label="${label}"]')`), `Missing export button: ${label}`)
const path = join(artifacts, `${name}.png`)
let listener
let timer
const completed = new Promise((resolve, reject) => {
timer = globalThis.setTimeout(() => reject(new Error(`No completed PNG download: ${name}`)), 15_000)
listener = (_event, item) => {
try {
assert.match(item.getFilename(), /\.png$/iu)
assert.equal(item.getMimeType(), 'image/png')
item.setSavePath(path)
item.once('done', (_event, state) => {
if (state === 'completed') resolve()
else reject(new Error(`Download ${state}: ${name}`))
})
} catch (error) { reject(error) }
}
win.webContents.session.once('will-download', listener)
})
try {
await Promise.all([completed, click(`.mermaid-viewer button[aria-label="${label}"]`)])
} finally {
globalThis.clearTimeout(timer)
win.webContents.session.removeListener('will-download', listener)
}
const bytes = await readFile(path)
assert.equal(bytes.subarray(0, 8).toString('hex'), '89504e470d0a1a0a')
const image = nativeImage.createFromBuffer(bytes)
const { width, height } = image.getSize()
const scale = Math.min(2, 4096 / (dimensions.width + 32), 4096 / (dimensions.height + 32))
assert(Math.abs(width - (dimensions.width + 32) * scale) <= 1, `PNG width ${width}: ${JSON.stringify(dimensions)}`)
assert(Math.abs(height - (dimensions.height + 32) * scale) <= 1, `PNG height ${height}: ${JSON.stringify(dimensions)}`)
assert(width <= 4096 && height <= 4096)
// NativeImage decodes the actual saved PNG to BGRA, independent of the export canvas.
const bitmap = image.toBitmap()
const colors = { red: 0, green: 0, blue: 0 }
const bounds = { left: width, top: height, right: 0, bottom: 0 }
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const offset = (y * width + x) * 4
const [b, g, r, a] = bitmap.subarray(offset, offset + 4)
const color = a > 240 && (r > 220 && g < 35 && b < 35 ? 'red'
: g > 220 && r < 35 && b < 35 ? 'green'
: b > 220 && r < 35 && g < 35 ? 'blue' : undefined)
if (color) {
colors[color]++
bounds.left = Math.min(bounds.left, x)
bounds.top = Math.min(bounds.top, y)
bounds.right = Math.max(bounds.right, x)
bounds.bottom = Math.max(bounds.bottom, y)
}
}
}
assert(Object.values(colors).every(count => count > 30), `Missing start/middle/end content: ${JSON.stringify(colors)}`)
const padding = Math.floor(16 * scale) - 1
assert(bounds.left >= padding && bounds.top >= padding && bounds.right < width - padding && bounds.bottom < height - padding,
`Missing PNG padding: ${JSON.stringify({ bounds, padding, width, height })}`)
return { width, height, colors, bitmap }
}
const reports = []
const failures = []
const scenarios = ['light', 'dark'].flatMap(theme => [1440, 390].flatMap(width =>
['wide', 'tall'].map(shape => ({ theme, width, shape, locale: width === 390 ? 'zh-CN' : 'en-US' }))))
scenarios.push({ theme: 'light', width: 1440, shape: 'small', locale: 'en-US' })
for (const scenario of scenarios) {
const { theme, width, shape, locale } = scenario
const name = `${theme}-${width}-${shape}`
win.setContentSize(width, width === 390 ? 740 : 1000)
await win.loadURL(`${process.env.GOODBUDDY_MERMAID_URL}?${new URLSearchParams({ theme, shape, locale })}`)
await wait('!!document.querySelector(".mermaid-diagram[aria-busy=false] .mermaid-diagram__viewport > svg")')
await js('document.fonts.ready')
await click('.mermaid-diagram__actions button:last-child')
await wait('!!document.querySelector(".mermaid-viewer__diagram > svg")')
const dimensions = await js(`(() => {
const svg = document.querySelector('.mermaid-viewer__diagram > svg');
return { width: Number(svg.getAttribute('width')), height: Number(svg.getAttribute('height')), nodes: svg.querySelectorAll('.node').length,
themeFill: getComputedStyle(svg.querySelector('.node[id*="-N1-"] rect')).fill };
})()`)
assert.equal(dimensions.nodes, shape === 'small' ? 3 : 24, 'Must render the real Mermaid graph')
const label = locale === 'en-US' ? 'Export PNG' : '\u5bfc\u51fa PNG'
let before
if (shape === 'small') {
try { before = await download(`${name}-100`, label, dimensions) }
catch (error) { failures.push(`${name}-100: ${error.message}`) }
}
for (let step = 0; step < 8; step++) {
await click('.mermaid-viewer button:has(.lucide-zoom-in)')
await wait(`document.querySelector('.mermaid-viewer__zoom').textContent === '${125 + step * 25}%'`)
}
assert.equal(await js('document.querySelector(".mermaid-viewer__zoom").textContent'), '300%')
const corners = []
for (const [horizontal, vertical] of [[0, 0], [1, 0], [0, 1], [1, 1]]) {
await js(`(() => { const e = document.querySelector('.mermaid-viewer__canvas'); e.scrollLeft = ${horizontal} * e.scrollWidth; e.scrollTop = ${vertical} * e.scrollHeight; })()`)
await settle()
const corner = await js(`(() => {
const e = document.querySelector('.mermaid-viewer__canvas'), c = e.getBoundingClientRect();
const s = document.querySelector('.mermaid-viewer__diagram > svg').getBoundingClientRect();
const m = document.querySelector('.mermaid-viewer').getBoundingClientRect();
const b = document.querySelector('.mermaid-viewer-backdrop').getBoundingClientRect();
return { left: s.left - c.left - e.clientLeft, top: s.top - c.top - e.clientTop,
right: c.left + e.clientLeft + e.clientWidth - s.right, bottom: c.top + e.clientTop + e.clientHeight - s.bottom,
scrollX: e.scrollLeft, scrollY: e.scrollTop, overflowX: e.scrollWidth - e.clientWidth, overflowY: e.scrollHeight - e.clientHeight,
modalFits: m.left >= 0 && m.right <= innerWidth && m.top >= 0 && m.bottom <= innerHeight,
backdropFits: b.top === 0 && b.left === 0 && b.right === innerWidth && b.bottom === innerHeight };
})()`)
if (!corner.modalFits || !corner.backdropFits) failures.push(`${name}: modal/backdrop outside viewport ${JSON.stringify(corner)}`)
if (corner[horizontal ? 'right' : 'left'] < -1) failures.push(`${name}: unreachable horizontal edge ${JSON.stringify(corner)}`)
if (corner[vertical ? 'bottom' : 'top'] < -1) failures.push(`${name}: unreachable vertical edge ${JSON.stringify(corner)}`)
if (shape !== 'small') assert(corner[shape === 'wide' ? 'overflowX' : 'overflowY'] > 1000, 'Fixture must overflow substantially at 300%')
corners.push(corner)
await writeFile(join(artifacts, `${name}-corner-${horizontal}-${vertical}.png`), (await win.webContents.capturePage()).toPNG())
}
let png
try {
const result = await download(`${name}-300`, label, dimensions)
if (before) assert(result.bitmap.equals(before.bitmap), 'PNG content must not depend on modal zoom or scroll position')
png = { width: result.width, height: result.height, colors: result.colors }
} catch (error) {
failures.push(`${name}-300: ${error.message}`)
console.log(`Mermaid ${name}: ${error.message}`)
}
reports.push({ ...scenario, dimensions, corners, png })
await writeFile(join(artifacts, 'mermaid-measurements.json'), JSON.stringify({ reports, failures }, null, 2))
console.log(`Mermaid ${name}: measured four corners at 300%; ${png ? `downloaded ${png.width}x${png.height} PNG with start/middle/end content` : 'PNG FAILED'}`)
}
assert.deepEqual(errors, [], 'Renderer console errors')
for (const shape of ['wide', 'tall']) {
const fills = reports.filter(report => report.width === 1440 && report.shape === shape).map(report => report.dimensions.themeFill)
assert.notEqual(fills[0], fills[1], 'Real Mermaid rendering must follow the selected theme')
}
await writeFile(join(artifacts, 'mermaid-measurements.json'), JSON.stringify({ reports, failures }, null, 2))
assert.deepEqual(failures, [], 'Mermaid modal/download regression')
console.log(`Mermaid PASS: ${reports.length} scenarios; 10 real PNG downloads; artifacts ${artifacts}`)
win.destroy()
app.quit()
}).catch(error => {
console.error(error)
app.exit(1)
})
@@ -0,0 +1,30 @@
import { createRoot } from 'react-dom/client'
import { MermaidDiagram } from '../../src/renderer/src/MermaidDiagram'
import { UiLocaleProvider } from '../../src/renderer/src/i18n/UiLocaleProvider'
import '../../src/renderer/src/fonts'
import '../../src/renderer/src/styles.css'
const params = new URLSearchParams(location.search)
const shape = params.get('shape') ?? 'wide'
const count = shape === 'small' ? 3 : 24
const nodes = Array.from({ length: count }, (_, index) =>
`N${index}["${index === 0 ? 'START' : index === count - 1 ? 'FINISH' : `Step ${index}`} \u5b8c\u6574\u56fe\u8868"]`)
const source = [
`flowchart ${shape === 'tall' ? 'TB' : 'LR'}`,
nodes.join(' --> '),
'classDef first fill:#ff0000,stroke:#000000,color:#000000;',
'classDef middle fill:#00ff00,stroke:#000000,color:#000000;',
'classDef last fill:#0000ff,stroke:#000000,color:#ffffff;',
'class N0 first;',
`class N${Math.floor(count / 2)} middle;`,
`class N${count - 1} last;`
].join('\n')
document.documentElement.dataset.theme = params.get('theme') ?? 'light'
createRoot(document.getElementById('root')!).render(
<UiLocaleProvider initialPreference={params.get('locale') === 'en-US' ? 'en-US' : 'zh-CN'}>
<main className="markdown-content" style={{ padding: 16 }}>
<MermaidDiagram source={source} />
</main>
</UiLocaleProvider>
)