diff --git a/ui/src/components/DocumentAnnotationLayer.test.tsx b/ui/src/components/DocumentAnnotationLayer.test.tsx index 955a51a8e5..80c6a010d9 100644 --- a/ui/src/components/DocumentAnnotationLayer.test.tsx +++ b/ui/src/components/DocumentAnnotationLayer.test.tsx @@ -4,11 +4,13 @@ import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DocumentAnnotationLayer } from "./DocumentAnnotationLayer"; +const mockBuildAnchorFromContainerSelection = vi.hoisted(() => vi.fn()); +const mockGetContainerTextOffset = vi.hoisted(() => vi.fn()); const mockRangesForNormalizedSpan = vi.hoisted(() => vi.fn()); vi.mock("@/lib/document-annotation-selection", () => ({ - buildAnchorFromContainerSelection: vi.fn(), - getContainerTextOffset: vi.fn(), + buildAnchorFromContainerSelection: mockBuildAnchorFromContainerSelection, + getContainerTextOffset: mockGetContainerTextOffset, rangesForNormalizedSpan: mockRangesForNormalizedSpan, })); @@ -141,6 +143,104 @@ describe("DocumentAnnotationLayer", () => { expect(container.querySelector(".paperclip-doc-annotation-hit-target")).toBeNull(); }); + it("does not capture annotation comments from editable selections", async () => { + const body = document.createElement("div"); + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", "true"); + const text = document.createTextNode("Editing routine instructions"); + editable.appendChild(text); + body.appendChild(editable); + + const range = document.createRange(); + range.setStart(text, 0); + range.setEnd(text, "Editing".length); + const getSelectionSpy = vi.spyOn(window, "getSelection").mockReturnValue({ + rangeCount: 1, + isCollapsed: false, + getRangeAt: () => range, + } as unknown as Selection); + const onPendingAnchorChange = vi.fn(); + root = createRoot(container); + + try { + await act(async () => { + root?.render( + , + ); + await new Promise((resolve) => window.requestAnimationFrame(resolve)); + }); + + await act(async () => { + document.dispatchEvent(new Event("selectionchange")); + }); + + expect(mockGetContainerTextOffset).not.toHaveBeenCalled(); + expect(mockBuildAnchorFromContainerSelection).not.toHaveBeenCalled(); + expect(onPendingAnchorChange).toHaveBeenCalledWith(null); + expect(container.querySelector('[data-testid="document-annotation-selection-toolbar"]')).toBeNull(); + } finally { + getSelectionSpy.mockRestore(); + } + }); + + it("does not capture annotation comments from bare contenteditable selections", async () => { + const body = document.createElement("div"); + const editable = document.createElement("div"); + editable.setAttribute("contenteditable", ""); + const text = document.createTextNode("Editing routine instructions"); + editable.appendChild(text); + body.appendChild(editable); + + const range = document.createRange(); + range.setStart(text, 0); + range.setEnd(text, "Editing".length); + const getSelectionSpy = vi.spyOn(window, "getSelection").mockReturnValue({ + rangeCount: 1, + isCollapsed: false, + getRangeAt: () => range, + } as unknown as Selection); + const onPendingAnchorChange = vi.fn(); + root = createRoot(container); + + try { + await act(async () => { + root?.render( + , + ); + await new Promise((resolve) => window.requestAnimationFrame(resolve)); + }); + + await act(async () => { + document.dispatchEvent(new Event("selectionchange")); + }); + + expect(mockGetContainerTextOffset).not.toHaveBeenCalled(); + expect(mockBuildAnchorFromContainerSelection).not.toHaveBeenCalled(); + expect(onPendingAnchorChange).toHaveBeenCalledWith(null); + expect(container.querySelector('[data-testid="document-annotation-selection-toolbar"]')).toBeNull(); + } finally { + getSelectionSpy.mockRestore(); + } + }); + it("uses native CSS highlights for visual paint when the browser supports them", async () => { const originalCss = globalThis.CSS; const originalHighlight = (globalThis as { Highlight?: unknown }).Highlight; diff --git a/ui/src/components/DocumentAnnotationLayer.tsx b/ui/src/components/DocumentAnnotationLayer.tsx index 7aab5ab415..619ff6a0f9 100644 --- a/ui/src/components/DocumentAnnotationLayer.tsx +++ b/ui/src/components/DocumentAnnotationLayer.tsx @@ -147,6 +147,24 @@ function elementFromNode(node: Node | null | undefined): HTMLElement | null { return parent instanceof HTMLElement ? parent : null; } +function selectionTouchesEditableElement(container: HTMLElement, range: Range) { + for (const node of [range.startContainer, range.endContainer, range.commonAncestorContainer]) { + const element = elementFromNode(node); + if (!element || !container.contains(element)) continue; + const editableElement = element.closest("input, textarea, select, [contenteditable]"); + if (!(editableElement instanceof HTMLElement)) continue; + if (editableElement.matches("input, textarea, select")) return true; + const contentEditableValue = editableElement.getAttribute("contenteditable"); + if ( + editableElement.isContentEditable || + (contentEditableValue !== null && contentEditableValue.toLowerCase() !== "false") + ) { + return true; + } + } + return false; +} + function intersectRects(a: DOMRect, b: DOMRect): DOMRect | null { const left = Math.max(a.left, b.left); const top = Math.max(a.top, b.top); @@ -384,6 +402,7 @@ export function DocumentAnnotationLayer({ if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null; const range = selection.getRangeAt(0); if (!container.contains(range.commonAncestorContainer)) return null; + if (selectionTouchesEditableElement(container, range)) return null; const containerOffset = getContainerTextOffset(container, range); if (!containerOffset) return null; const anchor = buildAnchorFromContainerSelection({ markdown, containerOffset });