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 });