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
@@ -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,