Before plan text
+Review Goal Previous feedback
After plan text
+ `; + document.body.append(container); + const paragraphs = container.querySelectorAll('p'); + selectText(paragraphs[0].firstChild as Text, paragraphs[2].firstChild as Text); + + const selectedText = getPlanSelection(container, 'plan.md')?.selectedText ?? ''; + expect(selectedText).toContain('Before plan text'); + expect(selectedText).toContain('After plan text'); + expect(selectedText).not.toContain('Previous feedback'); + }); + + it('does not return highlight ranges inside a review slot', () => { + const container = document.createElement('div'); + container.innerHTML = ` +Before plan text
+Review Goal Previous feedback
After plan text
+ `; + document.body.append(container); + const paragraphs = container.querySelectorAll('p'); + selectText(paragraphs[0].firstChild as Text, paragraphs[2].firstChild as Text); + + const ranges = getPlanSelectionTextRanges(container); + expect(ranges.map((range) => range.toString()).join('')).not.toContain('Previous feedback'); + expect( + ranges.every( + (range) => + range.commonAncestorContainer.parentElement?.closest(PLAN_REVIEW_FLOW_SLOT_SELECTOR) === + null, + ), + ).toBe(true); + }); + + it('keeps block indices and the flow anchor on real plan content', () => { + const container = document.createElement('div'); + container.innerHTML = ` +Before plan text
+Review Goal Previous feedback
After plan text
+ `; + document.body.append(container); + const paragraphs = container.querySelectorAll('p'); + const range = document.createRange(); + range.setStart(paragraphs[0].firstChild as Text, 0); + range.setEnd(paragraphs[2].firstChild as Text, (paragraphs[2].textContent ?? '').length); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + + expect(getPlanSelection(container, 'plan.md')).toMatchObject({ startLine: 0, endLine: 1 }); + expect(getPlanSelectionFlowAnchor(container)).toBe(paragraphs[2]); + }); + + it('preserves newlines and indentation for a selection contained in a code block', () => { + const container = document.createElement('div'); + container.className = 'plan-markdown plan-markdown-dialog'; + const pre = document.createElement('pre'); + pre.className = 'shiki-block'; + const code = document.createElement('code'); + for (const line of ['function a() {', ' return 1;', '}']) { + const span = document.createElement('span'); + span.className = 'line'; + span.textContent = line; + code.append(span); + } + pre.append(code); + container.append(pre); + document.body.append(container); + selectText(firstTextNodeIn(code), lastTextNodeIn(code)); + + const originalInnerText = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'innerText'); + Object.defineProperty(HTMLElement.prototype, 'innerText', { + configurable: true, + get() { + return this.querySelector('pre.shiki-block code') + ? 'function a() {\n return 1;\n}' + : (this.textContent ?? ''); + }, + }); + try { + expect(getPlanSelection(container, 'plan.md')?.selectedText).toBe( + 'function a() {\n return 1;\n}', + ); + } finally { + if (originalInnerText) { + Object.defineProperty(HTMLElement.prototype, 'innerText', originalInnerText); + } else { + delete (HTMLElement.prototype as { innerText?: unknown }).innerText; + } + } + }); + + it('anchors a selection ending at the next block boundary to the prior block', () => { + const container = document.createElement('div'); + container.innerHTML = 'First paragraph
Second paragraph
'; + document.body.append(container); + const paragraphs = container.querySelectorAll('p'); + const range = document.createRange(); + range.setStart(paragraphs[0].firstChild as Text, 0); + range.setEnd(paragraphs[1], 0); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + + expect(getPlanSelectionFlowAnchor(container)).toBe(paragraphs[0]); + }); + + it('preserves rendered block breaks between selected code and prose', () => { + const container = renderPlanMarkdown('```ts\nconst x = 1;\n```\n\nAfter step'); + const codeText = firstTextNodeIn(container.querySelector('code') as HTMLElement); + const proseText = container.querySelector('p')?.firstChild as Text; + selectText(codeText, proseText); + + expect(getPlanSelection(container, 'plan.md')?.selectedText).toBe('const x = 1;\nAfter step'); + }); + + it('intentionally excludes non-rendered Mermaid SVG text from prompt text', () => { + const container = document.createElement('div'); + container.innerHTML = 'After diagram
'; + const mermaid = container.querySelector('.mermaid-block') as HTMLDivElement; + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); + const hidden = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + hidden.textContent = 'Hidden marker'; + const visible = document.createElementNS('http://www.w3.org/2000/svg', 'text'); + visible.textContent = 'Visible diagram label'; + defs.append(hidden); + svg.append(defs, visible); + mermaid.append(svg); + document.body.append(container); + + selectText( + findTextNode(container, 'Hidden marker'), + container.querySelector('p')?.firstChild as Text, + ); + + const selectedText = getPlanSelection(container, 'plan.md')?.selectedText ?? ''; + expect(selectedText).not.toContain('Hidden marker'); + expect(selectedText).toContain('After diagram'); + }); + + it('recalculates retained range geometry when the plan reflows', () => { + const container = document.createElement('div'); + container.innerHTML = 'First block
Second block
'; + document.body.append(container); + const paragraphs = container.querySelectorAll('p'); + selectText(paragraphs[0].firstChild as Text, paragraphs[1].firstChild as Text); + const ranges = getPlanSelectionTextRanges(container); + let rangeTops = [30, 110]; + ranges.forEach((range, index) => { + Object.defineProperty(range, 'getClientRects', { + value: () => [rect(rangeTops[index], 15)] as unknown as DOMRectList, + }); + }); + Object.defineProperty(container, 'getBoundingClientRect', { value: () => rect(10, 5) }); + + class FakeResizeObserver { + static callback: ResizeObserverCallback; + static disconnected = false; + constructor(callback: ResizeObserverCallback) { + FakeResizeObserver.callback = callback; + } + observe() {} + disconnect() { + FakeResizeObserver.disconnected = true; + } + static trigger() { + FakeResizeObserver.callback([], {} as ResizeObserver); + } + } + vi.stubGlobal('ResizeObserver', FakeResizeObserver); + + const updates: number[][] = []; + const stop = trackPlanSelectionGeometry(container, ranges, (next) => + updates.push(next.map((item) => item.top)), + ); + expect(updates.at(-1)).toEqual([20, 100]); + rangeTops = [30, 50]; + FakeResizeObserver.trigger(); + expect(updates.at(-1)).toEqual([20, 40]); + stop(); + expect(FakeResizeObserver.disconnected).toBe(true); + }); +}); diff --git a/src/lib/plan-selection.ts b/src/lib/plan-selection.ts index 99e84ba7..52ec2ac6 100644 --- a/src/lib/plan-selection.ts +++ b/src/lib/plan-selection.ts @@ -12,23 +12,159 @@ export interface PlanSelection { const BLOCK_SELECTOR = 'p, li, h1, h2, h3, h4, h5, h6, pre, tr'; const HEADING_SELECTOR = 'h1, h2, h3, h4, h5, h6'; +export const PLAN_REVIEW_FLOW_SLOT_SELECTOR = '[data-plan-review-flow-slot]'; + +export interface PlanSelectionRect { + top: number; + left: number; + width: number; + height: number; +} + +function getSelectionRange(containerEl: HTMLElement): Range | null { + const selection = window.getSelection(); + if (!selection || selection.isCollapsed || selection.rangeCount === 0) return null; + + const range = selection.getRangeAt(0); + return containerEl.contains(range.commonAncestorContainer) ? range : null; +} + +function isInPlanReviewFlowSlot(node: Node): boolean { + const element = node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement; + return Boolean(element?.closest(PLAN_REVIEW_FLOW_SLOT_SELECTOR)); +} + +function cloneSelectionWithAncestors( + containerEl: HTMLElement, + selectedRange: Range, +): DocumentFragment { + let fragment = selectedRange.cloneContents(); + let ancestor: Node | null = + selectedRange.commonAncestorContainer.nodeType === Node.TEXT_NODE + ? selectedRange.commonAncestorContainer.parentNode + : selectedRange.commonAncestorContainer; + + while (ancestor && ancestor !== containerEl) { + if (ancestor instanceof Element) { + const wrapper = ancestor.cloneNode(false) as Element; + wrapper.append(fragment); + fragment = document.createDocumentFragment(); + fragment.append(wrapper); + } + ancestor = ancestor.parentNode; + } + + return fragment; +} + +function getPlanSelectionVisibleText(containerEl: HTMLElement, selectedRange: Range): string { + const host = document.createElement('div'); + const fragment = cloneSelectionWithAncestors(containerEl, selectedRange); + fragment.querySelectorAll(PLAN_REVIEW_FLOW_SLOT_SELECTOR).forEach((node) => node.remove()); + fragment + .querySelectorAll('style, script, defs, metadata, title, desc, [hidden], [aria-hidden="true"]') + .forEach((node) => node.remove()); + + host.className = containerEl.className; + host.style.cssText = [ + 'position:absolute', + 'left:-99999px', + 'top:0', + 'contain:layout style paint', + `width:${Math.max(containerEl.clientWidth, 1)}px`, + ].join(';'); + host.append(fragment); + + const parent = containerEl.parentElement ?? document.body; + parent.append(host); + try { + return host.innerText.trim(); + } finally { + host.remove(); + } +} + +/** Return the selected text ranges that belong to plan content, excluding inline review UI. */ +export function getPlanSelectionTextRanges(containerEl: HTMLElement): Range[] { + const selectedRange = getSelectionRange(containerEl); + if (!selectedRange) return []; + + const ranges: Range[] = []; + const walkerRoot = + selectedRange.commonAncestorContainer.nodeType === Node.TEXT_NODE + ? selectedRange.commonAncestorContainer.parentElement + : selectedRange.commonAncestorContainer; + const walker = document.createTreeWalker( + walkerRoot && containerEl.contains(walkerRoot) ? walkerRoot : containerEl, + NodeFilter.SHOW_TEXT, + ); + let node = walker.nextNode(); + while (node) { + if (!isInPlanReviewFlowSlot(node) && selectedRange.intersectsNode(node)) { + const text = node as Text; + const start = node === selectedRange.startContainer ? selectedRange.startOffset : 0; + const end = node === selectedRange.endContainer ? selectedRange.endOffset : text.length; + if (start < end) { + const range = document.createRange(); + range.setStart(text, start); + range.setEnd(text, end); + ranges.push(range); + } + } + node = walker.nextNode(); + } + return ranges; +} + +export function getPlanSelectionRects( + containerEl: HTMLElement, + ranges: readonly Range[], +): PlanSelectionRect[] { + const containerRect = containerEl.getBoundingClientRect(); + const rects: PlanSelectionRect[] = []; + for (const range of ranges) { + for (const rect of range.getClientRects()) { + rects.push({ + top: rect.top - containerRect.top, + left: rect.left - containerRect.left, + width: rect.width, + height: rect.height, + }); + } + } + return rects; +} + +/** Keep persisted selection overlays aligned while inline cards reflow the plan. */ +export function trackPlanSelectionGeometry( + containerEl: HTMLElement, + ranges: readonly Range[], + onChange: (rects: PlanSelectionRect[]) => void, +): () => void { + const refresh = () => onChange(getPlanSelectionRects(containerEl, ranges)); + refresh(); + + if (typeof ResizeObserver === 'undefined') return () => undefined; + const observer = new ResizeObserver(refresh); + observer.observe(containerEl); + return () => observer.disconnect(); +} /** * Extract structured selection info from the current DOM selection * within a plan viewer container. Returns null if no valid selection. */ export function getPlanSelection(containerEl: HTMLElement, source: string): PlanSelection | null { - const selection = window.getSelection(); - if (!selection || selection.isCollapsed) return null; - - const range = selection.getRangeAt(0); - if (!containerEl.contains(range.commonAncestorContainer)) return null; + const range = getSelectionRange(containerEl); + if (!range) return null; - const selectedText = selection.toString().trim(); + const selectedText = getPlanSelectionVisibleText(containerEl, range); if (!selectedText) return null; const nearestHeading = findNearestHeading(containerEl, range.startContainer); - const blocks = containerEl.querySelectorAll(BLOCK_SELECTOR); + const blocks = Array.from(containerEl.querySelectorAll(BLOCK_SELECTOR)).filter( + (block) => !block.closest(PLAN_REVIEW_FLOW_SLOT_SELECTOR), + ); const blockIndex = countBlocksBefore(blocks, range.startContainer); const endBlockIndex = countBlocksBefore(blocks, range.endContainer); @@ -41,6 +177,35 @@ export function getPlanSelection(containerEl: HTMLElement, source: string): Plan }; } +/** Find the block that should own an inline review card for the current selection. */ +export function getPlanSelectionFlowAnchor(containerEl: HTMLElement): HTMLElement | null { + const ranges = getPlanSelectionTextRanges(containerEl); + const range = ranges.at(-1); + if (!range) return null; + + let element: Element | null = + range.endContainer.nodeType === Node.ELEMENT_NODE + ? (range.endContainer as Element) + : range.endContainer.parentElement; + if (!element || element.closest(PLAN_REVIEW_FLOW_SLOT_SELECTOR)) return null; + + const block = element.closest(BLOCK_SELECTOR); + if (block && block !== containerEl && containerEl.contains(block)) { + // A div cannot be a child of a table row, so place table comments after the table. + if (block.tagName === 'TR') { + const table = block.closest('table'); + if (table instanceof HTMLElement && containerEl.contains(table)) return table; + } + if (block instanceof HTMLElement) return block; + } + + // Fallback for rendered blocks such as Mermaid diagrams that are not in BLOCK_SELECTOR. + while (element.parentElement && element.parentElement !== containerEl) { + element = element.parentElement; + } + return element instanceof HTMLElement && element.parentElement === containerEl ? element : null; +} + /** Walk backwards from the selection start to find the nearest heading. */ function findNearestHeading(container: HTMLElement, startNode: Node): string { let node: Node | null = startNode; @@ -88,7 +253,7 @@ function findNearestHeading(container: HTMLElement, startNode: Node): string { } /** Count block elements before the given node from a pre-queried list. */ -function countBlocksBefore(blocks: NodeListOf