mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
fix(desktop): render generated Mermaid labels in previews (#803)
Normalize generated flowchart labels before Mermaid rendering so Markdown previews handle labels with HTML breaks, braces, and bracketed type text. Tested: bun run check:desktop Scope-risk: narrow
This commit is contained in:
parent
ae32599ba8
commit
dd72901ba3
@ -57,4 +57,31 @@ describe('MermaidRenderer Mermaid integration', () => {
|
||||
expect(surface.innerHTML).not.toContain('<script')
|
||||
expect(surface.innerHTML).not.toContain('onerror')
|
||||
})
|
||||
|
||||
it('renders generated flowchart labels with HTML breaks and structural characters', async () => {
|
||||
render(
|
||||
<MermaidRenderer
|
||||
code={[
|
||||
'graph LR',
|
||||
' subgraph "Yjs CRDT 核心"',
|
||||
' Y[Yjs Document]',
|
||||
' A[嵌入类型<br/>Text / Map / Array]',
|
||||
' I[插入操作<br/>{content, position, clock, clientID}]',
|
||||
' D[删除操作<br/>{position, length, clock, clientID}]',
|
||||
' RM[Room Manager<br/>map[string]*Room]',
|
||||
' end',
|
||||
' I --> Y',
|
||||
' D --> Y',
|
||||
' RM --> Y',
|
||||
].join('\n')}
|
||||
/>,
|
||||
)
|
||||
|
||||
const surface = await screen.findByTestId('mermaid-diagram-surface')
|
||||
|
||||
expect(surface).toHaveTextContent('插入操作')
|
||||
expect(surface).toHaveTextContent('{content, position, clock, clientID}')
|
||||
expect(surface).toHaveTextContent('map[string]*Room')
|
||||
expect(screen.queryByText('Mermaid Error')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -38,6 +38,98 @@ type MermaidThemeColors = {
|
||||
isDark: boolean
|
||||
}
|
||||
|
||||
const FLOWCHART_START = /^\s*(?:graph|flowchart)\b/i
|
||||
const FLOWCHART_NODE_START = /^([A-Za-z][\w-]*)\[/
|
||||
const UNQUOTED_FLOWCHART_LABEL_UNSAFE = /<br\s*\/?>|[{}[\]*]/i
|
||||
|
||||
function isFlowchartDiagram(code: string) {
|
||||
const firstMeaningfulLine = code
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean)
|
||||
|
||||
return firstMeaningfulLine ? FLOWCHART_START.test(firstMeaningfulLine) : false
|
||||
}
|
||||
|
||||
function isQuotedFlowchartLabel(label: string) {
|
||||
const trimmed = label.trim()
|
||||
return (
|
||||
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||||
(trimmed.startsWith("'") && trimmed.endsWith("'")) ||
|
||||
(trimmed.startsWith('`') && trimmed.endsWith('`'))
|
||||
)
|
||||
}
|
||||
|
||||
function shouldQuoteFlowchartLabel(label: string) {
|
||||
return !isQuotedFlowchartLabel(label) && UNQUOTED_FLOWCHART_LABEL_UNSAFE.test(label)
|
||||
}
|
||||
|
||||
function escapeFlowchartLabel(label: string) {
|
||||
return label.replace(/\\/g, '\\\\').replace(/"/g, '\\"')
|
||||
}
|
||||
|
||||
function isLikelyFlowchartLabelClose(line: string, closeIndex: number) {
|
||||
const after = line.slice(closeIndex + 1).trimStart()
|
||||
return (
|
||||
after.length === 0 ||
|
||||
after.startsWith('--') ||
|
||||
after.startsWith('-.') ||
|
||||
after.startsWith('==') ||
|
||||
after.startsWith('~~~') ||
|
||||
after.startsWith(':::') ||
|
||||
after.startsWith('&') ||
|
||||
after.startsWith('@') ||
|
||||
/^[;,)]/.test(after)
|
||||
)
|
||||
}
|
||||
|
||||
function findFlowchartLabelClose(line: string, openIndex: number) {
|
||||
for (let index = openIndex + 1; index < line.length; index += 1) {
|
||||
if (line[index] === ']' && isLikelyFlowchartLabelClose(line, index)) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function normalizeFlowchartLine(line: string) {
|
||||
let output = ''
|
||||
let index = 0
|
||||
|
||||
while (index < line.length) {
|
||||
const match = FLOWCHART_NODE_START.exec(line.slice(index))
|
||||
if (!match) {
|
||||
output += line[index]
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const nodeId = match[1] ?? ''
|
||||
const openIndex = index + nodeId.length
|
||||
const closeIndex = findFlowchartLabelClose(line, openIndex)
|
||||
if (closeIndex < 0) {
|
||||
output += line[index]
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const label = line.slice(openIndex + 1, closeIndex)
|
||||
if (!shouldQuoteFlowchartLabel(label)) {
|
||||
output += line.slice(index, closeIndex + 1)
|
||||
} else {
|
||||
output += `${nodeId}["${escapeFlowchartLabel(label)}"]`
|
||||
}
|
||||
index = closeIndex + 1
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
function normalizeGeneratedFlowchartSyntax(code: string) {
|
||||
if (!isFlowchartDiagram(code)) return code
|
||||
return code.split('\n').map(normalizeFlowchartLine).join('\n')
|
||||
}
|
||||
|
||||
function rgbToHex(color: string, fallback: string) {
|
||||
const trimmed = color.trim()
|
||||
if (/^#[0-9a-f]{6}$/i.test(trimmed)) return trimmed
|
||||
@ -329,8 +421,9 @@ export function MermaidRenderer({ code }: Props) {
|
||||
const { lineColor } = initMermaid(theme)
|
||||
|
||||
const id = `mermaid-${++mermaidIdCounter}`
|
||||
const renderCode = normalizeGeneratedFlowchartSyntax(code)
|
||||
|
||||
mermaid.render(id, code).then(
|
||||
mermaid.render(id, renderCode).then(
|
||||
({ svg: renderedSvg }) => {
|
||||
if (!cancelled) {
|
||||
setSvg(normalizeMermaidSvg(renderedSvg, lineColor))
|
||||
|
||||
@ -221,6 +221,34 @@ function classNameContains(element: Element | null, needle: string) {
|
||||
return false
|
||||
}
|
||||
|
||||
type SvgMeasurementPrototype = SVGElement & {
|
||||
getBBox?: () => { x: number; y: number; width: number; height: number }
|
||||
getComputedTextLength?: () => number
|
||||
}
|
||||
|
||||
function ensureMermaidSvgMeasurementStubs() {
|
||||
const svgPrototype = SVGElement.prototype as SvgMeasurementPrototype
|
||||
|
||||
if (!svgPrototype.getBBox) {
|
||||
Object.defineProperty(svgPrototype, 'getBBox', {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 120,
|
||||
height: 24,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
if (!svgPrototype.getComputedTextLength) {
|
||||
Object.defineProperty(svgPrototype, 'getComputedTextLength', {
|
||||
configurable: true,
|
||||
value: () => 96,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('../../api/sessions', () => ({
|
||||
sessionsApi: (() => {
|
||||
if (!mocks) {
|
||||
@ -265,6 +293,7 @@ describe('WorkspacePanel', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
ensureMermaidSvgMeasurementStubs()
|
||||
await setWorkspaceState(workspaceInitialState)
|
||||
useChatStore.setState(chatInitialState, true)
|
||||
useWorkspaceChatContextStore.setState(workspaceChatInitialState, true)
|
||||
@ -1069,6 +1098,72 @@ describe('WorkspacePanel', () => {
|
||||
expect(view.queryByTestId('workspace-code')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders Mermaid diagrams in markdown file previews when labels contain HTML breaks and braces', async () => {
|
||||
await setWorkspaceState((state) => ({
|
||||
...state,
|
||||
panelBySession: {
|
||||
...state.panelBySession,
|
||||
'session-markdown-mermaid-preview': {
|
||||
isOpen: true,
|
||||
activeView: 'all',
|
||||
},
|
||||
},
|
||||
statusBySession: {
|
||||
...state.statusBySession,
|
||||
'session-markdown-mermaid-preview': {
|
||||
state: 'ok',
|
||||
workDir: '/repo',
|
||||
repoName: 'repo',
|
||||
branch: 'main',
|
||||
isGitRepo: true,
|
||||
changedFiles: [],
|
||||
},
|
||||
},
|
||||
previewTabsBySession: {
|
||||
...state.previewTabsBySession,
|
||||
'session-markdown-mermaid-preview': [{
|
||||
id: 'file:architecture.md',
|
||||
path: 'architecture.md',
|
||||
kind: 'file',
|
||||
title: 'architecture.md',
|
||||
language: 'markdown',
|
||||
content: [
|
||||
'# Architecture',
|
||||
'',
|
||||
'```mermaid',
|
||||
'graph LR',
|
||||
' subgraph "Yjs CRDT 核心"',
|
||||
' Y[Yjs Document]',
|
||||
' A[嵌入类型<br/>Text / Map / Array]',
|
||||
' I[插入操作<br/>{content, position, clock, clientID}]',
|
||||
' D[删除操作<br/>{position, length, clock, clientID}]',
|
||||
' RM[Room Manager<br/>map[string]*Room]',
|
||||
' end',
|
||||
' I --> Y',
|
||||
' D --> Y',
|
||||
' RM --> Y',
|
||||
'```',
|
||||
].join('\n'),
|
||||
state: 'ok',
|
||||
size: 256,
|
||||
}],
|
||||
},
|
||||
activePreviewTabIdBySession: {
|
||||
...state.activePreviewTabIdBySession,
|
||||
'session-markdown-mermaid-preview': 'file:architecture.md',
|
||||
},
|
||||
}))
|
||||
|
||||
const view = await renderPanel('session-markdown-mermaid-preview')
|
||||
|
||||
const surface = await view.findByTestId('mermaid-diagram-surface')
|
||||
expect(surface.textContent).toContain('插入操作')
|
||||
expect(surface.textContent).toContain('{content, position, clock, clientID}')
|
||||
expect(surface.textContent).toContain('map[string]*Room')
|
||||
expect(view.queryByText('Mermaid Error')).toBeNull()
|
||||
expect(view.queryByTestId('workspace-code')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens a context menu for preview tabs and closes tabs to the right', async () => {
|
||||
await setWorkspaceState((state) => ({
|
||||
...state,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user