feat: render interactive Mermaid diagrams

This commit is contained in:
lofyer
2026-08-13 00:30:47 +08:00
parent 86b63406c2
commit 40696d9ac7
13 changed files with 2016 additions and 27 deletions
+76
View File
@@ -4314,6 +4314,82 @@ describe('App', () => {
).not.toBeInTheDocument()
})
it('excludes ignored heartbeat suggestions from the navigation badge', async () => {
const heartbeatId = '00000000-0000-4000-8000-000000000701'
const cancelledTaskId = '00000000-0000-4000-8000-000000000801'
const pendingTaskId = '00000000-0000-4000-8000-000000000802'
vi.mocked(api.heartbeats.list).mockResolvedValue([
{
id: heartbeatId,
projectId,
name: '每日回顾',
timezone: 'Asia/Shanghai',
recurrence: { type: 'daily', localTime: '09:00' },
enabled: true,
lookbackHours: 24,
retentionDays: 30,
nextRunAt: '2026-08-05T01:00:00.000Z',
createdAt: '2026-08-01T00:00:00.000Z',
updatedAt: '2026-08-01T00:00:00.000Z'
}
])
vi.mocked(api.heartbeats.history).mockResolvedValue({
runs: [],
entries: [
{
id: '00000000-0000-4000-8000-000000000901',
configId: heartbeatId,
runId: '00000000-0000-4000-8000-000000000902',
scheduledFor: '2026-08-04T01:00:00.000Z',
summary: '建议处理两个后续行动。',
highlights: [],
proposedMemoryIds: [],
followUpTaskIds: [cancelledTaskId, pendingTaskId],
createdAt: '2026-08-04T01:00:00.000Z'
}
]
})
vi.mocked(api.tasks.list).mockResolvedValue([
{
id: cancelledTaskId,
projectId,
title: '已忽略建议',
instructions: '无需继续处理。',
origin: 'assistant',
status: 'cancelled',
createdAt: '2026-08-04T01:00:00.000Z'
},
{
id: pendingTaskId,
projectId,
title: '待处理建议',
instructions: '继续处理此建议。',
origin: 'assistant',
status: 'paused',
createdAt: '2026-08-04T01:00:00.000Z'
}
])
try {
render(<App />)
expect(
await screen.findByLabelText('1 条待处理建议')
).toBeInTheDocument()
expect(
screen.queryByLabelText('2 条待处理建议')
).not.toBeInTheDocument()
} finally {
cleanup()
vi.mocked(api.heartbeats.list).mockResolvedValue([])
vi.mocked(api.heartbeats.history).mockResolvedValue({
runs: [],
entries: []
})
vi.mocked(api.tasks.list).mockResolvedValue([])
}
})
it('shows retryable page-local knowledge errors without an empty-state flash', async () => {
vi.mocked(api.knowledge.getSnapshot).mockRejectedValueOnce(
new Error('知识数据库暂时不可用')
+3 -1
View File
@@ -2120,7 +2120,9 @@ function App(): React.JSX.Element {
).length +
assistantTasks.filter(
(task) =>
taskIds.has(task.id) && task.status !== 'completed'
taskIds.has(task.id) &&
task.status !== 'completed' &&
task.status !== 'cancelled'
).length
)
}, [assistantMemories, assistantTasks, heartbeatEntries])
+3 -16
View File
@@ -13,6 +13,7 @@ import type {
KnowledgeGraphNode,
KnowledgeGraphRelation
} from '../../shared/contracts'
import { useDocumentTheme } from './use-document-theme'
type ChartKnowledgeGraphNode = Omit<
KnowledgeGraphNode,
@@ -347,7 +348,7 @@ export function KnowledgeGraphChart({
() => graphRevision(nodes, relations),
[nodes, relations]
)
const [themeRevision, setThemeRevision] = useState(0)
const documentTheme = useDocumentTheme()
const [renderError, setRenderError] = useState<string>()
useEffect(() => {
@@ -407,20 +408,6 @@ export function KnowledgeGraphChart({
})
}, [dataRevision, fitViewRequest, renderErrorFallback])
useEffect(() => {
if (typeof MutationObserver !== 'function') {
return
}
const observer = new MutationObserver(() => {
setThemeRevision((revision) => revision + 1)
})
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme']
})
return () => observer.disconnect()
}, [])
useEffect(() => {
const container = containerRef.current
if (!container) {
@@ -583,7 +570,7 @@ export function KnowledgeGraphChart({
locale,
relationFallback,
renderErrorFallback,
themeRevision
documentTheme
])
useEffect(() => {
+314 -3
View File
@@ -1,10 +1,45 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor
} from '@testing-library/react'
import {
afterEach,
beforeEach,
describe,
expect,
it,
vi
} from 'vitest'
import { changeUiLocale } from './i18n'
import { MarkdownRenderer } from './MarkdownRenderer'
const mermaidMock = vi.hoisted(() => ({
initialize: vi.fn(),
render: vi.fn()
}))
vi.mock('mermaid', () => ({
default: mermaidMock
}))
describe('MarkdownRenderer', () => {
afterEach(cleanup)
beforeEach(() => {
mermaidMock.render.mockResolvedValue({
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50">
<rect id="safe-node" width="100" height="50" />
</svg>`
})
})
afterEach(() => {
cleanup()
delete document.documentElement.dataset.theme
vi.clearAllMocks()
})
it('renders CommonMark and GitHub Flavored Markdown', () => {
render(
@@ -165,6 +200,282 @@ $\\href{javascript:alert(1)}{unsafe}$`}</MarkdownRenderer>
).not.toBeInTheDocument()
})
it('renders Mermaid fences with strict bounded configuration', async () => {
const { container } = render(
<MarkdownRenderer>{`\`\`\`mermaid
flowchart LR
Start --> Finish
\`\`\``}</MarkdownRenderer>
)
expect(
screen.getByText('正在绘制 Mermaid 图表…')
).toHaveAttribute('role', 'status')
const diagram = await screen.findByRole('region', {
name: 'Mermaid 图表,可横向滚动'
})
expect(diagram).toHaveAttribute('tabindex', '0')
expect(container.querySelector('#safe-node')).toBeInTheDocument()
expect(mermaidMock.render).toHaveBeenCalledWith(
expect.stringMatching(/^goodbuddy-mermaid-\d+-0$/u),
'flowchart LR\n Start --> Finish'
)
expect(mermaidMock.initialize).toHaveBeenCalledWith(
expect.objectContaining({
htmlLabels: false,
maxEdges: 300,
maxTextSize: 20_000,
securityLevel: 'strict',
startOnLoad: false,
suppressErrorRendering: true,
theme: 'default'
})
)
})
it('shows source and opens an accessible zoomable Mermaid viewer', async () => {
render(
<div className="app-shell">
<MarkdownRenderer>{`\`\`\`mermaid
flowchart LR
Start --> Finish
\`\`\``}</MarkdownRenderer>
</div>
)
await screen.findByRole('region', {
name: 'Mermaid 图表,可横向滚动'
})
const sourceButton = screen.getByRole('button', {
name: '查看源码'
})
expect(sourceButton).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(sourceButton)
expect(
screen.getByRole('button', { name: '隐藏源码' })
).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByText(/Start --> Finish/u)).toBeInTheDocument()
const viewerButton = screen.getByRole('button', {
name: '打开大图'
})
viewerButton.focus()
fireEvent.click(viewerButton)
const dialog = screen.getByRole('dialog', {
name: 'Mermaid 大图'
})
expect(dialog).toHaveAttribute('aria-modal', 'true')
expect(
screen.getByRole('button', { name: '关闭 Mermaid 大图' })
).toHaveFocus()
expect(
document.querySelector<HTMLElement>('.app-shell')?.inert
).toBe(true)
fireEvent.click(screen.getByRole('button', { name: '放大图表' }))
expect(screen.getByLabelText('当前缩放比例')).toHaveTextContent(
'125%'
)
const canvas = screen.getByRole('region', {
name: '可缩放、可拖动的 Mermaid 图表'
})
expect(canvas).toHaveAttribute('tabindex', '0')
canvas.focus()
expect(canvas).toHaveFocus()
fireEvent.keyDown(dialog, { key: 'Tab' })
expect(
screen.getByRole('button', { name: '缩小图表' })
).toHaveFocus()
fireEvent.wheel(canvas, { deltaY: -100 })
fireEvent.wheel(canvas, { deltaY: -100 })
fireEvent.wheel(canvas, { deltaY: -100 })
await waitFor(() =>
expect(screen.getByLabelText('当前缩放比例')).toHaveTextContent(
'200%'
)
)
fireEvent.click(screen.getByRole('button', { name: '重置缩放' }))
expect(screen.getByLabelText('当前缩放比例')).toHaveTextContent(
'100%'
)
fireEvent.keyDown(dialog, { key: 'Escape' })
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await waitFor(() => expect(viewerButton).toHaveFocus())
expect(
document.querySelector<HTMLElement>('.app-shell')?.inert
).toBe(false)
})
it('closes Mermaid controls when streamed source changes', async () => {
const { rerender } = render(
<MarkdownRenderer>{`\`\`\`mermaid
flowchart LR
A --> B
\`\`\``}</MarkdownRenderer>
)
await screen.findByRole('region', {
name: 'Mermaid 图表,可横向滚动'
})
fireEvent.click(screen.getByRole('button', { name: '查看源码' }))
fireEvent.click(screen.getByRole('button', { name: '打开大图' }))
expect(screen.getByRole('dialog')).toBeInTheDocument()
rerender(
<MarkdownRenderer>{`\`\`\`mermaid
flowchart LR
A --> B --> C
\`\`\``}</MarkdownRenderer>
)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
await screen.findByRole('region', {
name: 'Mermaid 图表,可横向滚动'
})
expect(
screen.getByRole('button', { name: '查看源码' })
).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByText(/A --> B --> C/u)).not.toBeInTheDocument()
})
it('pans the large Mermaid diagram with pointer dragging', async () => {
render(
<MarkdownRenderer>{`\`\`\`mermaid
flowchart LR
A --> B
\`\`\``}</MarkdownRenderer>
)
await screen.findByRole('region', {
name: 'Mermaid 图表,可横向滚动'
})
fireEvent.click(screen.getByRole('button', { name: '打开大图' }))
const canvas = screen.getByRole('region', {
name: '可缩放、可拖动的 Mermaid 图表'
})
const setPointerCapture = vi.fn()
const releasePointerCapture = vi.fn()
Object.defineProperties(canvas, {
setPointerCapture: { value: setPointerCapture },
hasPointerCapture: { value: () => true },
releasePointerCapture: { value: releasePointerCapture }
})
canvas.scrollLeft = 30
canvas.scrollTop = 40
fireEvent.pointerDown(canvas, {
button: 0,
clientX: 100,
clientY: 100,
pointerId: 9
})
fireEvent.pointerMove(canvas, {
clientX: 70,
clientY: 60,
pointerId: 9
})
expect(setPointerCapture).toHaveBeenCalledWith(9)
expect(canvas).toHaveClass('mermaid-viewer__canvas--dragging')
expect(canvas.scrollLeft).toBe(60)
expect(canvas.scrollTop).toBe(80)
fireEvent.pointerUp(canvas, { pointerId: 9 })
expect(releasePointerCapture).toHaveBeenCalledWith(9)
expect(canvas).not.toHaveClass(
'mermaid-viewer__canvas--dragging'
)
})
it('sanitizes Mermaid SVG without enabling diagram interactions', async () => {
mermaidMock.render.mockResolvedValueOnce({
svg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50">
<style>@\\69mport url("https://unsafe.test/style.css");</style>
<script>window.bad = true</script>
<a href="javascript:alert(1)">
<rect id="unsafe-node" onclick="alert(1)"
style="fill: url(javascript:alert(1))" width="100" height="50" />
</a>
<rect id="safe-node" fill="url(#gradient)" width="20" height="20" />
</svg>`
})
const { container } = render(
<MarkdownRenderer>{`\`\`\`mermaid
flowchart LR
A --> B
\`\`\``}</MarkdownRenderer>
)
await screen.findByRole('region', {
name: 'Mermaid 图表,可横向滚动'
})
expect(container.querySelector('script')).not.toBeInTheDocument()
expect(container.querySelector('a')).not.toBeInTheDocument()
expect(container.querySelector('style')).not.toBeInTheDocument()
expect(container.querySelector('#unsafe-node')).not.toHaveAttribute(
'onclick'
)
expect(container.querySelector('#unsafe-node')).not.toHaveAttribute(
'style'
)
expect(container.querySelector('#safe-node')).toHaveAttribute(
'fill',
'url(#gradient)'
)
})
it('rerenders Mermaid diagrams when the application theme changes', async () => {
render(
<MarkdownRenderer>{`\`\`\`mermaid
sequenceDiagram
Alice->>Bob: Hello
\`\`\``}</MarkdownRenderer>
)
await waitFor(() =>
expect(mermaidMock.render).toHaveBeenCalledTimes(1)
)
act(() => {
document.documentElement.dataset.theme = 'dark'
})
await waitFor(() =>
expect(mermaidMock.render).toHaveBeenCalledTimes(2)
)
expect(mermaidMock.initialize).toHaveBeenLastCalledWith(
expect.objectContaining({
darkMode: true,
theme: 'dark'
})
)
})
it('shows Mermaid source when rendering fails or input is oversized', async () => {
mermaidMock.render.mockRejectedValueOnce(
new Error('Invalid Mermaid syntax')
)
const { rerender } = render(
<MarkdownRenderer>{`\`\`\`mermaid
not a diagram
\`\`\``}</MarkdownRenderer>
)
expect(
await screen.findByRole('alert')
).toHaveTextContent('无法绘制 Mermaid 图表')
expect(screen.getByText('not a diagram')).toBeInTheDocument()
rerender(
<MarkdownRenderer>{`\`\`\`mermaid
${'A'.repeat(20_001)}
\`\`\``}</MarkdownRenderer>
)
expect(
await screen.findByRole('alert')
).toHaveTextContent('无法绘制 Mermaid 图表')
expect(mermaidMock.render).toHaveBeenCalledTimes(1)
})
it('updates table accessibility copy when the locale changes', async () => {
render(
<MarkdownRenderer>{`| Name |
+57 -3
View File
@@ -1,4 +1,11 @@
import { memo, useMemo } from 'react'
import {
Children,
isValidElement,
lazy,
memo,
Suspense,
useMemo
} from 'react'
import rehypeKatex from 'rehype-katex'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
@@ -6,6 +13,12 @@ import remarkMath from 'remark-math'
import type { Components } from 'react-markdown'
import { useTranslation } from 'react-i18next'
const MermaidDiagram = lazy(() =>
import('./MermaidDiagram').then((module) => ({
default: module.MermaidDiagram
}))
)
const linkComponent: Components['a'] = ({
children,
node,
@@ -19,9 +32,46 @@ const linkComponent: Components['a'] = ({
)
}
function markdownComponents(tableAriaLabel: string): Components {
function markdownComponents(
tableAriaLabel: string,
mermaidLoadingLabel: string
): Components {
return {
a: linkComponent,
pre: ({ children, node, ...properties }) => {
void node
const child = Children.count(children) === 1
? Children.only(children)
: undefined
if (
isValidElement<{
children?: React.ReactNode
className?: string
}>(child) &&
/(?:^|\s)language-mermaid(?:\s|$)/iu.test(
child.props.className ?? ''
)
) {
const source = String(child.props.children ?? '').replace(
/\n$/u,
''
)
return (
<Suspense
fallback={
<figure aria-busy="true" className="mermaid-diagram">
<figcaption role="status">
{mermaidLoadingLabel}
</figcaption>
</figure>
}
>
<MermaidDiagram source={source} />
</Suspense>
)
}
return <pre {...properties}>{children}</pre>
},
table: ({ children, node, ...properties }) => {
void node
return (
@@ -149,7 +199,11 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({
}: MarkdownRendererProps): React.JSX.Element {
const { t } = useTranslation('app')
const components = useMemo(
() => markdownComponents(t('markdown.scrollableTable')),
() =>
markdownComponents(
t('markdown.scrollableTable'),
t('markdown.mermaidLoading')
),
[t]
)
+617
View File
@@ -0,0 +1,617 @@
import DOMPurify from 'dompurify'
import {
Code2,
Maximize2,
RotateCcw,
X,
ZoomIn,
ZoomOut
} from 'lucide-react'
import {
useEffect,
useId,
useMemo,
useRef,
useState
} from 'react'
import { createPortal } from 'react-dom'
import { useTranslation } from 'react-i18next'
import type { Mermaid, MermaidConfig } from 'mermaid'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { useDocumentTheme } from './use-document-theme'
import './markdown-mermaid.css'
const MAX_MERMAID_TEXT_SIZE = 20_000
const MAX_MERMAID_EDGES = 300
const MAX_MERMAID_DIMENSION = 4_096
const MIN_VIEWER_ZOOM = 0.5
const MAX_VIEWER_ZOOM = 3
const VIEWER_ZOOM_STEP = 0.25
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'
const unsafeCssPattern =
/(?:@import|\bexpression\s*\(|(?:https?|ftp|file|data|javascript|vbscript):|\/\/|-moz-binding|\bbehavior\s*:)/iu
const cssUrlPattern = /url\s*\(\s*(['"]?)(.*?)\1\s*\)/giu
const forbiddenTags = [
'a',
'animate',
'animatemotion',
'animatetransform',
'discard',
'foreignobject',
'iframe',
'image',
'mpath',
'set',
'script'
]
const forbiddenAttributes = ['href', 'xlink:href']
const sharedSanitizerConfig = {
ALLOW_DATA_ATTR: false,
ALLOW_UNKNOWN_PROTOCOLS: false,
FORBID_ATTR: forbiddenAttributes,
FORBID_TAGS: forbiddenTags
} as const
const secureConfigKeys = [
'secure',
'securityLevel',
'startOnLoad',
'maxTextSize',
'suppressErrorRendering',
'maxEdges',
'htmlLabels',
'dompurifyConfig',
'theme',
'themeCSS',
'themeVariables',
'fontFamily',
'altFontFamily',
'flowchart'
]
let mermaidModulePromise: Promise<Mermaid> | undefined
let mermaidRenderQueue = Promise.resolve()
let mermaidDiagramSequence = 0
type MermaidRenderState =
| {
source: string
status: 'ready'
svg: string
theme: 'light' | 'dark'
}
| {
source: string
status: 'error'
theme: 'light' | 'dark'
}
function loadMermaid(): Promise<Mermaid> {
mermaidModulePromise ??= import('mermaid').then(
(module) => module.default
)
return mermaidModulePromise
}
function containsUnsafeCss(value: string): boolean {
const normalizedValue = value
.replace(/\/\*[\s\S]*?\*\//gu, '')
.replace(
/\\([0-9a-f]{1,6}\s?|.)/giu,
(_match, escaped: string) => {
const hex = escaped.trim()
return /^[0-9a-f]{1,6}$/iu.test(hex)
? String.fromCodePoint(Number.parseInt(hex, 16))
: escaped
}
)
if (unsafeCssPattern.test(normalizedValue)) {
return true
}
cssUrlPattern.lastIndex = 0
for (
let match = cssUrlPattern.exec(normalizedValue);
match;
match = cssUrlPattern.exec(normalizedValue)
) {
if (!match[2]?.trim().startsWith('#')) {
return true
}
}
return false
}
function sanitizeMermaidSvg(svg: string): string {
const sanitized = DOMPurify.sanitize(svg, {
...sharedSanitizerConfig,
USE_PROFILES: {
svg: true,
svgFilters: true
}
})
const documentNode = new DOMParser().parseFromString(
sanitized,
'image/svg+xml'
)
const root = documentNode.documentElement
if (
root.localName !== 'svg' ||
root.namespaceURI !== SVG_NAMESPACE ||
documentNode.querySelector('parsererror')
) {
throw new Error('Mermaid returned invalid SVG')
}
for (const element of [root, ...root.querySelectorAll('*')]) {
if (
element.localName === 'style' &&
containsUnsafeCss(element.textContent ?? '')
) {
element.remove()
continue
}
for (const attribute of [...element.attributes]) {
const attributeName = attribute.name.toLowerCase()
if (
attributeName.startsWith('on') ||
forbiddenAttributes.includes(attributeName) ||
containsUnsafeCss(attribute.value)
) {
element.removeAttribute(attribute.name)
}
}
}
const viewBox = root
.getAttribute('viewBox')
?.trim()
.split(/[\s,]+/u)
.map(Number)
if (
viewBox?.length === 4 &&
viewBox.every(Number.isFinite) &&
viewBox[2]! > 0 &&
viewBox[3]! > 0
) {
const scale = Math.min(
1,
MAX_MERMAID_DIMENSION / viewBox[2]!,
MAX_MERMAID_DIMENSION / viewBox[3]!
)
root.setAttribute('width', String(Math.ceil(viewBox[2]! * scale)))
root.setAttribute('height', String(Math.ceil(viewBox[3]! * scale)))
root.style.removeProperty('max-width')
}
return new XMLSerializer().serializeToString(root)
}
function mermaidConfig(darkTheme: boolean): MermaidConfig {
return {
darkMode: darkTheme,
deterministicIds: true,
dompurifyConfig: sharedSanitizerConfig,
fontFamily:
'"Inter Variable", "Noto Sans SC Variable", "Segoe UI Variable", sans-serif',
htmlLabels: false,
logLevel: 'fatal',
maxEdges: MAX_MERMAID_EDGES,
maxTextSize: MAX_MERMAID_TEXT_SIZE,
secure: secureConfigKeys,
securityLevel: 'strict',
startOnLoad: false,
suppressErrorRendering: true,
theme: darkTheme ? 'dark' : 'default'
}
}
function renderMermaid(
source: string,
id: string,
darkTheme: boolean,
shouldRender: () => boolean
): Promise<string> {
if (source.length > MAX_MERMAID_TEXT_SIZE) {
return Promise.reject(new Error('Mermaid source is too large'))
}
const render = mermaidRenderQueue.then(async () => {
if (!shouldRender()) {
throw new Error('Mermaid render was superseded')
}
const mermaid = await loadMermaid()
if (!shouldRender()) {
throw new Error('Mermaid render was superseded')
}
mermaid.initialize(mermaidConfig(darkTheme))
const result = await mermaid.render(id, source)
return sanitizeMermaidSvg(result.svg)
})
mermaidRenderQueue = render.then(
() => undefined,
() => undefined
)
return render
}
type MermaidDiagramProps = {
source: string
}
type MermaidViewerProps = {
onClose: () => void
svg: string
}
function clampViewerZoom(zoom: number): number {
return Math.min(MAX_VIEWER_ZOOM, Math.max(MIN_VIEWER_ZOOM, zoom))
}
function MermaidViewer({
onClose,
svg
}: MermaidViewerProps): React.JSX.Element {
const { i18n, t } = useTranslation('app')
const titleId = useId()
const hintId = useId()
const dialogRef = useRef<HTMLElement>(null)
const closeRef = useRef<HTMLButtonElement>(null)
const dragRef = useRef<{
pointerId: number
startX: number
startY: number
scrollLeft: number
scrollTop: number
} | undefined>(undefined)
const [dragging, setDragging] = useState(false)
const [zoom, setZoom] = useState(1)
const pendingWheelZoomRef = useRef(0)
const zoomFrameRef = useRef<number | undefined>(undefined)
const percentFormatter = useMemo(
() =>
new Intl.NumberFormat(i18n.resolvedLanguage, {
maximumFractionDigits: 0,
style: 'percent'
}),
[i18n.resolvedLanguage]
)
useEffect(() => {
const deactivateModalFocus = activateModalFocus(
() => closeRef.current
)
return () => {
if (zoomFrameRef.current !== undefined) {
cancelAnimationFrame(zoomFrameRef.current)
}
deactivateModalFocus()
}
}, [])
const adjustZoom = (delta: number): void => {
setZoom((current) => clampViewerZoom(current + delta))
}
const queueWheelZoom = (delta: number): void => {
pendingWheelZoomRef.current += delta
if (zoomFrameRef.current !== undefined) {
return
}
zoomFrameRef.current = requestAnimationFrame(() => {
const pendingZoom = pendingWheelZoomRef.current
pendingWheelZoomRef.current = 0
zoomFrameRef.current = undefined
adjustZoom(pendingZoom)
})
}
const finishDragging = (
event: React.PointerEvent<HTMLDivElement>
): void => {
if (dragRef.current?.pointerId !== event.pointerId) {
return
}
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
event.currentTarget.releasePointerCapture(event.pointerId)
}
dragRef.current = undefined
setDragging(false)
}
return createPortal(
<div
className="mermaid-viewer-backdrop"
onMouseDown={(event) => {
if (event.target === event.currentTarget) {
onClose()
}
}}
>
<section
aria-describedby={hintId}
aria-labelledby={titleId}
aria-modal="true"
className="mermaid-viewer"
onKeyDown={(event) => {
if (event.key === 'Escape') {
event.preventDefault()
onClose()
return
}
trapTabFocus(event, dialogRef.current)
}}
ref={dialogRef}
role="dialog"
>
<header className="mermaid-viewer__header">
<div>
<strong id={titleId}>{t('markdown.mermaidViewerTitle')}</strong>
<small id={hintId}>
{t('markdown.mermaidViewerHint')}
</small>
</div>
<div className="mermaid-viewer__controls">
<button
aria-label={t('markdown.mermaidZoomOut')}
className="icon-button"
disabled={zoom <= MIN_VIEWER_ZOOM}
onClick={() => adjustZoom(-VIEWER_ZOOM_STEP)}
title={t('markdown.mermaidZoomOut')}
type="button"
>
<ZoomOut aria-hidden="true" size={17} />
</button>
<output
aria-label={t('markdown.mermaidZoomLevel')}
className="mermaid-viewer__zoom"
>
{percentFormatter.format(zoom)}
</output>
<button
aria-label={t('markdown.mermaidZoomIn')}
className="icon-button"
disabled={zoom >= MAX_VIEWER_ZOOM}
onClick={() => adjustZoom(VIEWER_ZOOM_STEP)}
title={t('markdown.mermaidZoomIn')}
type="button"
>
<ZoomIn aria-hidden="true" size={17} />
</button>
<button
aria-label={t('markdown.mermaidResetZoom')}
className="icon-button"
disabled={zoom === 1}
onClick={() => setZoom(1)}
title={t('markdown.mermaidResetZoom')}
type="button"
>
<RotateCcw aria-hidden="true" size={16} />
</button>
<button
aria-label={t('markdown.mermaidCloseViewer')}
className="icon-button"
onClick={onClose}
ref={closeRef}
title={t('markdown.mermaidCloseViewer')}
type="button"
>
<X aria-hidden="true" size={18} />
</button>
</div>
</header>
<div
aria-label={t('markdown.mermaidViewerCanvas')}
className={`mermaid-viewer__canvas${
dragging ? ' mermaid-viewer__canvas--dragging' : ''
}`}
onPointerCancel={finishDragging}
onPointerDown={(event) => {
if (event.button !== 0) {
return
}
event.preventDefault()
dragRef.current = {
pointerId: event.pointerId,
startX: event.clientX,
startY: event.clientY,
scrollLeft: event.currentTarget.scrollLeft,
scrollTop: event.currentTarget.scrollTop
}
event.currentTarget.setPointerCapture(event.pointerId)
setDragging(true)
}}
onPointerMove={(event) => {
const drag = dragRef.current
if (drag?.pointerId !== event.pointerId) {
return
}
event.preventDefault()
event.currentTarget.scrollLeft =
drag.scrollLeft - (event.clientX - drag.startX)
event.currentTarget.scrollTop =
drag.scrollTop - (event.clientY - drag.startY)
}}
onPointerUp={finishDragging}
onWheel={(event) => {
if (event.deltaY === 0) {
return
}
event.preventDefault()
queueWheelZoom(
event.deltaY < 0
? VIEWER_ZOOM_STEP
: -VIEWER_ZOOM_STEP
)
}}
role="region"
tabIndex={0}
>
<div
className="mermaid-viewer__diagram"
dangerouslySetInnerHTML={{ __html: svg }}
style={{ zoom }}
/>
</div>
</section>
</div>,
document.body
)
}
export function MermaidDiagram({
source
}: MermaidDiagramProps): React.JSX.Element {
const { t } = useTranslation('app')
const idRef = useRef(
`goodbuddy-mermaid-${++mermaidDiagramSequence}`
)
const renderRevisionRef = useRef(0)
const sourceId = useId()
const documentTheme = useDocumentTheme()
const [renderState, setRenderState] =
useState<MermaidRenderState>()
const [visibleSource, setVisibleSource] = useState<string>()
const [viewerSource, setViewerSource] = useState<string>()
const sourceVisible = visibleSource === source
const viewerOpen = viewerSource === source
useEffect(() => {
let active = true
const renderRevision = renderRevisionRef.current++
const timeout = window.setTimeout(() => {
void renderMermaid(
source.trim(),
`${idRef.current}-${renderRevision}`,
documentTheme === 'dark',
() => active
).then(
(svg) => {
if (active) {
setRenderState({
source,
status: 'ready',
svg,
theme: documentTheme
})
}
},
() => {
if (active) {
setRenderState({
source,
status: 'error',
theme: documentTheme
})
}
}
)
}, 120)
return () => {
active = false
window.clearTimeout(timeout)
}
}, [documentTheme, source])
const currentRenderState =
renderState?.source === source &&
renderState.theme === documentTheme
? renderState
: undefined
const retainedSvg =
renderState?.status === 'ready' &&
renderState.source === source
? renderState.svg
: undefined
const svg =
currentRenderState?.status === 'ready'
? currentRenderState.svg
: retainedSvg
if (currentRenderState?.status === 'error') {
return (
<figure className="mermaid-diagram mermaid-diagram--error">
<figcaption role="alert">
{t('markdown.mermaidError')}
</figcaption>
<pre>
<code className="language-mermaid">{source}</code>
</pre>
</figure>
)
}
return (
<figure
aria-busy={!currentRenderState}
className="mermaid-diagram"
>
{svg ? (
<>
<div
aria-label={t('markdown.mermaidActions')}
className="mermaid-diagram__actions"
role="group"
>
<button
aria-controls={sourceId}
aria-expanded={sourceVisible}
aria-label={t(
sourceVisible
? 'markdown.mermaidHideSource'
: 'markdown.mermaidViewSource'
)}
className={`icon-button${
sourceVisible ? ' icon-button--active' : ''
}`}
onClick={() =>
setVisibleSource(sourceVisible ? undefined : source)
}
title={t(
sourceVisible
? 'markdown.mermaidHideSource'
: 'markdown.mermaidViewSource'
)}
type="button"
>
<Code2 aria-hidden="true" size={17} />
</button>
<button
aria-label={t('markdown.mermaidOpenViewer')}
className="icon-button"
onClick={() => setViewerSource(source)}
title={t('markdown.mermaidOpenViewer')}
type="button"
>
<Maximize2 aria-hidden="true" size={17} />
</button>
</div>
{!viewerOpen && (
<div
aria-label={t('markdown.mermaidDiagram')}
className="mermaid-diagram__viewport"
dangerouslySetInnerHTML={{ __html: svg }}
role="region"
tabIndex={0}
/>
)}
{sourceVisible && (
<pre className="mermaid-diagram__source" id={sourceId}>
<code className="language-mermaid">{source}</code>
</pre>
)}
{viewerOpen && (
<MermaidViewer
onClose={() => setViewerSource(undefined)}
svg={svg}
/>
)}
</>
) : (
<figcaption className="mermaid-diagram__loading" role="status">
{t('markdown.mermaidLoading')}
</figcaption>
)}
</figure>
)
}
+1 -1
View File
@@ -5,7 +5,7 @@ type TabKeyEvent = {
}
const focusableSelector =
'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled])'
'button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
export function trapTabFocus(
event: TabKeyEvent,
+18 -1
View File
@@ -473,6 +473,23 @@ export const app = {
evidenceExcerpt: '{{source}}: {{excerpt}}'
},
markdown: {
scrollableTable: 'Table, horizontally scrollable'
scrollableTable: 'Table, horizontally scrollable',
mermaidDiagram: 'Mermaid diagram, horizontally scrollable',
mermaidLoading: 'Rendering Mermaid diagram…',
mermaidError:
'The Mermaid diagram could not be rendered. Its source is shown below.',
mermaidActions: 'Mermaid diagram actions',
mermaidViewSource: 'View source',
mermaidHideSource: 'Hide source',
mermaidOpenViewer: 'Open large diagram',
mermaidViewerTitle: 'Large Mermaid diagram',
mermaidViewerHint:
'Use the mouse wheel or buttons to zoom, and drag the canvas to pan.',
mermaidViewerCanvas: 'Zoomable, pannable Mermaid diagram',
mermaidZoomOut: 'Zoom out diagram',
mermaidZoomIn: 'Zoom in diagram',
mermaidResetZoom: 'Reset zoom',
mermaidZoomLevel: 'Current zoom level',
mermaidCloseViewer: 'Close large Mermaid diagram'
}
} satisfies TranslationShape<typeof chineseApp>
+16 -1
View File
@@ -439,6 +439,21 @@ export const app = {
evidenceExcerpt: '{{source}}{{excerpt}}'
},
markdown: {
scrollableTable: '表格,可横向滚动'
scrollableTable: '表格,可横向滚动',
mermaidDiagram: 'Mermaid 图表,可横向滚动',
mermaidLoading: '正在绘制 Mermaid 图表…',
mermaidError: '无法绘制 Mermaid 图表,已保留原始图表代码。',
mermaidActions: 'Mermaid 图表操作',
mermaidViewSource: '查看源码',
mermaidHideSource: '隐藏源码',
mermaidOpenViewer: '打开大图',
mermaidViewerTitle: 'Mermaid 大图',
mermaidViewerHint: '使用滚轮或按钮缩放,拖动画布浏览。',
mermaidViewerCanvas: '可缩放、可拖动的 Mermaid 图表',
mermaidZoomOut: '缩小图表',
mermaidZoomIn: '放大图表',
mermaidResetZoom: '重置缩放',
mermaidZoomLevel: '当前缩放比例',
mermaidCloseViewer: '关闭 Mermaid 大图'
}
} as const
+205
View File
@@ -0,0 +1,205 @@
.mermaid-diagram {
position: relative;
display: grid;
min-width: 0;
margin: var(--space-3) 0;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-control);
background: var(--surface-raised);
}
.mermaid-diagram__actions {
position: absolute;
z-index: 1;
top: var(--space-2);
right: var(--space-2);
display: flex;
padding: 2px;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-control);
background: var(--surface-raised);
box-shadow: var(--shadow-card);
gap: 2px;
}
.mermaid-diagram__actions .icon-button {
width: 30px;
height: 30px;
}
.mermaid-diagram__viewport {
min-width: 0;
padding: var(--space-4);
overflow-x: auto;
overscroll-behavior-inline: contain;
}
.mermaid-diagram__viewport:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.mermaid-diagram__viewport > svg {
display: block;
min-width: min(100%, 320px);
max-width: none !important;
height: auto;
min-height: 80px;
margin-inline: auto;
font-family: var(--font-family-ui);
}
.markdown-content .mermaid-diagram__source {
max-height: 320px;
margin: 0;
border: 0;
border-top: 1px solid var(--border-subtle);
border-radius: 0 0 var(--radius-control) var(--radius-control);
overflow: auto;
}
.mermaid-diagram__loading,
.mermaid-diagram--error > figcaption {
padding: var(--space-3) var(--space-4);
color: var(--text-muted);
font-size: var(--font-caption);
line-height: 1.5;
}
.mermaid-diagram--error {
border-color: var(--danger-border);
background: var(--danger-subtle);
}
.mermaid-diagram--error > figcaption {
color: var(--danger-strong);
}
.markdown-content .mermaid-diagram--error > pre {
margin: 0;
border: 0;
border-top: 1px solid var(--danger-border);
border-radius: 0 0 var(--radius-control) var(--radius-control);
}
.mermaid-viewer-backdrop {
position: fixed;
z-index: 120;
display: grid;
padding: var(--space-4);
background: var(--overlay-backdrop);
inset: 38px 0 0;
place-items: center;
}
.mermaid-viewer {
display: grid;
width: min(1280px, 100%);
height: min(860px, calc(100vh - 70px));
min-height: 0;
border: 1px solid var(--border-default);
border-radius: var(--radius-card);
background: var(--surface-raised);
box-shadow: var(--shadow-dialog);
grid-template-rows: auto minmax(0, 1fr);
}
.mermaid-viewer__header {
display: flex;
min-width: 0;
align-items: center;
padding: var(--space-3) var(--space-4);
border-bottom: 1px solid var(--border-subtle);
justify-content: space-between;
gap: var(--space-3);
}
.mermaid-viewer__header > div:first-child {
display: grid;
min-width: 0;
gap: 2px;
}
.mermaid-viewer__header strong {
color: var(--text-primary);
font-size: var(--font-section-title);
}
.mermaid-viewer__header small {
color: var(--text-muted);
font-size: var(--font-caption);
line-height: 1.4;
}
.mermaid-viewer__controls {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: var(--space-1);
}
.mermaid-viewer__zoom {
min-width: 48px;
color: var(--text-secondary);
font-size: var(--font-caption);
font-variant-numeric: tabular-nums;
text-align: center;
}
.mermaid-viewer__canvas {
display: grid;
min-width: 0;
min-height: 0;
padding: var(--space-6);
overflow: auto;
background: var(--surface-subtle);
cursor: grab;
overscroll-behavior: contain;
place-items: center;
touch-action: none;
}
.mermaid-viewer__canvas--dragging {
cursor: grabbing;
user-select: none;
}
.mermaid-viewer__canvas:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.mermaid-viewer__diagram {
display: grid;
min-width: max-content;
min-height: max-content;
transform-origin: center;
place-items: center;
}
.mermaid-viewer__diagram > svg {
display: block;
max-width: none !important;
height: auto;
font-family: var(--font-family-ui);
}
@media (max-width: 720px) {
.mermaid-viewer-backdrop {
padding: var(--space-2);
}
.mermaid-viewer {
height: calc(100vh - 54px);
}
.mermaid-viewer__header {
align-items: flex-start;
flex-direction: column;
}
.mermaid-viewer__controls {
width: 100%;
justify-content: flex-end;
}
}
+46
View File
@@ -0,0 +1,46 @@
import { useSyncExternalStore } from 'react'
import type { ResolvedAppearanceTheme } from './theme'
const listeners = new Set<() => void>()
let observer: MutationObserver | undefined
function resolvedDocumentTheme(): ResolvedAppearanceTheme {
return document.documentElement.dataset.theme === 'dark'
? 'dark'
: 'light'
}
function notifyThemeListeners(): void {
for (const listener of listeners) {
listener()
}
}
function subscribeToDocumentTheme(listener: () => void): () => void {
listeners.add(listener)
if (
listeners.size === 1 &&
typeof MutationObserver === 'function'
) {
observer = new MutationObserver(notifyThemeListeners)
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme']
})
}
return () => {
listeners.delete(listener)
if (listeners.size === 0) {
observer?.disconnect()
observer = undefined
}
}
}
export function useDocumentTheme(): ResolvedAppearanceTheme {
return useSyncExternalStore(
subscribeToDocumentTheme,
resolvedDocumentTheme,
resolvedDocumentTheme
)
}