feat: render interactive Mermaid diagrams
This commit is contained in:
@@ -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('知识数据库暂时不可用')
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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]
|
||||
)
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user