Skip to content

Commit 9010c30

Browse files
PttCodingManclaude
andcommitted
refactor: replace Mermaid mindmap with custom SVG renderer and reader-selectable palettes.
Swap the Mermaid-based mindmap for a pure React SVG pipeline (parser → layout → JSX), add five palettes selectable per-reader via a toolbar dropdown, and place the dropdown above the diagram so it no longer overlays mindmap content. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e18da9c commit 9010c30

11 files changed

Lines changed: 1182 additions & 405 deletions

File tree

docs/plans/mindmap.md

Lines changed: 219 additions & 201 deletions
Large diffs are not rendered by default.
Lines changed: 172 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -1,127 +1,200 @@
11
import { useEffect, useMemo, useRef, useState } from 'react'
2-
import useTheme from '../store/useTheme'
32
import { renderMindmap, MindmapParseError } from '../lib/mindmap'
4-
import { ensureMermaid } from '../lib/mermaidBootstrap'
3+
import { layoutMindmap, LAYOUT } from '../lib/mindmapLayout'
4+
import useMindmapTheme, { mindmapThemes } from '../store/useMindmapTheme'
55

66
/**
7-
* Read the active wiki theme into concrete hex / rgb values.
7+
* XMind-style left-to-right mindmap renderer.
88
*
9-
* Mermaid classDef cannot reference CSS variables directly — the Mermaid
10-
* SVG lives in its own style tree and `var(--...)` would not resolve
11-
* against the wiki's `:root`. We snapshot the computed values at render
12-
* time and embed them into the classDef block, so the diagram picks up
13-
* theme switches on its next render.
9+
* Pipeline:
10+
* markdown → `renderMindmap` → tree → `layoutMindmap` → SVG JSX
11+
*
12+
* The renderer has no Mermaid / DOMParser / dangerouslySetInnerHTML: the text
13+
* goes straight into React's `<text>` element so XSS is impossible, and the
14+
* layout functions are pure so theme / palette changes re-render instantly.
15+
*
16+
* Palettes (see `useMindmapTheme`) can be chosen per-reader via the dropdown
17+
* at the top-right of the diagram. `classic` defers to the wiki theme; other
18+
* palettes override node fill/stroke/text with their own color scale.
1419
*/
15-
function readTheme() {
16-
if (typeof document === 'undefined') return null
17-
const cs = getComputedStyle(document.documentElement)
18-
const read = (name, fallback) => cs.getPropertyValue(name).trim() || fallback
19-
return {
20-
primary: read('--color-primary', '#7ea7d8'),
21-
primaryText: read('--color-primary-text', '#ffffff'),
22-
primarySoft: read('--color-primary-soft', '#eef4fb'),
23-
accent: read('--color-accent', '#a8c5e4'),
24-
surface: read('--color-surface', '#ffffff'),
25-
surfaceHover: read('--color-surface-hover', '#f2f6fa'),
26-
text: read('--color-text', '#3e4b5e'),
27-
textSecondary: read('--color-text-secondary', '#7a8798'),
28-
border: read('--color-border', '#e6ecf4'),
20+
21+
function levelStyle(palette, depth) {
22+
if (palette.useWikiTheme) {
23+
const n = Math.min(depth, 4)
24+
return {
25+
fill: `var(--mindmap-lv${n}-fill)`,
26+
stroke: `var(--mindmap-lv${n}-stroke)`,
27+
text: `var(--mindmap-lv${n}-text)`,
28+
}
2929
}
30+
const levels = palette.levels
31+
const idx = Math.min(depth, levels.length - 1)
32+
return levels[idx]
3033
}
3134

32-
/**
33-
* Append per-level classDef rules so nodes inherit wiki colors.
34-
*
35-
* - lv0 is the root (primary fill, white text)
36-
* - lv1 uses the soft-primary tint (so top-level branches pop)
37-
* - lv2..lv4 fade through surface/surface-hover for depth
38-
*
39-
* Link styling picks up `border` so edges look like part of the card.
40-
*/
41-
function withThemeStyles(code, theme) {
42-
if (!theme) return code
43-
const levels = [
44-
['lv0', theme.primary, theme.primaryText, theme.primary],
45-
['lv1', theme.primarySoft, theme.text, theme.accent],
46-
['lv2', theme.surface, theme.text, theme.border],
47-
['lv3', theme.surfaceHover, theme.text, theme.border],
48-
['lv4', theme.surfaceHover, theme.textSecondary, theme.border],
49-
]
50-
const defs = levels.map(
51-
([name, fill, color, stroke]) =>
52-
` classDef ${name} fill:${fill},color:${color},stroke:${stroke},stroke-width:1.5px,rx:6,ry:6`,
35+
function ThemeDropdown({ value, onChange }) {
36+
const [open, setOpen] = useState(false)
37+
const ref = useRef(null)
38+
39+
useEffect(() => {
40+
if (!open) return
41+
const handleClick = (e) => {
42+
if (ref.current && !ref.current.contains(e.target)) setOpen(false)
43+
}
44+
const handleKey = (e) => {
45+
if (e.key === 'Escape') setOpen(false)
46+
}
47+
document.addEventListener('mousedown', handleClick)
48+
document.addEventListener('keydown', handleKey)
49+
return () => {
50+
document.removeEventListener('mousedown', handleClick)
51+
document.removeEventListener('keydown', handleKey)
52+
}
53+
}, [open])
54+
55+
const current = mindmapThemes[value] || mindmapThemes.classic
56+
return (
57+
<div className="relative" ref={ref}>
58+
<button
59+
type="button"
60+
onClick={() => setOpen((v) => !v)}
61+
className="flex items-center gap-2 px-2 py-1 text-xs rounded-lg border border-border bg-surface hover:bg-surface-hover text-text-secondary"
62+
title="Mindmap theme"
63+
aria-label="Mindmap theme"
64+
>
65+
<span className="flex gap-0.5">
66+
{current.preview.map((c, i) => (
67+
<span
68+
key={i}
69+
className="w-2.5 h-2.5 rounded-full border border-border"
70+
style={{ background: c }}
71+
/>
72+
))}
73+
</span>
74+
<span>{current.name}</span>
75+
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true">
76+
<path d="M2 4l3 3 3-3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
77+
</svg>
78+
</button>
79+
{open && (
80+
<div className="absolute right-0 top-full mt-1 z-20 bg-surface border border-border rounded-xl shadow-lg p-1.5 min-w-[220px]">
81+
<div className="text-xs font-semibold text-text-secondary uppercase tracking-wider px-2 py-1">
82+
Mindmap theme
83+
</div>
84+
{Object.entries(mindmapThemes).map(([id, t]) => (
85+
<button
86+
key={id}
87+
type="button"
88+
onClick={() => {
89+
onChange(id)
90+
setOpen(false)
91+
}}
92+
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm text-left transition-colors ${
93+
value === id
94+
? 'bg-surface-hover text-text font-medium'
95+
: 'text-text-secondary hover:bg-surface-hover'
96+
}`}
97+
>
98+
<span className="flex gap-0.5 shrink-0">
99+
{t.preview.map((c, i) => (
100+
<span
101+
key={i}
102+
className="w-3 h-3 rounded-full border border-border"
103+
style={{ background: c }}
104+
/>
105+
))}
106+
</span>
107+
<span className="flex-1">{t.name}</span>
108+
{value === id && <span className="text-primary text-xs">&#10003;</span>}
109+
</button>
110+
))}
111+
</div>
112+
)}
113+
</div>
53114
)
54-
return [code, ...defs, ` linkStyle default stroke:${theme.border},stroke-width:1.5px`].join('\n')
55115
}
56116

57117
export default function MindmapView({ content, title }) {
58-
const ref = useRef(null)
59-
const [svg, setSvg] = useState('')
60-
const [renderError, setRenderError] = useState('')
61-
// Theme identifier is a proxy for "CSS vars changed": the store updates
62-
// `data-theme` on <html>, which swaps `--color-*` values. Include it in
63-
// the useMemo deps so a theme switch re-computes the embedded classDef.
64-
const themeId = useTheme((s) => s.theme)
118+
const mindmapTheme = useMindmapTheme((s) => s.theme)
119+
const setMindmapTheme = useMindmapTheme((s) => s.setTheme)
65120

66-
const { code, parseError } = useMemo(() => {
121+
const parsed = useMemo(() => {
67122
try {
68-
const base = renderMindmap(content || '', title || '')
69-
return { code: withThemeStyles(base, readTheme()), parseError: '' }
123+
return { tree: renderMindmap(content || '', title || ''), error: '' }
70124
} catch (e) {
71-
if (e instanceof MindmapParseError) return { code: '', parseError: e.message }
125+
if (e instanceof MindmapParseError) return { tree: null, error: e.message }
72126
throw e
73127
}
74-
// eslint-disable-next-line react-hooks/exhaustive-deps
75-
}, [content, title, themeId])
128+
}, [content, title])
76129

77-
useEffect(() => {
78-
if (parseError || !code) return
79-
const mermaid = ensureMermaid()
80-
const id = `mm-${Math.random().toString(36).slice(2)}`
81-
let cancelled = false
82-
mermaid
83-
.render(id, code)
84-
.then((res) => {
85-
if (!cancelled) {
86-
// Mermaid is initialized with `securityLevel: 'strict'`, which
87-
// runs DOMPurify over the generated SVG internally. Wrapping
88-
// again with `USE_PROFILES: { svg: true }` strips foreignObject
89-
// children and the labels vanish — matches MarkdownViewer.
90-
setSvg(res.svg)
91-
setRenderError('')
92-
}
93-
})
94-
.catch((err) => {
95-
if (!cancelled) {
96-
setSvg('')
97-
setRenderError(err?.message || 'Mermaid render failed')
98-
}
99-
})
100-
return () => {
101-
cancelled = true
102-
}
103-
}, [code, parseError])
130+
const layout = useMemo(
131+
() => (parsed.tree ? layoutMindmap(parsed.tree) : null),
132+
[parsed.tree],
133+
)
104134

105-
if (parseError) {
135+
if (parsed.error) {
106136
return (
107137
<div className="p-4 text-amber-800 bg-amber-50 border border-amber-200 rounded-lg">
108-
{parseError}
109-
</div>
110-
)
111-
}
112-
if (renderError) {
113-
return (
114-
<div className="p-4 text-red-700 bg-red-50 border border-red-200 rounded-lg">
115-
<div className="font-medium mb-1">Mermaid render error</div>
116-
<pre className="text-sm whitespace-pre-wrap">{renderError}</pre>
138+
{parsed.error}
117139
</div>
118140
)
119141
}
142+
if (!layout) return null
143+
144+
const palette = mindmapThemes[mindmapTheme] || mindmapThemes.classic
145+
const edgeStroke = palette.useWikiTheme ? 'var(--mindmap-edge)' : palette.edge
146+
120147
return (
121-
<div
122-
ref={ref}
123-
className="mindmap-container overflow-auto"
124-
dangerouslySetInnerHTML={{ __html: svg }}
125-
/>
148+
<div className="mindmap-container" data-mindmap-theme={mindmapTheme}>
149+
{/* Toolbar sits above the SVG in normal flow so it never covers the
150+
mindmap — absolute positioning over the diagram obscured root nodes
151+
on narrow screens. */}
152+
<div className="flex justify-end mb-3">
153+
<ThemeDropdown value={mindmapTheme} onChange={setMindmapTheme} />
154+
</div>
155+
<svg
156+
role="img"
157+
aria-label={title ? `Mindmap: ${title}` : 'Mindmap'}
158+
viewBox={layout.viewBox.join(' ')}
159+
width={layout.viewBox[2]}
160+
height={layout.viewBox[3]}
161+
style={{ display: 'block', maxWidth: '100%', height: 'auto' }}
162+
>
163+
<g className="mindmap-edges" fill="none" stroke={edgeStroke} strokeWidth="1.5">
164+
{layout.edges.map((e) => (
165+
<path key={e.id} d={e.d} />
166+
))}
167+
</g>
168+
<g className="mindmap-nodes" fontFamily={LAYOUT.FONT_FAMILY} fontSize={LAYOUT.FONT_SIZE}>
169+
{layout.nodes.map((n) => {
170+
const s = levelStyle(palette, n.depth)
171+
return (
172+
<g key={n.id} transform={`translate(${n.x},${n.y})`}>
173+
<rect
174+
x={-n.w / 2}
175+
y={-n.h / 2}
176+
width={n.w}
177+
height={n.h}
178+
rx="6"
179+
ry="6"
180+
fill={s.fill}
181+
stroke={s.stroke}
182+
strokeWidth="1.5"
183+
/>
184+
<text
185+
x="0"
186+
y="0"
187+
fill={s.text}
188+
textAnchor="middle"
189+
dominantBaseline="central"
190+
>
191+
{n.text}
192+
</text>
193+
</g>
194+
)
195+
})}
196+
</g>
197+
</svg>
198+
</div>
126199
)
127200
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe, it, expect, beforeEach } from 'vitest'
2+
import { render, screen, fireEvent, cleanup } from '@testing-library/react'
3+
import MindmapView from './MindmapView'
4+
import useMindmapTheme from '../store/useMindmapTheme'
5+
6+
beforeEach(() => {
7+
cleanup()
8+
// Reset mindmap-theme store between tests so palette selection from one
9+
// test doesn't leak into another.
10+
useMindmapTheme.setState({ theme: 'classic' })
11+
})
12+
13+
describe('MindmapView', () => {
14+
it('renders a rect per node and a path per edge', () => {
15+
const md = `# Root\n\n## A\n\n## B\n`
16+
const { container } = render(<MindmapView content={md} title="Ignored" />)
17+
// Scope to the mindmap SVG — the theme dropdown button has its own
18+
// chevron `<svg><path>` that we must not count.
19+
const mindmap = container.querySelector('svg[role="img"]')
20+
const rects = mindmap.querySelectorAll('rect')
21+
const paths = mindmap.querySelectorAll('.mindmap-edges path')
22+
// 3 nodes → 3 rects; 2 non-root edges → 2 paths.
23+
expect(rects.length).toBe(3)
24+
expect(paths.length).toBe(2)
25+
})
26+
27+
it('labels each node with the parsed text', () => {
28+
const md = `# Root\n\n## Child\n`
29+
const { container } = render(<MindmapView content={md} title="" />)
30+
const texts = [...container.querySelectorAll('svg text')].map(
31+
(el) => el.textContent,
32+
)
33+
expect(texts).toEqual(expect.arrayContaining(['Root', 'Child']))
34+
})
35+
36+
it('shows a friendly error panel when the markdown has no structure', () => {
37+
render(<MindmapView content="just prose" title="X" />)
38+
expect(
39+
screen.getByText(/ heading bullet list /),
40+
).toBeInTheDocument()
41+
})
42+
43+
it('lets the reader change the mindmap palette', async () => {
44+
const md = `# R\n\n## A\n`
45+
const { container } = render(<MindmapView content={md} title="" />)
46+
// Open the theme dropdown — the button is labelled with the current palette
47+
// name, so we locate it by aria-label.
48+
const trigger = screen.getByRole('button', { name: /mindmap theme/i })
49+
fireEvent.click(trigger)
50+
// Pick the Colorful palette.
51+
fireEvent.click(screen.getByRole('button', { name: /^Colorful/ }))
52+
// The root rect (first in document order) should now carry the Colorful
53+
// palette's lv0 fill instead of the classic CSS-var reference.
54+
const firstRect = container.querySelector('svg rect')
55+
expect(firstRect.getAttribute('fill')).toBe('#e76f51')
56+
// Wiki-theme dropdown is gone; subsequent render of the same view uses
57+
// the store's updated selection.
58+
expect(useMindmapTheme.getState().theme).toBe('colorful')
59+
})
60+
61+
it('uses CSS variables for the classic palette so the wiki theme wins', () => {
62+
const md = `# R\n\n## A\n`
63+
const { container } = render(<MindmapView content={md} title="" />)
64+
const firstRect = container.querySelector('svg rect')
65+
// Classic palette defers to `var(--mindmap-lv0-fill)` at the root.
66+
expect(firstRect.getAttribute('fill')).toBe('var(--mindmap-lv0-fill)')
67+
})
68+
69+
it('persists a chosen palette to localStorage', () => {
70+
const md = `# R\n\n## A\n`
71+
render(<MindmapView content={md} title="" />)
72+
fireEvent.click(screen.getByRole('button', { name: /mindmap theme/i }))
73+
fireEvent.click(screen.getByRole('button', { name: /^Pastel/ }))
74+
expect(localStorage.getItem('mindmapTheme')).toBe('pastel')
75+
})
76+
})

0 commit comments

Comments
 (0)