diff --git a/apps/penpal/ERD.md b/apps/penpal/ERD.md index 8eb06d17..1089932b 100644 --- a/apps/penpal/ERD.md +++ b/apps/penpal/ERD.md @@ -136,11 +136,14 @@ see-also: - **E-PENPAL-HIGHLIGHT-CROSS**: The rehype plugin splits and wraps `` elements across inline formatting boundaries (bold, italic, code, links) and across block boundaries between code blocks and prose. Each contiguous segment of highlighted text gets its own `` wrapper so the highlight renders correctly regardless of intervening HTML structure. ← [P-PENPAL-HIGHLIGHT-CROSS](PRODUCT.md#P-PENPAL-HIGHLIGHT-CROSS) +- **E-PENPAL-HIGHLIGHT-MEDIA**: The rehype plugin expands highlights to encompass entire media elements. Inline images sandwiched between `` elements with the same `threadId` are wrapped in a matching ``. Block-level image paragraphs are wrapped during continuation. Mermaid `
` 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 {