Skip to content

Commit 92d4a64

Browse files
PttCodingManclaude
andcommitted
feat: mindmap image thumbnails, equal-width layout, click-to-zoom, editor heading hierarchy.
- Mindmap nodes can now embed same-origin images (`![alt](/api/media/...)`) parsed from headings or trailing paragraphs; cross-origin / data: / scheme URLs are rejected. - Layout: every node at the same depth shares a width (per-depth equalization in addition to per-parent height alignment), so blocks on the same level read as visually flush regardless of their parent. - Lightbox: clicking a thumbnail in the mindmap or any image in the markdown viewer opens a full-screen overlay; click outside or press Esc to close. CSS class is shared (`image-lightbox-*`). - Editor: h1-h6 now have distinct sizes, weights, and a leading `#` / `##` / ... badge so heading depth is unambiguous even between consecutive levels. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ceaaa14 commit 92d4a64

8 files changed

Lines changed: 573 additions & 75 deletions

File tree

frontend/src/components/MindmapView.jsx

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ function ThemeDropdown({ value, onChange }) {
119119
export default function MindmapView({ content, title }) {
120120
const mindmapTheme = useMindmapTheme((s) => s.theme)
121121
const setMindmapTheme = useMindmapTheme((s) => s.setTheme)
122+
const [zoomed, setZoomed] = useState(null)
122123

123124
const parsed = useMemo(() => {
124125
try {
@@ -134,6 +135,15 @@ export default function MindmapView({ content, title }) {
134135
[parsed.tree],
135136
)
136137

138+
useEffect(() => {
139+
if (!zoomed) return
140+
const onKey = (e) => {
141+
if (e.key === 'Escape') setZoomed(null)
142+
}
143+
document.addEventListener('keydown', onKey)
144+
return () => document.removeEventListener('keydown', onKey)
145+
}, [zoomed])
146+
137147
if (parsed.error) {
138148
return (
139149
<div className="p-4 text-amber-800 bg-amber-50 border border-amber-200 rounded-lg">
@@ -170,6 +180,21 @@ export default function MindmapView({ content, title }) {
170180
<g className="mindmap-nodes" fontFamily={LAYOUT.FONT_FAMILY} fontSize={LAYOUT.FONT_SIZE}>
171181
{layout.nodes.map((n) => {
172182
const s = levelStyle(palette, n.depth)
183+
const hasImage = !!n.image
184+
const hasText = !!n.text
185+
// Layout image + text within the node's local frame (origin = rect
186+
// center). Image-and-text: a left-anchored block centered in the
187+
// rect, image on the left. Image-only: image centered. Text-only:
188+
// text centered (matches the legacy behavior).
189+
let imgX = 0
190+
let textX = 0
191+
if (hasImage && hasText) {
192+
const contentW = LAYOUT.IMG_SIZE + LAYOUT.IMG_GAP + n.textW
193+
imgX = -contentW / 2
194+
textX = imgX + LAYOUT.IMG_SIZE + LAYOUT.IMG_GAP + n.textW / 2
195+
} else if (hasImage) {
196+
imgX = -LAYOUT.IMG_SIZE / 2
197+
}
173198
return (
174199
<g key={n.id} transform={`translate(${n.x},${n.y})`}>
175200
<rect
@@ -183,20 +208,55 @@ export default function MindmapView({ content, title }) {
183208
stroke={s.stroke}
184209
strokeWidth="1.5"
185210
/>
186-
<text
187-
x="0"
188-
y="0"
189-
fill={s.text}
190-
textAnchor="middle"
191-
dominantBaseline="central"
192-
>
193-
{n.text}
194-
</text>
211+
{hasImage && (
212+
<image
213+
href={n.image.src}
214+
x={imgX}
215+
y={-LAYOUT.IMG_SIZE / 2}
216+
width={LAYOUT.IMG_SIZE}
217+
height={LAYOUT.IMG_SIZE}
218+
preserveAspectRatio="xMidYMid meet"
219+
style={{ cursor: 'zoom-in' }}
220+
onClick={(e) => {
221+
e.stopPropagation()
222+
setZoomed(n.image)
223+
}}
224+
>
225+
{n.image.alt ? <title>{n.image.alt}</title> : null}
226+
</image>
227+
)}
228+
{hasText && (
229+
<text
230+
x={textX}
231+
y="0"
232+
fill={s.text}
233+
textAnchor="middle"
234+
dominantBaseline="central"
235+
>
236+
{n.text}
237+
</text>
238+
)}
195239
</g>
196240
)
197241
})}
198242
</g>
199243
</svg>
244+
{zoomed && (
245+
<div
246+
className="image-lightbox-overlay"
247+
onClick={() => setZoomed(null)}
248+
role="dialog"
249+
aria-modal="true"
250+
aria-label={zoomed.alt || 'Image preview'}
251+
>
252+
<img
253+
src={zoomed.src}
254+
alt={zoomed.alt || ''}
255+
className="image-lightbox-image"
256+
onClick={(e) => e.stopPropagation()}
257+
/>
258+
</div>
259+
)}
200260
</div>
201261
)
202262
}

frontend/src/components/MindmapView.test.jsx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,37 @@ describe('MindmapView', () => {
6666
expect(firstRect.getAttribute('fill')).toBe('var(--mindmap-lv0-fill)')
6767
})
6868

69+
it('renders an SVG <image> for nodes with a same-origin image', () => {
70+
const md = `# Root\n\n## ![logo](/api/media/abc.png) Branded\n## Plain\n`
71+
const { container } = render(<MindmapView content={md} title="" />)
72+
const mindmap = container.querySelector('svg[role="img"]')
73+
const images = mindmap.querySelectorAll('image')
74+
expect(images.length).toBe(1)
75+
const img = images[0]
76+
// jsdom serializes the SVG `href` attribute as either `href` or `xlink:href`
77+
// depending on React version; either is acceptable here.
78+
expect(img.getAttribute('href') || img.getAttribute('xlink:href')).toBe(
79+
'/api/media/abc.png',
80+
)
81+
expect(img.getAttribute('preserveAspectRatio')).toBe('xMidYMid meet')
82+
})
83+
84+
it('omits the <image> element entirely when no image is parsed', () => {
85+
const md = `# R\n\n## A\n`
86+
const { container } = render(<MindmapView content={md} title="" />)
87+
const mindmap = container.querySelector('svg[role="img"]')
88+
expect(mindmap.querySelectorAll('image').length).toBe(0)
89+
})
90+
91+
it('drops cross-origin image URLs but still renders the surrounding text', () => {
92+
const md = `# R\n\n## ![bad](https://evil.example.com/x.png) Hello\n`
93+
const { container } = render(<MindmapView content={md} title="" />)
94+
const mindmap = container.querySelector('svg[role="img"]')
95+
expect(mindmap.querySelectorAll('image').length).toBe(0)
96+
const texts = [...mindmap.querySelectorAll('text')].map((t) => t.textContent)
97+
expect(texts).toContain('Hello')
98+
})
99+
69100
it('persists a chosen palette to localStorage', () => {
70101
const md = `# R\n\n## A\n`
71102
render(<MindmapView content={md} title="" />)

frontend/src/components/Viewer/MarkdownViewer.jsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export default function MarkdownViewer({
7373
const containerRef = useRef(null)
7474
const navigate = useNavigate()
7575
const [lightboxSvg, setLightboxSvg] = useState(null)
76+
const [lightboxImg, setLightboxImg] = useState(null)
7677

7778
// React 19 re-applies dangerouslySetInnerHTML on every re-render even when the
7879
// string is referentially equal, which wipes the Mermaid SVGs and transcluded
@@ -83,6 +84,18 @@ export default function MarkdownViewer({
8384
if (containerRef.current) containerRef.current.innerHTML = html
8485
}, [html])
8586

87+
useEffect(() => {
88+
if (!lightboxSvg && !lightboxImg) return
89+
const onKey = (e) => {
90+
if (e.key === 'Escape') {
91+
setLightboxSvg(null)
92+
setLightboxImg(null)
93+
}
94+
}
95+
document.addEventListener('keydown', onKey)
96+
return () => document.removeEventListener('keydown', onKey)
97+
}, [lightboxSvg, lightboxImg])
98+
8699
// Extract h1-h3 headings for the TOC. Runs after DOM is populated.
87100
useEffect(() => {
88101
if (!onHeadings || !containerRef.current) return
@@ -106,6 +119,22 @@ export default function MarkdownViewer({
106119
return
107120
}
108121

122+
// Inline image click → open lightbox. Skip when the image is wrapped in
123+
// a link (the link wins) or sits inside a draw.io / mermaid block (those
124+
// have their own handlers right below).
125+
if (
126+
e.target.tagName === 'IMG' &&
127+
!e.target.closest('a') &&
128+
!e.target.closest('.drawio-embed') &&
129+
!e.target.closest('[data-mermaid]')
130+
) {
131+
const src = e.target.getAttribute('src')
132+
if (src) {
133+
setLightboxImg({ src, alt: e.target.getAttribute('alt') || '' })
134+
return
135+
}
136+
}
137+
109138
// Draw.io diagram click
110139
const diagram = e.target.closest('.drawio-embed')
111140
if (diagram) {
@@ -245,6 +274,22 @@ export default function MarkdownViewer({
245274
/>
246275
</div>
247276
)}
277+
{lightboxImg && (
278+
<div
279+
className="image-lightbox-overlay"
280+
onClick={() => setLightboxImg(null)}
281+
role="dialog"
282+
aria-modal="true"
283+
aria-label={lightboxImg.alt || 'Image preview'}
284+
>
285+
<img
286+
src={lightboxImg.src}
287+
alt={lightboxImg.alt}
288+
className="image-lightbox-image"
289+
onClick={(e) => e.stopPropagation()}
290+
/>
291+
</div>
292+
)}
248293
</>
249294
)
250295
}

frontend/src/index.css

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -190,9 +190,67 @@ body {
190190
margin: 0.5em 0;
191191
}
192192

193-
.milkdown .editor h1 { font-size: 2em; font-weight: 700; margin: 0.67em 0; }
194-
.milkdown .editor h2 { font-size: 1.5em; font-weight: 600; margin: 0.75em 0; }
195-
.milkdown .editor h3 { font-size: 1.25em; font-weight: 600; margin: 0.75em 0; }
193+
.milkdown .editor :is(h1, h2, h3, h4, h5, h6)::before {
194+
display: inline-block;
195+
margin-right: 0.5em;
196+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
197+
font-weight: 600;
198+
color: var(--color-text-secondary);
199+
opacity: 0.6;
200+
font-size: 0.7em;
201+
vertical-align: 0.25em;
202+
user-select: none;
203+
}
204+
.milkdown .editor h1 {
205+
font-size: 2.2em;
206+
font-weight: 800;
207+
margin: 0.67em 0 0.5em;
208+
padding-bottom: 0.2em;
209+
border-bottom: 2px solid var(--color-border);
210+
line-height: 1.2;
211+
}
212+
.milkdown .editor h1::before { content: "#"; }
213+
.milkdown .editor h2 {
214+
font-size: 1.7em;
215+
font-weight: 700;
216+
margin: 1em 0 0.5em;
217+
padding-bottom: 0.15em;
218+
border-bottom: 1px solid var(--color-border);
219+
line-height: 1.25;
220+
}
221+
.milkdown .editor h2::before { content: "##"; }
222+
.milkdown .editor h3 {
223+
font-size: 1.4em;
224+
font-weight: 700;
225+
margin: 0.9em 0 0.4em;
226+
line-height: 1.3;
227+
}
228+
.milkdown .editor h3::before { content: "###"; }
229+
.milkdown .editor h4 {
230+
font-size: 1.18em;
231+
font-weight: 700;
232+
margin: 0.85em 0 0.35em;
233+
line-height: 1.35;
234+
}
235+
.milkdown .editor h4::before { content: "####"; }
236+
.milkdown .editor h5 {
237+
font-size: 1.05em;
238+
font-weight: 700;
239+
margin: 0.8em 0 0.3em;
240+
color: var(--color-text-secondary);
241+
line-height: 1.4;
242+
}
243+
.milkdown .editor h5::before { content: "#####"; }
244+
.milkdown .editor h6 {
245+
font-size: 0.95em;
246+
font-weight: 700;
247+
margin: 0.8em 0 0.3em;
248+
text-transform: uppercase;
249+
letter-spacing: 0.05em;
250+
color: var(--color-text-secondary);
251+
line-height: 1.4;
252+
}
253+
.milkdown .editor h6::before { content: "######"; }
196254

197255
.milkdown .editor ul {
198256
padding-left: 1.5em;
@@ -458,7 +516,8 @@ mark {
458516
.markdown-viewer code { background: var(--color-surface-hover); padding: 0.15em 0.4em; border-radius: 3px; font-size: 0.9em; }
459517
.markdown-viewer pre { background: #1e293b; color: #e2e8f0; padding: 1em; border-radius: 8px; overflow-x: auto; }
460518
.markdown-viewer pre code { background: none; padding: 0; color: inherit; }
461-
.markdown-viewer img { max-width: 100%; border-radius: 8px; }
519+
.markdown-viewer img { max-width: 100%; border-radius: 8px; cursor: zoom-in; }
520+
.markdown-viewer a img { cursor: pointer; }
462521
.markdown-viewer a { color: var(--color-primary); text-decoration: underline; }
463522
.markdown-viewer table { border-collapse: collapse; width: 100%; margin: 0.75em 0; }
464523
.markdown-viewer th, .markdown-viewer td { border: 1px solid var(--color-border); padding: 0.5em 0.75em; }
@@ -607,6 +666,28 @@ mark {
607666
border-radius: 8px;
608667
}
609668

669+
/* Image zoom lightbox (mindmap thumbnails + viewer images) */
670+
.image-lightbox-overlay {
671+
position: fixed;
672+
inset: 0;
673+
background: rgba(0, 0, 0, 0.8);
674+
z-index: 1000;
675+
display: flex;
676+
align-items: center;
677+
justify-content: center;
678+
cursor: zoom-out;
679+
padding: 2rem;
680+
}
681+
.image-lightbox-image {
682+
max-width: 90vw;
683+
max-height: 90vh;
684+
object-fit: contain;
685+
border-radius: 8px;
686+
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
687+
cursor: default;
688+
background: white;
689+
}
690+
610691
/* Draw.io zoom lightbox */
611692
.drawio-lightbox-overlay {
612693
position: fixed;

0 commit comments

Comments
 (0)