` blocks are annotated (not wrapped) via `dataMermaidHighlight` on the `` element — MarkdownViewer reads this to add CSS highlight classes without changing tree structure, preserving the imperatively-rendered SVG. When a highlight's `startLine` falls on a mermaid block, the plugin annotates the mermaid and schedules the full `selectedText` for continuation with `mermaidCrossed: true`, which relaxes Strategy 3 matching thresholds so post-mermaid text is found despite SVG label pollution in `remaining`. Highlighted mermaid containers render with a yellow `border` + `box-shadow` instead of a background overlay. `MermaidSelection` detects when a drag leaves the container bounds and transitions from SVG rect mode to a programmatic text selection (via `Selection.setBaseAndExtent` + `Selection.extend`) that includes the whole diagram, handing off to `SelectionToolbar` on mouseup.
+ ← [P-PENPAL-HIGHLIGHT-MEDIA](PRODUCT.md#P-PENPAL-HIGHLIGHT-MEDIA)
+
---
## Mermaid Diagram Anchoring
-- **E-PENPAL-SVG-DRAG**: `MermaidSelection` handles drag on `.mermaid-container` elements. After a 5px movement threshold, a live `.penpal-pending-svg-highlight` rect tracks the selection. On mouseup, SVG coordinates are computed.
+- **E-PENPAL-SVG-DRAG**: `MermaidSelection` handles drag on `.mermaid-container` elements. After a 5px movement threshold, a live `.penpal-pending-svg-highlight` rect tracks the selection. On mouseup, SVG coordinates are computed. If the mouse leaves the container bounds during drag, SVG rect mode is cancelled and the drag transitions to a text selection that includes the whole diagram (see [E-PENPAL-HIGHLIGHT-MEDIA](#E-PENPAL-HIGHLIGHT-MEDIA)).
← [P-PENPAL-DIAGRAM-SELECT](PRODUCT.md#P-PENPAL-DIAGRAM-SELECT)
- **E-PENPAL-SVG-EXTRACT**: The SVG snippet is extracted by cloning the SVG, setting a cropped `viewBox`, scaling to max 300x200px, and re-IDing all elements with a random prefix to prevent DOM ID collisions. CSS `url(#id)` and `href="#id"` references are rewritten.
diff --git a/apps/penpal/PRODUCT.md b/apps/penpal/PRODUCT.md
index 4e4a831f..5f132a7a 100644
--- a/apps/penpal/PRODUCT.md
+++ b/apps/penpal/PRODUCT.md
@@ -236,6 +236,8 @@ Global views aggregate content across all projects. They appear as top-level ite
- **P-PENPAL-HIGHLIGHT-CROSS**: A single highlight can span across formatting boundaries — bold, italic, code, links, and other inline markup — as well as cross between code blocks and surrounding prose.
+- **P-PENPAL-HIGHLIGHT-MEDIA**: A highlight that intersects an image or mermaid diagram expands to encompass the entire media element. Selecting text that spans into, through, or starting within a diagram highlights the diagram with a visible yellow border and highlights the adjacent text normally. Dragging a selection out of a mermaid diagram transitions from rectangle selection to text selection, including the whole diagram in the resulting highlight.
+
---
## Mermaid Diagram Comments
diff --git a/apps/penpal/TESTING.md b/apps/penpal/TESTING.md
index 64fb1c1c..a184ebaf 100644
--- a/apps/penpal/TESTING.md
+++ b/apps/penpal/TESTING.md
@@ -75,6 +75,7 @@ see-also:
| Anchor Resolution (P-PENPAL-ANCHOR-RESOLVE) | comments_test.go (implicit via round-trip) | rehypeCommentHighlights.test.ts | — | — |
| Comment Highlights (P-PENPAL-HIGHLIGHT) | — | rehypeCommentHighlights.test.ts, MarkdownViewer.test.tsx | — | review-workflow.spec.ts |
| Cross-Element Highlights (P-PENPAL-HIGHLIGHT-CROSS) | — | rehypeCommentHighlights.test.ts | — | — |
+| Media Highlight Expansion (P-PENPAL-HIGHLIGHT-MEDIA) | — | rehypeCommentHighlights.test.ts, MarkdownViewer.test.tsx | — | mermaid-comments.spec.ts |
| Mermaid Diagram Comments (P-PENPAL-DIAGRAM-SELECT) | — | — | — | mermaid-comments.spec.ts |
| Comment Threads (P-PENPAL-THREAD-PANEL, REPLY, STATES) | comments_test.go | CommentsPanel.test.tsx | api_threads_test.go | review-workflow.spec.ts |
| Comment Ordering (E-PENPAL-COMMENT-ORDER) | ordering_test.go | utils/comments.test.ts | — | — |
diff --git a/apps/penpal/e2e/tests/mermaid-comments.spec.ts b/apps/penpal/e2e/tests/mermaid-comments.spec.ts
index a817444d..59c4a8ba 100644
--- a/apps/penpal/e2e/tests/mermaid-comments.spec.ts
+++ b/apps/penpal/e2e/tests/mermaid-comments.spec.ts
@@ -217,6 +217,111 @@ test.describe('mermaid diagram commenting', () => {
await expect(highlight).not.toBeAttached({ timeout: 5000 });
});
+ // E-PENPAL-HIGHLIGHT-MEDIA: verifies dragging out of a mermaid diagram cancels SVG rect
+ // and transitions to text selection that includes the whole diagram.
+ test('dragging out of mermaid diagram switches to text selection', async ({ page }) => {
+ await blockPendingNavigation(page);
+ await page.goto(`/file/${projectName}/${filePath}`);
+
+ // Wait for mermaid to render
+ const container = page.locator('.mermaid-container');
+ await expect(container).toBeVisible({ timeout: 10000 });
+ const svg = container.locator('svg');
+ await expect(svg).toBeVisible();
+
+ const svgBox = await svg.boundingBox();
+ expect(svgBox).toBeTruthy();
+
+ // Find the "Text after" paragraph below the diagram
+ const afterText = page.locator('p', { hasText: 'Text after the diagram' });
+ await expect(afterText).toBeVisible();
+ const afterBox = await afterText.boundingBox();
+ expect(afterBox).toBeTruthy();
+
+ // Start drag inside the SVG (center area)
+ const startX = svgBox!.x + svgBox!.width * 0.5;
+ const startY = svgBox!.y + svgBox!.height * 0.5;
+ await page.mouse.move(startX, startY);
+ await page.mouse.down();
+
+ // Move past the 5px threshold (still inside SVG) to enter SVG rect mode
+ await page.mouse.move(startX + 10, startY + 10, { steps: 2 });
+
+ // Verify we entered SVG rect mode — pending rect should exist
+ const pendingRect = svg.locator('.penpal-pending-svg-highlight');
+ await expect(pendingRect).toBeAttached();
+
+ // Now drag below the container — this should trigger escape to text selection
+ const endX = afterBox!.x + afterBox!.width * 0.5;
+ const endY = afterBox!.y + afterBox!.height * 0.5;
+ await page.mouse.move(endX, endY, { steps: 5 });
+
+ // The SVG pending rect should be gone (cancelled on escape)
+ await expect(pendingRect).not.toBeAttached({ timeout: 2000 });
+
+ // Release the mouse on the content area to trigger SelectionToolbar
+ await page.mouse.up();
+
+ // A text selection should exist that includes the mermaid content
+ const selectionText = await page.evaluate(() => window.getSelection()?.toString().trim() ?? '');
+ expect(selectionText.length).toBeGreaterThan(0);
+
+ // The selection toolbar should appear
+ const toolbar = page.locator('.selection-toolbar');
+ await expect(toolbar).toBeVisible({ timeout: 5000 });
+ await expect(toolbar.locator('button', { hasText: 'Comment' })).toBeVisible();
+ });
+
+ // E-PENPAL-HIGHLIGHT-MEDIA: verifies dragging upward out of a mermaid diagram
+ // creates a text selection anchored at the end of the container.
+ test('dragging upward out of mermaid diagram switches to text selection', async ({ page }) => {
+ await blockPendingNavigation(page);
+ await page.goto(`/file/${projectName}/${filePath}`);
+
+ const container = page.locator('.mermaid-container');
+ await expect(container).toBeVisible({ timeout: 10000 });
+ const svg = container.locator('svg');
+ await expect(svg).toBeVisible();
+
+ const svgBox = await svg.boundingBox();
+ expect(svgBox).toBeTruthy();
+
+ // Find the "intro text" paragraph above the diagram
+ const beforeText = page.locator('p', { hasText: 'intro text before' });
+ await expect(beforeText).toBeVisible();
+ const beforeBox = await beforeText.boundingBox();
+ expect(beforeBox).toBeTruthy();
+
+ // Start drag inside the SVG (center area)
+ const startX = svgBox!.x + svgBox!.width * 0.5;
+ const startY = svgBox!.y + svgBox!.height * 0.5;
+ await page.mouse.move(startX, startY);
+ await page.mouse.down();
+
+ // Move past 5px threshold to enter SVG rect mode
+ await page.mouse.move(startX - 10, startY - 10, { steps: 2 });
+ const pendingRect = svg.locator('.penpal-pending-svg-highlight');
+ await expect(pendingRect).toBeAttached();
+
+ // Drag upward above the container into the text before
+ const endX = beforeBox!.x + beforeBox!.width * 0.5;
+ const endY = beforeBox!.y + beforeBox!.height * 0.5;
+ await page.mouse.move(endX, endY, { steps: 5 });
+
+ // SVG rect should be cancelled
+ await expect(pendingRect).not.toBeAttached({ timeout: 2000 });
+
+ await page.mouse.up();
+
+ // Text selection should exist
+ const selectionText = await page.evaluate(() => window.getSelection()?.toString().trim() ?? '');
+ expect(selectionText.length).toBeGreaterThan(0);
+
+ // SelectionToolbar should appear
+ const toolbar = page.locator('.selection-toolbar');
+ await expect(toolbar).toBeVisible({ timeout: 5000 });
+ });
+
// E-PENPAL-MD-RENDER: verifies normal text selection toolbar works alongside mermaid diagrams.
test('normal text selection still works alongside diagrams', async ({ page }) => {
await blockPendingNavigation(page);
diff --git a/apps/penpal/frontend/src/components/MarkdownViewer.test.tsx b/apps/penpal/frontend/src/components/MarkdownViewer.test.tsx
index 773a2fc5..b376b1c5 100644
--- a/apps/penpal/frontend/src/components/MarkdownViewer.test.tsx
+++ b/apps/penpal/frontend/src/components/MarkdownViewer.test.tsx
@@ -164,6 +164,34 @@ describe('MarkdownViewer', () => {
expect(mermaidAfter).toBe(mermaidBefore);
});
+ // E-PENPAL-HIGHLIGHT-MEDIA: mermaid container gets highlight class via annotation
+ it('applies comment-highlight class to mermaid container when highlight spans into it', () => {
+ const md = 'Before text\n\n```mermaid\ngraph TD\n A --> B\n```';
+ const highlights = [
+ { threadId: 't-media', selectedText: 'Before text A B', startLine: 1 },
+ ];
+ const { container } = render(
+ ,
+ );
+ const mermaidContainer = container.querySelector('.mermaid-container.comment-highlight');
+ expect(mermaidContainer).not.toBeNull();
+ expect(mermaidContainer?.getAttribute('data-thread-id')).toBe('t-media');
+ });
+
+ // E-PENPAL-HIGHLIGHT-MEDIA: pending mermaid container gets pending-highlight class
+ it('applies pending-highlight class to mermaid container for pending highlights', () => {
+ const md = 'Before text\n\n```mermaid\ngraph TD\n A --> B\n```';
+ const highlights = [
+ { threadId: 'pending', selectedText: 'Before text A B', startLine: 1, pending: true },
+ ];
+ const { container } = render(
+ ,
+ );
+ const mermaidContainer = container.querySelector('.mermaid-container.pending-highlight');
+ expect(mermaidContainer).not.toBeNull();
+ expect(mermaidContainer?.classList.contains('comment-highlight')).toBe(true);
+ });
+
it('renders pending highlights with pending-highlight class', () => {
const md = 'Hello world';
const highlights = [
diff --git a/apps/penpal/frontend/src/components/MarkdownViewer.tsx b/apps/penpal/frontend/src/components/MarkdownViewer.tsx
index dfe5852f..e25623d3 100644
--- a/apps/penpal/frontend/src/components/MarkdownViewer.tsx
+++ b/apps/penpal/frontend/src/components/MarkdownViewer.tsx
@@ -230,12 +230,31 @@ const MarkdownViewer = forwardRef(
// Use AST node position to set data-source-line at render time,
// matching how the Go template sets it server-side.
const sourceLine = node?.position?.start?.line;
+
+ // E-PENPAL-HIGHLIGHT-MEDIA: read highlight annotation from rehype plugin.
+ // Applied as className (not a wrapper) to avoid changing tree
+ // structure, which would cause React to recreate the DOM node and lose
+ // the imperatively-rendered mermaid SVG.
+ let highlightClass = 'mermaid-container';
+ let highlightThreadId: string | undefined;
+ const mermaidHighlightRaw = node?.properties?.dataMermaidHighlight;
+ if (typeof mermaidHighlightRaw === 'string') {
+ try {
+ const parsed = JSON.parse(mermaidHighlightRaw) as { threadId: string; pending?: boolean };
+ highlightClass += parsed.pending
+ ? ' comment-highlight pending-highlight'
+ : ' comment-highlight';
+ highlightThreadId = parsed.threadId;
+ } catch { /* ignore malformed */ }
+ }
+
return (
{children}
diff --git a/apps/penpal/frontend/src/components/MermaidSelection.tsx b/apps/penpal/frontend/src/components/MermaidSelection.tsx
index bcce63db..f69cb571 100644
--- a/apps/penpal/frontend/src/components/MermaidSelection.tsx
+++ b/apps/penpal/frontend/src/components/MermaidSelection.tsx
@@ -189,6 +189,74 @@ export default function MermaidSelection({
const dy = e2.clientY - startY;
if (!dragging && Math.sqrt(dx * dx + dy * dy) < 5) return;
+ // E-PENPAL-HIGHLIGHT-MEDIA: when mouse leaves the mermaid container
+ // during drag, cancel SVG rect mode and switch to text selection
+ // that includes the whole diagram plus whatever text the user drags to.
+ const containerRect = containerEl.getBoundingClientRect();
+ const outsideContainer =
+ e2.clientX < containerRect.left || e2.clientX > containerRect.right ||
+ e2.clientY < containerRect.top || e2.clientY > containerRect.bottom;
+
+ if (outsideContainer) {
+ if (!dragging) {
+ // Never entered SVG mode — just cancel our tracking and let browser handle
+ document.removeEventListener('mousemove', onMouseMove);
+ document.removeEventListener('mouseup', onMouseUp);
+ dragActiveRef.current = false;
+ if (draggingRef) draggingRef.current = false;
+ return;
+ }
+
+ // Was in SVG mode — switch to text selection including the whole diagram
+ if (overlayRect) { overlayRect.remove(); overlayRect = null; }
+ containerEl.style.userSelect = '';
+ dragging = false;
+
+ // Create a text selection from the mermaid container to the mouse position
+ const sel = window.getSelection();
+ sel?.removeAllRanges();
+ const caretRange = document.caretRangeFromPoint(e2.clientX, e2.clientY);
+
+ if (sel && caretRange) {
+ const isAbove = e2.clientY < containerRect.top;
+ if (isAbove) {
+ // Dragging up: anchor at end of container, focus at mouse position
+ sel.setBaseAndExtent(
+ containerEl, containerEl.childNodes.length,
+ caretRange.startContainer, caretRange.startOffset,
+ );
+ } else {
+ // Dragging down/sideways: anchor at start of container, focus at mouse position
+ sel.setBaseAndExtent(
+ containerEl, 0,
+ caretRange.startContainer, caretRange.startOffset,
+ );
+ }
+ }
+
+ // Replace SVG handlers with text-extension handlers
+ document.removeEventListener('mousemove', onMouseMove);
+ document.removeEventListener('mouseup', onMouseUp);
+
+ const onTextMove = (e3: MouseEvent) => {
+ const cr = document.caretRangeFromPoint(e3.clientX, e3.clientY);
+ if (cr && sel) {
+ try { sel.extend(cr.startContainer, cr.startOffset); } catch { /* ignore */ }
+ }
+ };
+ const onTextUp = () => {
+ document.removeEventListener('mousemove', onTextMove);
+ document.removeEventListener('mouseup', onTextUp);
+ dragActiveRef.current = false;
+ if (draggingRef) draggingRef.current = false;
+ // SelectionToolbar's mouseup handler on contentEl will fire and
+ // show the comment button for the text+diagram selection.
+ };
+ document.addEventListener('mousemove', onTextMove);
+ document.addEventListener('mouseup', onTextUp);
+ return;
+ }
+
if (!dragging) {
dragging = true;
dragActiveRef.current = true;
diff --git a/apps/penpal/frontend/src/components/rehypeCommentHighlights.test.ts b/apps/penpal/frontend/src/components/rehypeCommentHighlights.test.ts
index a10c7a6a..7665504b 100644
--- a/apps/penpal/frontend/src/components/rehypeCommentHighlights.test.ts
+++ b/apps/penpal/frontend/src/components/rehypeCommentHighlights.test.ts
@@ -60,6 +60,44 @@ function makeParagraph(line: number, text: string): Element {
} as Element;
}
+/** Build a 
block-level image paragraph */
+function makeImgParagraph(line: number, src: string): Element {
+ return {
+ type: 'element',
+ tagName: 'p',
+ properties: {},
+ children: [
+ {
+ type: 'element',
+ tagName: 'img',
+ properties: { src },
+ children: [],
+ } as Element,
+ ],
+ position: { start: { line, column: 1, offset: 0 }, end: { line, column: 1, offset: 0 } },
+ } as Element;
+}
+
+/** Build a text
text
with inline image */
+function makeParagraphWithImg(line: number, textBefore: string, imgSrc: string, textAfter: string): Element {
+ return {
+ type: 'element',
+ tagName: 'p',
+ properties: {},
+ children: [
+ { type: 'text', value: textBefore } as Text,
+ {
+ type: 'element',
+ tagName: 'img',
+ properties: { src: imgSrc },
+ children: [],
+ } as Element,
+ { type: 'text', value: textAfter } as Text,
+ ],
+ position: { start: { line, column: 1, offset: 0 }, end: { line, column: 1 + textBefore.length + textAfter.length, offset: textBefore.length + textAfter.length } },
+ } as Element;
+}
+
// E-PENPAL-HIGHLIGHT-REHYPE: verifies injection, pending class, line matching, and text splitting.
describe('rehypeCommentHighlights', () => {
it('wraps matching text in a with comment-highlight class', () => {
@@ -481,6 +519,372 @@ describe('rehypeCommentHighlights', () => {
});
});
+ // E-PENPAL-HIGHLIGHT-MEDIA: media wrapping tests
+ describe('media highlight wrapping', () => {
+ it('inline image wrapped when between marks with same threadId', () => {
+ // Hello
world
— highlight covers "Hello" and "world"
+ const tree: Root = {
+ type: 'root',
+ children: [makeParagraphWithImg(1, 'Hello ', 'test.png', ' world')],
+ };
+ const transform = rehypeCommentHighlights({
+ highlights: [{ threadId: 't1', selectedText: 'Hello world', startLine: 1 }],
+ });
+ transform(tree);
+
+ const marks = findMarks(tree);
+ // Should have 3 marks: "Hello ",
wrapper, " world"
+ expect(marks).toHaveLength(3);
+ // The middle mark should wrap the img
+ const imgMark = marks.find(m =>
+ m.children.some(c => c.type === 'element' && (c as Element).tagName === 'img')
+ );
+ expect(imgMark).toBeDefined();
+ expect((imgMark!.properties as Record).dataThreadId).toBe('t1');
+ });
+
+ it('block-level image wrapped during continuation', () => {
+ // First
![]()
Last
+ const tree: Root = {
+ type: 'root',
+ children: [
+ makeParagraph(1, 'First paragraph text'),
+ makeImgParagraph(3, 'diagram.png'),
+ makeParagraph(5, 'Last paragraph text'),
+ ],
+ };
+ const transform = rehypeCommentHighlights({
+ highlights: [{
+ threadId: 't1',
+ selectedText: 'First paragraph text Last paragraph text',
+ startLine: 1,
+ }],
+ });
+ transform(tree);
+
+ // The image paragraph should be wrapped in a
+ const rootChildren = tree.children;
+ const imgContainer = rootChildren[1];
+ expect(imgContainer.type).toBe('element');
+ expect((imgContainer as Element).tagName).toBe('mark');
+ expect((imgContainer as Element).properties?.dataThreadId).toBe('t1');
+ // The ![]()
should be inside the mark
+ const innerP = (imgContainer as Element).children[0] as Element;
+ expect(innerP.tagName).toBe('p');
+ expect((innerP.children[0] as Element).tagName).toBe('img');
+
+ // First and last paragraphs should also have marks
+ const marks = findMarks(tree);
+ expect(marks.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it('mermaid block annotated (not wrapped) during continuation', () => {
+ // Before
...
After
+ // Mermaid blocks can't be wrapped in because it changes tree structure,
+ // causing React to recreate DOM nodes and lose imperatively-rendered SVG.
+ // Instead, the element gets a dataMermaidHighlight annotation.
+ const tree: Root = {
+ type: 'root',
+ children: [
+ makeParagraph(1, 'Before mermaid text'),
+ makePreCode(3, 'mermaid', 'graph TD\n A-->B\n'),
+ makeParagraph(7, 'After mermaid text'),
+ ],
+ };
+ const transform = rehypeCommentHighlights({
+ highlights: [{
+ threadId: 't1',
+ selectedText: 'Before mermaid text After mermaid text',
+ startLine: 1,
+ }],
+ });
+ transform(tree);
+
+ // The mermaid should NOT be wrapped — tree structure unchanged
+ const pre = tree.children[1] as Element;
+ expect(pre.tagName).toBe('pre');
+
+ // The element should have dataMermaidHighlight annotation
+ const code = pre.children[0] as Element;
+ const raw = code.properties?.dataMermaidHighlight;
+ expect(raw).toBeDefined();
+ const parsed = JSON.parse(String(raw)) as { threadId: string; pending?: boolean };
+ expect(parsed.threadId).toBe('t1');
+
+ // Before and after paragraphs should also have marks
+ const proseMarks = findMarks(tree).filter(m =>
+ m.children.some(c => c.type === 'text')
+ );
+ expect(proseMarks.length).toBeGreaterThanOrEqual(2);
+ });
+
+ it('image NOT wrapped when highlight is only on one side', () => {
+ // Hello
world
— highlight covers only "Hello"
+ const tree: Root = {
+ type: 'root',
+ children: [makeParagraphWithImg(1, 'Hello ', 'test.png', ' world')],
+ };
+ const transform = rehypeCommentHighlights({
+ highlights: [{ threadId: 't1', selectedText: 'Hello', startLine: 1 }],
+ });
+ transform(tree);
+
+ const marks = findMarks(tree);
+ // Should have 1 mark for "Hello" only — img should NOT be wrapped
+ expect(marks).toHaveLength(1);
+ expect((marks[0].children[0] as Text).value).toBe('Hello');
+
+ // The img should still be a direct child of the , not wrapped in
+ const p = tree.children[0] as Element;
+ const hasDirectImg = p.children.some(
+ c => c.type === 'element' && (c as Element).tagName === 'img'
+ );
+ expect(hasDirectImg).toBe(true);
+ });
+
+ it('mermaid annotation carries pending flag', () => {
+ const tree: Root = {
+ type: 'root',
+ children: [
+ makeParagraph(1, 'Before text'),
+ makePreCode(3, 'mermaid', 'graph TD\n A-->B\n'),
+ makeParagraph(7, 'After text'),
+ ],
+ };
+ const transform = rehypeCommentHighlights({
+ highlights: [{
+ threadId: 't1',
+ selectedText: 'Before text After text',
+ startLine: 1,
+ pending: true,
+ }],
+ });
+ transform(tree);
+
+ const pre = tree.children[1] as Element;
+ const code = pre.children[0] as Element;
+ const parsed = JSON.parse(String(code.properties?.dataMermaidHighlight)) as { threadId: string; pending?: boolean };
+ expect(parsed.pending).toBe(true);
+ });
+
+ it('continuation works past mermaid when selectedText has SVG text', () => {
+ // Real scenario: user selects from prose through a mermaid diagram to prose
+ // after. Selection captures SVG node labels (not mermaid source), so the
+ // remaining after matching the first paragraph contains junk SVG text
+ // before the post-mermaid paragraph text.
+ const tree: Root = {
+ type: 'root',
+ children: [
+ makeParagraph(1, 'Before mermaid text'),
+ makePreCode(3, 'mermaid', 'graph TD\n A[Start]-->B[End]\n'),
+ makeParagraph(7, 'After mermaid text here'),
+ ],
+ };
+ const transform = rehypeCommentHighlights({
+ highlights: [{
+ threadId: 't1',
+ // Simulates sel.toString(): prose + SVG labels + post-prose
+ selectedText: 'Before mermaid text Start End After mermaid text here',
+ startLine: 1,
+ }],
+ });
+ transform(tree);
+
+ // First paragraph should be highlighted
+ const firstPMarks = findMarks(tree.children[0] as Element);
+ expect(firstPMarks.length).toBeGreaterThanOrEqual(1);
+
+ // Mermaid block should be annotated
+ const pre = tree.children[1] as Element;
+ const code = pre.children[0] as Element;
+ expect(code.properties?.dataMermaidHighlight).toBeDefined();
+
+ // Last paragraph should also be highlighted via continuation
+ const lastP = tree.children[2];
+ // lastP might be wrapped in (block-level) or have inner marks
+ const lastPMarks = lastP.type === 'element' && (lastP as Element).tagName === 'mark'
+ ? [lastP as Element]
+ : findMarks(lastP as Element);
+ expect(lastPMarks.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('short text after mermaid is highlighted via mermaidCrossed flag', () => {
+ // When SVG text is long relative to post-mermaid text, the normal
+ // Strategy 3 thresholds reject the match. mermaidCrossed lowers them.
+ const tree: Root = {
+ type: 'root',
+ children: [
+ makeParagraph(1, 'Intro text'),
+ makePreCode(3, 'mermaid', 'graph TD\n A[Login]-->B[Dashboard]-->C[Settings]-->D[Profile]\n'),
+ makeParagraph(8, 'Done'),
+ ],
+ };
+ const transform = rehypeCommentHighlights({
+ highlights: [{
+ threadId: 't1',
+ // SVG labels are long, post-mermaid text is short
+ selectedText: 'Intro text Login Dashboard Settings Profile Done',
+ startLine: 1,
+ }],
+ });
+ transform(tree);
+
+ const lastP = tree.children[2];
+ const lastPMarks = lastP.type === 'element' && (lastP as Element).tagName === 'mark'
+ ? [lastP as Element]
+ : findMarks(lastP as Element);
+ expect(lastPMarks.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('highlight starting at mermaid annotates it and continues to text after', () => {
+ // User drags selection from text below UP into a preceding mermaid diagram.
+ // startLine lands on the mermaid block. The rehype plugin must annotate
+ // the mermaid and set up continuation so the text after is also highlighted.
+ const tree: Root = {
+ type: 'root',
+ children: [
+ makePreCode(1, 'mermaid', 'graph TD\n A[Start]-->B[End]\n'),
+ makeParagraph(5, 'Text after the diagram'),
+ ],
+ };
+ const transform = rehypeCommentHighlights({
+ highlights: [{
+ threadId: 't1',
+ // sel.toString() captures SVG labels + text after
+ selectedText: 'Start End Text after the diagram',
+ startLine: 1,
+ }],
+ });
+ transform(tree);
+
+ // Mermaid block should be annotated
+ const pre = tree.children[0] as Element;
+ const code = pre.children[0] as Element;
+ expect(code.properties?.dataMermaidHighlight).toBeDefined();
+ const parsed = JSON.parse(String(code.properties?.dataMermaidHighlight));
+ expect(parsed.threadId).toBe('t1');
+
+ // Text after should also be highlighted via continuation
+ const lastP = tree.children[1];
+ const lastPMarks = lastP.type === 'element' && (lastP as Element).tagName === 'mark'
+ ? [lastP as Element]
+ : findMarks(lastP as Element);
+ expect(lastPMarks.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('highlight starting near mermaid via lineOffset annotates it', () => {
+ // startLine is 1 line before the mermaid fence (e.g. empty line or
+ // thematic break). The 0-3 lineOffset loop should still find the match.
+ const tree: Root = {
+ type: 'root',
+ children: [
+ makePreCode(4, 'mermaid', 'graph TD\n A[Start]-->B[End]\n'),
+ makeParagraph(8, 'After text'),
+ ],
+ };
+ const transform = rehypeCommentHighlights({
+ highlights: [{
+ threadId: 't1',
+ selectedText: 'Start End After text',
+ startLine: 3, // 1 line before the mermaid fence at line 4
+ }],
+ });
+ transform(tree);
+
+ // Mermaid should be annotated via lineOffset match
+ const pre = tree.children[0] as Element;
+ const code = pre.children[0] as Element;
+ expect(code.properties?.dataMermaidHighlight).toBeDefined();
+ const parsed = JSON.parse(String(code.properties?.dataMermaidHighlight));
+ expect(parsed.threadId).toBe('t1');
+
+ // Text after should be highlighted via continuation
+ const lastP = tree.children[1];
+ const lastPMarks = lastP.type === 'element' && (lastP as Element).tagName === 'mark'
+ ? [lastP as Element]
+ : findMarks(lastP as Element);
+ expect(lastPMarks.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('highlight starting at mermaid with no text after only annotates diagram', () => {
+ // Selection entirely within mermaid — only SVG labels captured
+ const tree: Root = {
+ type: 'root',
+ children: [
+ makeParagraph(1, 'Before the diagram'),
+ makePreCode(3, 'mermaid', 'graph TD\n A[Login]-->B[Dashboard]\n'),
+ ],
+ };
+ const transform = rehypeCommentHighlights({
+ highlights: [{
+ threadId: 't1',
+ selectedText: 'Login Dashboard',
+ startLine: 3,
+ }],
+ });
+ transform(tree);
+
+ // Mermaid should be annotated
+ const pre = tree.children[1] as Element;
+ const code = pre.children[0] as Element;
+ expect(code.properties?.dataMermaidHighlight).toBeDefined();
+
+ // Before paragraph should NOT be highlighted (highlight starts at mermaid, not before)
+ const beforeMarks = findMarks(tree.children[0] as Element);
+ expect(beforeMarks).toHaveLength(0);
+ });
+
+ it('mermaid block NOT annotated when no continuation is active', () => {
+ // Mermaid block as first element — no highlight spans into it
+ const tree: Root = {
+ type: 'root',
+ children: [
+ makePreCode(1, 'mermaid', 'graph TD\n A-->B\n'),
+ makeParagraph(5, 'Some text after'),
+ ],
+ };
+ const transform = rehypeCommentHighlights({
+ highlights: [{ threadId: 't1', selectedText: 'Some text after', startLine: 5 }],
+ });
+ transform(tree);
+
+ // Mermaid should NOT have annotation
+ const pre = tree.children[0] as Element;
+ expect(pre.tagName).toBe('pre');
+ const code = pre.children[0] as Element;
+ expect(code.properties?.dataMermaidHighlight).toBeUndefined();
+
+ // Paragraph should have a mark
+ const marks = findMarks(tree);
+ expect(marks).toHaveLength(1);
+ });
+
+ it('pending class propagated to media wrapping marks', () => {
+ const tree: Root = {
+ type: 'root',
+ children: [
+ makeParagraph(1, 'Before text'),
+ makeImgParagraph(3, 'photo.png'),
+ makeParagraph(5, 'After text'),
+ ],
+ };
+ const transform = rehypeCommentHighlights({
+ highlights: [{
+ threadId: 't1',
+ selectedText: 'Before text After text',
+ startLine: 1,
+ pending: true,
+ }],
+ });
+ transform(tree);
+
+ // The image paragraph wrapper should have pending class
+ const imgContainer = tree.children[1] as Element;
+ expect(imgContainer.tagName).toBe('mark');
+ expect((imgContainer.properties?.className as string[])).toContain('pending-highlight');
+ });
+ });
+
describe('nthIndexOf', () => {
it('returns first occurrence for index 0', () => {
expect(nthIndexOf('foo bar foo baz foo', 'foo', 0)).toBe(0);
diff --git a/apps/penpal/frontend/src/components/rehypeCommentHighlights.ts b/apps/penpal/frontend/src/components/rehypeCommentHighlights.ts
index 7b73beba..40c19f85 100644
--- a/apps/penpal/frontend/src/components/rehypeCommentHighlights.ts
+++ b/apps/penpal/frontend/src/components/rehypeCommentHighlights.ts
@@ -39,7 +39,7 @@ export default function rehypeCommentHighlights(options: Options) {
// Track which highlights have been started to avoid double-applying
const applied = new Set();
// Cross-element highlights needing continuation: threadId → remaining normalized text
- const continuing = new Map();
+ const continuing = new Map();
visit(tree, 'element', (node: Element, index, parent) => {
if (index === undefined || !parent) return;
@@ -52,6 +52,77 @@ export default function rehypeCommentHighlights(options: Options) {
// string), so handleCodeBlock stores match info as data attributes instead.
// Language-less falls through to normal mark insertion.
if (node.tagName === 'pre') {
+ // E-PENPAL-HIGHLIGHT-MEDIA: annotate mermaid blocks during continuation.
+ // We can't wrap in because mermaid rendering uses imperative
+ // DOM mutations (innerHTML = svg). Wrapping changes the tree structure,
+ // causing React to recreate DOM nodes and lose the rendered SVG.
+ // Instead, annotate the element so MarkdownViewer can add highlight
+ // classes to the mermaid container div (props-only change, no DOM recreation).
+ const codeChild = node.children.find(
+ (c): c is Element => c.type === 'element' && c.tagName === 'code'
+ );
+ const isMermaid = codeChild && Array.isArray(codeChild.properties?.className) &&
+ (codeChild.properties.className as string[]).some(c => c === 'language-mermaid');
+ if (isMermaid) {
+ const sourceLine = node.position?.start?.line;
+ let annotated = false;
+
+ // Annotate mermaid during cross-element continuation
+ if (continuing.size > 0 && sourceLine) {
+ for (const [, state] of continuing) {
+ if (sourceLine > state.highlight.startLine) {
+ codeChild.properties = codeChild.properties || {};
+ codeChild.properties.dataMermaidHighlight = JSON.stringify({
+ threadId: state.highlight.threadId,
+ pending: state.highlight.pending,
+ });
+ // Mark that this continuation crossed a mermaid block, so
+ // post-mermaid matching is lenient (remaining contains SVG
+ // text from sel.toString() that won't match any HAST element).
+ state.mermaidCrossed = true;
+ annotated = true;
+ break;
+ }
+ }
+ }
+
+ // E-PENPAL-HIGHLIGHT-MEDIA: start highlights whose startLine falls on
+ // this mermaid block. The selectedText from sel.toString() contains SVG
+ // labels (not mermaid source), so we can't text-match — just annotate
+ // the mermaid and schedule the full selectedText for continuation so
+ // adjacent prose elements get highlighted.
+ if (sourceLine) {
+ for (let lineOffset = 0; lineOffset <= 3; lineOffset++) {
+ const lineHighlights = byLine.get(sourceLine - lineOffset);
+ if (!lineHighlights) continue;
+ for (const highlight of lineHighlights) {
+ if (applied.has(highlight.threadId)) continue;
+ applied.add(highlight.threadId);
+ if (!annotated) {
+ codeChild.properties = codeChild.properties || {};
+ codeChild.properties.dataMermaidHighlight = JSON.stringify({
+ threadId: highlight.threadId,
+ pending: highlight.pending,
+ });
+ annotated = true;
+ }
+ // Schedule continuation with the full text — mermaidCrossed
+ // ensures lenient matching past the SVG text in remaining.
+ const normSelected = normalizeSelected(highlight.selectedText);
+ if (normSelected.length >= 3) {
+ continuing.set(highlight.threadId, {
+ highlight,
+ remaining: normSelected,
+ mermaidCrossed: true,
+ });
+ }
+ }
+ }
+ }
+
+ return SKIP;
+ }
+
if (handleCodeBlock(node, continuing, byLine, applied)) {
return SKIP;
}
@@ -60,14 +131,32 @@ export default function rehypeCommentHighlights(options: Options) {
const sourceLine = node.position?.start?.line;
if (!sourceLine) return;
+ // E-PENPAL-HIGHLIGHT-MEDIA: wrap block-level images during continuation.
+ // Images have no text content, so wrapping doesn't consume from remaining —
+ // the next text element will continue matching. Only SKIP if we wrapped,
+ // to avoid falling through to text-matching logic on an image-only block.
+ if (isMediaOnlyBlock(node) && continuing.size > 0) {
+ let wrapped = false;
+ for (const [, state] of continuing) {
+ if (sourceLine > state.highlight.startLine) {
+ wrapNodeInMark(node, index!, parent! as Element | Root, state.highlight);
+ wrapped = true;
+ break;
+ }
+ }
+ if (wrapped) return SKIP;
+ }
+
// Continue cross-element highlights into subsequent elements.
// Only try elements on lines AFTER the highlight's startLine to avoid
// double-matching in child elements of the start element (whose text
// was already covered by collectTextNodes on the parent).
+ let continuationMatched = false;
for (const [threadId, state] of continuing) {
if (sourceLine <= state.highlight.startLine) continue;
- const matched = applyContinuation(node, state.highlight, state.remaining);
+ const matched = applyContinuation(node, state.highlight, state.remaining, state.mermaidCrossed);
if (matched > 0) {
+ continuationMatched = true;
const newRemaining = state.remaining.slice(matched).trim();
if (newRemaining.length === 0) {
continuing.delete(threadId);
@@ -76,6 +165,8 @@ export default function rehypeCommentHighlights(options: Options) {
}
}
}
+ // E-PENPAL-HIGHLIGHT-MEDIA: wrap inline images after continuation marks
+ if (continuationMatched) wrapInlineMedia(node);
// Start new highlights at or near this line.
// Check nearby lines (0-3 offset) because startLine may point to an empty
@@ -98,6 +189,8 @@ export default function rehypeCommentHighlights(options: Options) {
continuing.set(highlight.threadId, { highlight, remaining: normSelected });
}
}
+ // E-PENPAL-HIGHLIGHT-MEDIA: wrap inline images after highlight marks
+ if (result.matched) wrapInlineMedia(node);
}
}
});
@@ -332,7 +425,7 @@ const BLOCK_TAGS = new Set([
'tr', 'td', 'th', 'section', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
]);
-function applyContinuation(element: Element, highlight: ThreadHighlight, remaining: string): number {
+function applyContinuation(element: Element, highlight: ThreadHighlight, remaining: string, mermaidCrossed?: boolean): number {
// Skip container elements with block children — let individual children handle
// the continuation to avoid matching the wrong child's text when collectTextNodes
// concatenates all descendants without separators.
@@ -383,13 +476,19 @@ function applyContinuation(element: Element, highlight: ThreadHighlight, remaini
// with a suffix of remaining (e.g. remaining="s Observability..." and
// element text starts with "Observability...").
// Thresholds are proportional to content length to avoid edge cases with
- // very short or very long text.
- const minOverlapElement = Math.max(3, Math.min(10, Math.floor(remaining.length / 3)));
+ // very short or very long text. When mermaidCrossed, remaining is polluted
+ // with SVG text from sel.toString() that doesn't appear in HAST, so we
+ // lower thresholds to accept shorter matches at any position.
+ const minOverlapElement = mermaidCrossed ? 3 : Math.max(3, Math.min(10, Math.floor(remaining.length / 3)));
const probeWindow = Math.max(5, Math.floor(remaining.length / 2));
if (matchIndex === -1 && normalizedText.length >= minOverlapElement) {
const probe = normalizedText.slice(0, Math.min(probeWindow, normalizedText.length));
const overlapIdx = remaining.indexOf(probe);
- if (overlapIdx > 0 && overlapIdx < probeWindow) {
+ // Accept the overlap if it's within the probe window, OR if the probe
+ // is long enough (≥8 chars) to be specific regardless of position.
+ // After crossing a mermaid block, accept any position with ≥3 char probe
+ // since the SVG text may push the real match arbitrarily far into remaining.
+ if (overlapIdx > 0 && (mermaidCrossed ? probe.length >= 3 : (probe.length >= 8 || overlapIdx < probeWindow))) {
matchIndex = 0;
matchLength = Math.min(normalizedText.length, remaining.length - overlapIdx);
skippedChars = overlapIdx;
@@ -407,6 +506,88 @@ function applyContinuation(element: Element, highlight: ThreadHighlight, remaini
return skippedChars + matchLength;
}
+// ── Media wrapping ──────────────────────────────────────────────────
+
+// E-PENPAL-HIGHLIGHT-MEDIA: helpers for wrapping images and mermaid diagrams in highlights.
+
+/** Returns true if the element is a block containing only
elements and whitespace text. */
+function isMediaOnlyBlock(node: Element): boolean {
+ if (!node.children || node.children.length === 0) return false;
+ let hasImg = false;
+ for (const child of node.children) {
+ if (child.type === 'element' && (child as Element).tagName === 'img') {
+ hasImg = true;
+ } else if (child.type === 'text' && !(child as Text).value.trim()) {
+ // whitespace text node — ok
+ } else {
+ return false;
+ }
+ }
+ return hasImg;
+}
+
+/** Wrap a node in a element by replacing it in the parent's children array. */
+function wrapNodeInMark(
+ node: Element, index: number, parent: Element | Root, highlight: ThreadHighlight
+): void {
+ const mark: Element = {
+ type: 'element',
+ tagName: 'mark',
+ properties: {
+ className: highlight.pending
+ ? ['comment-highlight', 'pending-highlight']
+ : ['comment-highlight'],
+ dataThreadId: highlight.threadId,
+ },
+ children: [node],
+ };
+ (parent as Element).children[index] = mark;
+}
+
+/** Find the nearest element searching from `fromIndex` in `direction`, skipping whitespace text. */
+function findNearestMark(children: ElementContent[], fromIndex: number, direction: -1 | 1): Element | null {
+ for (let i = fromIndex + direction; i >= 0 && i < children.length; i += direction) {
+ const c = children[i];
+ if (c.type === 'element' && (c as Element).tagName === 'mark') return c as Element;
+ if (c.type === 'text' && !(c as Text).value.trim()) continue;
+ break;
+ }
+ return null;
+}
+
+/**
+ * After mark insertion, scan an element's children for
elements
+ * sandwiched between elements with the same threadId and wrap them.
+ */
+function wrapInlineMedia(element: Element): void {
+ const children = element.children;
+ for (let i = children.length - 1; i >= 0; i--) {
+ const child = children[i];
+ if (child.type !== 'element') continue;
+ const el = child as Element;
+ if (el.tagName !== 'img') continue;
+
+ const markBefore = findNearestMark(children, i, -1);
+ const markAfter = findNearestMark(children, i, 1);
+
+ if (markBefore && markAfter &&
+ markBefore.properties?.dataThreadId === markAfter.properties?.dataThreadId) {
+ const threadId = markBefore.properties?.dataThreadId as string;
+ const pending = (markBefore.properties?.className as string[])?.includes('pending-highlight');
+ const mark: Element = {
+ type: 'element',
+ tagName: 'mark',
+ properties: {
+ className: pending ? ['comment-highlight', 'pending-highlight'] : ['comment-highlight'],
+ dataThreadId: threadId,
+ },
+ children: [el],
+ };
+ children[i] = mark;
+ }
+ }
+}
+
// ── Code block bridging ─────────────────────────────────────────────
// E-PENPAL-HIGHLIGHT-CROSS: Handle syntax-highlighted code blocks for cross-boundary highlights.
@@ -425,14 +606,13 @@ function handleCodeBlock(
);
if (!codeChild) return false;
+ // Mermaid blocks are handled by the caller (annotated as media)
+ // before handleCodeBlock is reached, so only syntax-highlighted and
+ // language-less code blocks arrive here.
const classes = Array.isArray(codeChild.properties?.className)
? (codeChild.properties.className as string[]) : [];
- const isMermaid = classes.some(c => c === 'language-mermaid');
const hasLanguage = classes.some(c => /^language-/.test(String(c)));
- // Mermaid: skip for now (Phase 2 handles wrapping as media)
- if (isMermaid) return true;
-
// Language-less code: fall through to normal mark insertion
// (SyntaxHighlighter is not used, so elements render fine)
if (!hasLanguage) return false;
diff --git a/apps/penpal/frontend/src/index.css b/apps/penpal/frontend/src/index.css
index b0283fea..76b00d39 100644
--- a/apps/penpal/frontend/src/index.css
+++ b/apps/penpal/frontend/src/index.css
@@ -1364,6 +1364,27 @@ a:hover { text-decoration: underline; }
box-shadow: 0 0 0 2px rgba(163, 134, 0, 0.4);
color: #e0e0e0;
}
+/* E-PENPAL-HIGHLIGHT-MEDIA: mermaid containers use a prominent border + shadow
+ instead of background, since the SVG covers the highlight bg. */
+.mermaid-container.comment-highlight {
+ border: 2px solid #f5c518;
+ box-shadow: 0 0 0 2px rgba(245, 197, 24, 0.35);
+ border-bottom: 2px solid #f5c518;
+ background: var(--bg-surface);
+}
+[data-theme="dark"] .mermaid-container.comment-highlight {
+ border-color: #a38600;
+ box-shadow: 0 0 0 2px rgba(163, 134, 0, 0.35);
+ background: var(--bg-surface);
+}
+.mermaid-container.comment-highlight:hover {
+ border-color: #e8b400;
+ box-shadow: 0 0 0 3px rgba(245, 197, 24, 0.45);
+}
+[data-theme="dark"] .mermaid-container.comment-highlight:hover {
+ border-color: #c79f00;
+ box-shadow: 0 0 0 3px rgba(163, 134, 0, 0.45);
+}
/* Review banner */
.review-banner {