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:
@@ -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
|
||||
|
||||
@@ -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 静态预览',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user