diff --git a/ui/src/components/DocumentAnnotationLayer.test.tsx b/ui/src/components/DocumentAnnotationLayer.test.tsx index 3c8296b7e1e..61cee8f5ffd 100644 --- a/ui/src/components/DocumentAnnotationLayer.test.tsx +++ b/ui/src/components/DocumentAnnotationLayer.test.tsx @@ -301,6 +301,215 @@ describe("DocumentAnnotationLayer", () => { (globalThis as { Highlight?: unknown }).Highlight = originalHighlight; }); + it("makes run-sized annotation hit targets selection-transparent on coarse pointers", async () => { + mockIsCoarsePointerDevice.mockReturnValue(true); + const body = document.createElement("div"); + body.textContent = "Annotated body text."; + const onThreadFocus = vi.fn(); + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + await new Promise((resolve) => window.requestAnimationFrame(resolve)); + }); + + const hitTarget = container.querySelector(".paperclip-doc-annotation-hit-target"); + expect(hitTarget?.className).toContain("pointer-events-none"); + + const mouseDown = new MouseEvent("mousedown", { bubbles: true, cancelable: true }); + expect(hitTarget?.dispatchEvent(mouseDown)).toBe(true); + expect(mouseDown.defaultPrevented).toBe(false); + expect(onThreadFocus).not.toHaveBeenCalled(); + + const tailFocus = container.querySelector('[data-testid="document-annotation-focus-tail"]'); + expect(tailFocus?.className).toContain("pointer-events-auto"); + tailFocus?.click(); + expect(onThreadFocus).toHaveBeenCalledWith("thread-1"); + }); + + it("skips native highlight registry rewrites during an active selection gesture", async () => { + const originalCss = globalThis.CSS; + const originalHighlight = (globalThis as { Highlight?: unknown }).Highlight; + const setHighlight = vi.fn(); + const deleteHighlight = vi.fn(); + class MockHighlight { + ranges: Range[]; + + constructor(...ranges: Range[]) { + this.ranges = ranges; + } + } + (globalThis as { CSS?: unknown }).CSS = { + ...(originalCss ?? {}), + highlights: { + set: setHighlight, + delete: deleteHighlight, + }, + }; + (globalThis as { Highlight?: unknown }).Highlight = MockHighlight; + + const body = document.createElement("div"); + const containerRef = { current: body }; + const textNode = document.createTextNode("Annotated body text."); + body.appendChild(textNode); + const selectionRange = document.createRange(); + selectionRange.setStart(textNode, 0); + selectionRange.setEnd(textNode, "Annotated".length); + selectionRange.getBoundingClientRect = () => makeRect(12, 24, 64, 18); + const getSelectionSpy = vi.spyOn(window, "getSelection").mockReturnValue({ + rangeCount: 1, + isCollapsed: false, + getRangeAt: () => selectionRange, + } as unknown as Selection); + mockGetContainerTextOffset.mockReturnValue({ + startOffset: 0, + endOffset: 9, + containerText: "Annotated body text.", + selectedText: "Annotated", + }); + mockBuildAnchorFromContainerSelection.mockReturnValue({ + selector: { + quote: { exact: "Annotated", prefix: "", suffix: " body" }, + position: { normalizedStart: 0, normalizedEnd: 9, markdownStart: 0, markdownEnd: 9 }, + }, + }); + root = createRoot(container); + const onThreadFocus = vi.fn(); + const onPendingAnchorChange = vi.fn(); + const onRequestComment = vi.fn(); + + try { + const renderLayer = (focusedThreadId: string | null) => ( + + ); + + await act(async () => { + root?.render(renderLayer(null)); + await new Promise((resolve) => window.requestAnimationFrame(resolve)); + }); + setHighlight.mockClear(); + deleteHighlight.mockClear(); + + await act(async () => { + document.dispatchEvent(new Event("selectionchange")); + }); + await act(async () => { + window.dispatchEvent(new Event("scroll")); + root?.render(renderLayer("thread-1")); + await new Promise((resolve) => window.requestAnimationFrame(resolve)); + }); + + expect(setHighlight).not.toHaveBeenCalled(); + expect(deleteHighlight).not.toHaveBeenCalled(); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 140)); + }); + + expect(setHighlight).toHaveBeenCalled(); + } finally { + if (root) { + await act(() => root?.unmount()); + root = null; + } + getSelectionSpy.mockRestore(); + (globalThis as { CSS?: unknown }).CSS = originalCss; + (globalThis as { Highlight?: unknown }).Highlight = originalHighlight; + } + }); + + it("defers absolute highlight rect recomputation during active selection scroll", async () => { + const body = document.createElement("div"); + const containerRef = { current: body }; + const textNode = document.createTextNode("Annotated body text."); + body.appendChild(textNode); + const selectionRange = document.createRange(); + selectionRange.setStart(textNode, 0); + selectionRange.setEnd(textNode, "Annotated".length); + selectionRange.getBoundingClientRect = () => makeRect(12, 24, 64, 18); + const getSelectionSpy = vi.spyOn(window, "getSelection").mockReturnValue({ + rangeCount: 1, + isCollapsed: false, + getRangeAt: () => selectionRange, + } as unknown as Selection); + mockGetContainerTextOffset.mockReturnValue({ + startOffset: 0, + endOffset: 9, + containerText: "Annotated body text.", + selectedText: "Annotated", + }); + mockBuildAnchorFromContainerSelection.mockReturnValue({ + selector: { + quote: { exact: "Annotated", prefix: "", suffix: " body" }, + position: { normalizedStart: 0, normalizedEnd: 9, markdownStart: 0, markdownEnd: 9 }, + }, + }); + root = createRoot(container); + + try { + await act(async () => { + root?.render( + , + ); + await new Promise((resolve) => window.requestAnimationFrame(resolve)); + }); + mockRangesForNormalizedSpan.mockClear(); + + await act(async () => { + document.dispatchEvent(new Event("selectionchange")); + }); + await act(async () => { + window.dispatchEvent(new Event("scroll")); + await new Promise((resolve) => window.requestAnimationFrame(resolve)); + }); + + expect(mockRangesForNormalizedSpan).not.toHaveBeenCalled(); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 140)); + }); + + expect(mockRangesForNormalizedSpan).toHaveBeenCalled(); + } finally { + getSelectionSpy.mockRestore(); + } + }); + it("captures a coarse-pointer selection once after selectionchange settles", async () => { mockIsCoarsePointerDevice.mockReturnValue(true); const body = document.createElement("div"); @@ -487,6 +696,124 @@ describe("DocumentAnnotationLayer", () => { expect(toolbar?.style.left).toBe("8px"); }); + it("places a coarse-pointer selection toolbar below the selection by default", async () => { + mockIsCoarsePointerDevice.mockReturnValue(true); + const body = document.createElement("div"); + const textNode = document.createTextNode("Select this text."); + body.appendChild(textNode); + const range = document.createRange(); + range.setStart(textNode, 0); + range.setEnd(textNode, 6); + range.getBoundingClientRect = () => makeRect(100, 100, 60, 18); + vi.spyOn(window, "getSelection").mockReturnValue({ + rangeCount: 1, + isCollapsed: false, + getRangeAt: () => range, + } as unknown as Selection); + mockGetContainerTextOffset.mockReturnValue({ + startOffset: 0, + endOffset: 6, + containerText: "Select this text.", + selectedText: "Select", + }); + mockBuildAnchorFromContainerSelection.mockReturnValue({ + selector: { + quote: { exact: "Select", prefix: "", suffix: " this" }, + position: { normalizedStart: 0, normalizedEnd: 6, markdownStart: 0, markdownEnd: 6 }, + }, + }); + root = createRoot(container); + let capturedAnchor: React.ComponentProps["pendingAnchor"] = null; + const renderLayer = () => ( + { + capturedAnchor = anchor; + }} + onRequestComment={vi.fn()} + testWindow={{ innerWidth: 320, innerHeight: 640 }} + /> + ); + + await act(async () => { + root?.render(renderLayer()); + }); + document.dispatchEvent(new Event("selectionchange")); + document.dispatchEvent(new Event("touchend")); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 140)); + root?.render(renderLayer()); + }); + + const toolbar = container.querySelector('[data-testid="document-annotation-selection-toolbar"]'); + expect(toolbar?.style.top).toBe("130px"); + expect(toolbar?.style.left).toBe("70px"); + }); + + it("flips a coarse-pointer selection toolbar above when there is no room below", async () => { + mockIsCoarsePointerDevice.mockReturnValue(true); + const body = document.createElement("div"); + const textNode = document.createTextNode("Select this text."); + body.appendChild(textNode); + const range = document.createRange(); + range.setStart(textNode, 0); + range.setEnd(textNode, 6); + range.getBoundingClientRect = () => makeRect(100, 590, 60, 18); + vi.spyOn(window, "getSelection").mockReturnValue({ + rangeCount: 1, + isCollapsed: false, + getRangeAt: () => range, + } as unknown as Selection); + mockGetContainerTextOffset.mockReturnValue({ + startOffset: 0, + endOffset: 6, + containerText: "Select this text.", + selectedText: "Select", + }); + mockBuildAnchorFromContainerSelection.mockReturnValue({ + selector: { + quote: { exact: "Select", prefix: "", suffix: " this" }, + position: { normalizedStart: 0, normalizedEnd: 6, markdownStart: 0, markdownEnd: 6 }, + }, + }); + root = createRoot(container); + let capturedAnchor: React.ComponentProps["pendingAnchor"] = null; + const renderLayer = () => ( + { + capturedAnchor = anchor; + }} + onRequestComment={vi.fn()} + testWindow={{ innerWidth: 320, innerHeight: 640 }} + /> + ); + + await act(async () => { + root?.render(renderLayer()); + }); + document.dispatchEvent(new Event("selectionchange")); + document.dispatchEvent(new Event("touchend")); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 140)); + root?.render(renderLayer()); + }); + + const toolbar = container.querySelector('[data-testid="document-annotation-selection-toolbar"]'); + expect(toolbar?.style.top).toBe("546px"); + expect(toolbar?.style.left).toBe("70px"); + }); + it("uses the settled pending anchor for an explicit shortcut request", async () => { const body = document.createElement("div"); const textNode = document.createTextNode("Select this text."); @@ -514,18 +841,20 @@ describe("DocumentAnnotationLayer", () => { }); const onRequestComment = vi.fn(); let pendingAnchor: React.ComponentProps["pendingAnchor"] = null; + const containerRef = { current: body }; + const onPendingAnchorChange = (anchor: React.ComponentProps["pendingAnchor"]) => { + pendingAnchor = anchor; + }; root = createRoot(container); const renderLayer = (captureSelectionRequestId: number) => ( { - pendingAnchor = anchor; - }} + onPendingAnchorChange={onPendingAnchorChange} onRequestComment={onRequestComment} captureSelectionRequestId={captureSelectionRequestId} /> @@ -537,6 +866,7 @@ describe("DocumentAnnotationLayer", () => { await act(async () => { document.dispatchEvent(new Event("selectionchange")); }); + expect(pendingAnchor).not.toBeNull(); await act(async () => { root?.render(renderLayer(1)); }); diff --git a/ui/src/components/DocumentAnnotationLayer.tsx b/ui/src/components/DocumentAnnotationLayer.tsx index b1077ace3a2..b330acba357 100644 --- a/ui/src/components/DocumentAnnotationLayer.tsx +++ b/ui/src/components/DocumentAnnotationLayer.tsx @@ -1,5 +1,5 @@ import { Profiler, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { AlertTriangle, MessageSquarePlus } from "lucide-react"; +import { AlertTriangle, MessageSquare, MessageSquarePlus } from "lucide-react"; import type { DocumentAnnotationAnchorState, DocumentAnnotationThreadStatus, @@ -69,9 +69,11 @@ export interface AnnotationLayerProps { const PENDING_HIGHLIGHT_THREAD_ID = "__paperclip-pending-annotation__"; const COARSE_SELECTION_SETTLE_MS = 400; const COARSE_GESTURE_END_CAPTURE_MS = 120; -const TOOLBAR_WIDTH = 160; +const FINE_SELECTION_SETTLE_MS = 120; +const TOOLBAR_FALLBACK_WIDTH = 120; const TOOLBAR_HEIGHT = 36; const TOOLBAR_VIEWPORT_GAP = 8; +const TOUCH_CALLOUT_GAP = 12; interface HighlightRect { threadId: string; @@ -241,6 +243,21 @@ function nativeHighlightKind(input: { return "open"; } +function selectionRangeInsideContainer(selection: Selection | null, container: HTMLElement | null): Range | null { + if (!selection || !container || selection.rangeCount === 0 || selection.isCollapsed) return null; + const range = selection.getRangeAt(0); + return container.contains(range.commonAncestorContainer) ? range : null; +} + +function isAnnotationLayerMutation(mutation: MutationRecord) { + const target = elementFromNode(mutation.target); + return Boolean( + target?.closest( + ".paperclip-doc-annotation-layer, .paperclip-doc-annotation-visual-layer, .paperclip-doc-annotation-selection-toolbar", + ), + ); +} + export function DocumentAnnotationLayer({ containerRef, markdown, @@ -260,9 +277,18 @@ export function DocumentAnnotationLayer({ const [highlightRects, setHighlightRects] = useState([]); const [hoveredThreadId, setHoveredThreadId] = useState(null); const [toolbarPosition, setToolbarPosition] = useState(null); + const [coarsePointer, setCoarsePointer] = useState(() => + typeof window === "undefined" ? false : isCoarsePointerDevice(), + ); + const [selectionGestureActive, setSelectionGestureActive] = useState(false); const overlayRef = useRef(null); + const toolbarRef = useRef(null); + const toolbarAnchorRangeRef = useRef(null); const lastCaptureSelectionRequestIdRef = useRef(0); const selectionCaptureTimeoutRef = useRef(null); + const selectionGestureActiveRef = useRef(false); + const selectionGestureIdleTimeoutRef = useRef(null); + const pendingHighlightSyncRef = useRef(false); const lastProcessedSelectionRef = useRef(null); const hasProcessedSelectionRef = useRef(false); const reactId = useId(); @@ -279,7 +305,27 @@ export function DocumentAnnotationLayer({ return threads.filter((thread) => thread.status !== "resolved" || thread.anchorState === "orphaned" || thread.id === focusedThreadId); }, [threads, hideResolved, focusedThreadId]); - const computeHighlightRects = useCallback(() => { + useEffect(() => { + if (typeof window === "undefined") return; + const updateCoarsePointer = () => setCoarsePointer(isCoarsePointerDevice()); + updateCoarsePointer(); + const pointerQuery = typeof window.matchMedia === "function" + ? window.matchMedia("(pointer: coarse)") + : null; + if (!pointerQuery) return; + pointerQuery.addEventListener?.("change", updateCoarsePointer); + pointerQuery.addListener?.(updateCoarsePointer); + return () => { + pointerQuery.removeEventListener?.("change", updateCoarsePointer); + pointerQuery.removeListener?.(updateCoarsePointer); + }; + }, []); + + const computeHighlightRects = useCallback((options?: { force?: boolean }) => { + if (!options?.force && selectionGestureActiveRef.current) { + pendingHighlightSyncRef.current = true; + return; + } const container = containerRef.current; const overlay = overlayRef.current; if (!container || !overlay) { @@ -358,14 +404,83 @@ export function DocumentAnnotationLayer({ } setNativeHighlightRanges(nativeHighlightInstanceId, nativeRanges); setHighlightRects(next); + pendingHighlightSyncRef.current = false; }, [containerRef, focusedThreadId, nativeHighlightInstanceId, pendingHighlightText, visibleThreads]); + const computeHighlightRectsRef = useRef(computeHighlightRects); + + useEffect(() => { + computeHighlightRectsRef.current = computeHighlightRects; + }, [computeHighlightRects]); + useLayoutEffect(() => { computeHighlightRects(); }, [computeHighlightRects]); useEffect(() => () => clearNativeHighlightRanges(nativeHighlightInstanceId), [nativeHighlightInstanceId]); + const positionToolbar = useCallback((range: Range) => { + const overlay = overlayRef.current; + if (!overlay) return; + const overlayRect = overlay.getBoundingClientRect(); + const rect = range.getBoundingClientRect(); + const visualViewport = window.visualViewport; + const viewportLeft = visualViewport?.offsetLeft ?? 0; + const viewportTop = visualViewport?.offsetTop ?? 0; + const viewportWidth = visualViewport?.width ?? testWindow?.innerWidth ?? window.innerWidth; + const viewportHeight = visualViewport?.height ?? testWindow?.innerHeight ?? window.innerHeight; + const viewportBottom = viewportTop + viewportHeight; + const preferredAboveTop = rect.top - TOOLBAR_HEIGHT - TOOLBAR_VIEWPORT_GAP; + const preferredBelowTop = rect.bottom + TOUCH_CALLOUT_GAP; + const screenTop = coarsePointer + ? preferredBelowTop + TOOLBAR_HEIGHT > viewportBottom + ? preferredAboveTop + : preferredBelowTop + : preferredAboveTop < viewportTop + TOOLBAR_VIEWPORT_GAP + ? Math.min(rect.bottom + TOOLBAR_VIEWPORT_GAP, viewportBottom - TOOLBAR_HEIGHT - TOOLBAR_VIEWPORT_GAP) + : preferredAboveTop; + const toolbarWidth = toolbarRef.current?.offsetWidth || TOOLBAR_FALLBACK_WIDTH; + const preferredLeft = rect.left + rect.width / 2 - toolbarWidth / 2; + const screenLeft = Math.min( + Math.max(preferredLeft, viewportLeft + TOOLBAR_VIEWPORT_GAP), + viewportLeft + viewportWidth - toolbarWidth - TOOLBAR_VIEWPORT_GAP, + ); + const nextPosition = { + top: Math.max(0, screenTop - overlayRect.top), + left: Math.max(0, screenLeft - overlayRect.left), + }; + setToolbarPosition((current) => + current?.top === nextPosition.top && current.left === nextPosition.left ? current : nextPosition, + ); + }, [coarsePointer, testWindow]); + + useLayoutEffect(() => { + if (!pendingAnchor || !toolbarAnchorRangeRef.current) return; + positionToolbar(toolbarAnchorRangeRef.current); + }, [pendingAnchor, positionToolbar]); + + useEffect(() => { + if (!pendingAnchor || !toolbarAnchorRangeRef.current) return; + const visualViewport = window.visualViewport; + let frame: number | null = null; + const schedule = () => { + if (frame !== null) return; + frame = window.requestAnimationFrame(() => { + frame = null; + if (toolbarAnchorRangeRef.current) positionToolbar(toolbarAnchorRangeRef.current); + }); + }; + visualViewport?.addEventListener("resize", schedule); + visualViewport?.addEventListener("scroll", schedule); + window.addEventListener("scroll", schedule, true); + return () => { + if (frame !== null) window.cancelAnimationFrame(frame); + visualViewport?.removeEventListener("resize", schedule); + visualViewport?.removeEventListener("scroll", schedule); + window.removeEventListener("scroll", schedule, true); + }; + }, [pendingAnchor, positionToolbar]); + useEffect(() => { if (typeof window === "undefined") return; const container = containerRef.current; @@ -375,6 +490,10 @@ export function DocumentAnnotationLayer({ const schedule = () => { if (cancelled || frame !== null) return; + if (selectionGestureActiveRef.current) { + pendingHighlightSyncRef.current = true; + return; + } frame = window.requestAnimationFrame(() => { frame = null; if (!cancelled) computeHighlightRects(); @@ -399,10 +518,7 @@ export function DocumentAnnotationLayer({ ); if (markdownMutations.length > 0) recordMarkdownMutations(markdownMutations.length); } - const onlyLayerMutations = mutations.every((mutation) => { - const target = elementFromNode(mutation.target); - return !!target?.closest(".paperclip-doc-annotation-layer, .paperclip-doc-annotation-visual-layer"); - }); + const onlyLayerMutations = mutations.every(isAnnotationLayerMutation); if (!onlyLayerMutations) schedule(); }) : null; @@ -461,40 +577,39 @@ export function DocumentAnnotationLayer({ if (!containerOffset) return null; const anchor = buildAnchorFromContainerSelection({ markdown, containerOffset }); if (!anchor) return null; - const overlayRect = overlay.getBoundingClientRect(); - const rect = range.getBoundingClientRect(); - const visualViewport = window.visualViewport; - const viewportLeft = visualViewport?.offsetLeft ?? 0; - const viewportTop = visualViewport?.offsetTop ?? 0; - const viewportWidth = visualViewport?.width ?? testWindow?.innerWidth ?? window.innerWidth; - const viewportHeight = visualViewport?.height ?? testWindow?.innerHeight ?? window.innerHeight; - const preferredTop = rect.top - TOOLBAR_HEIGHT - TOOLBAR_VIEWPORT_GAP; - const viewportBottom = viewportTop + viewportHeight; - const screenTop = preferredTop < viewportTop + TOOLBAR_VIEWPORT_GAP - ? Math.min(rect.bottom + TOOLBAR_VIEWPORT_GAP, viewportBottom - TOOLBAR_HEIGHT - TOOLBAR_VIEWPORT_GAP) - : preferredTop; - const preferredLeft = rect.left + rect.width / 2 - TOOLBAR_WIDTH / 2; - const screenLeft = Math.min( - Math.max(preferredLeft, viewportLeft + TOOLBAR_VIEWPORT_GAP), - viewportLeft + viewportWidth - TOOLBAR_WIDTH - TOOLBAR_VIEWPORT_GAP, - ); - const top = Math.max(0, screenTop - overlayRect.top); - const left = Math.max(0, screenLeft - overlayRect.left); - setToolbarPosition({ top, left }); + toolbarAnchorRangeRef.current = range; + positionToolbar(range); return { selector: anchor.selector, selectedText: containerOffset.selectedText, }; - }, [containerRef, markdown, testWindow]); + }, [containerRef, markdown, positionToolbar]); useEffect(() => { if (typeof document === "undefined") return; - const coarsePointer = isCoarsePointerDevice(); const clearSelectionCaptureTimeout = () => { if (selectionCaptureTimeoutRef.current === null) return; window.clearTimeout(selectionCaptureTimeoutRef.current); selectionCaptureTimeoutRef.current = null; }; + const clearSelectionGestureIdleTimeout = () => { + if (selectionGestureIdleTimeoutRef.current === null) return; + window.clearTimeout(selectionGestureIdleTimeoutRef.current); + selectionGestureIdleTimeoutRef.current = null; + }; + const setSelectionGestureActiveValue = (active: boolean) => { + selectionGestureActiveRef.current = active; + setSelectionGestureActive(active); + }; + const settleSelectionGesture = () => { + clearSelectionGestureIdleTimeout(); + setSelectionGestureActiveValue(false); + if (pendingHighlightSyncRef.current) computeHighlightRectsRef.current({ force: true }); + }; + const scheduleSelectionGestureSettle = (delay: number) => { + clearSelectionGestureIdleTimeout(); + selectionGestureIdleTimeoutRef.current = window.setTimeout(settleSelectionGesture, delay); + }; const applySelectionChange = () => { const captureStartedAt = selectionDebugEnabled ? performance.now() : 0; const anchor = captureSelection(); @@ -506,6 +621,7 @@ export function DocumentAnnotationLayer({ if (hasProcessedSelectionRef.current && lastProcessedSelectionRef.current === null) return; lastProcessedSelectionRef.current = null; hasProcessedSelectionRef.current = true; + toolbarAnchorRangeRef.current = null; onPendingAnchorChange(null); setToolbarPosition(null); return; @@ -521,11 +637,15 @@ export function DocumentAnnotationLayer({ }; const handleSelectionChange = () => { const selection = window.getSelection(); - const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null; - const selectionIsActive = Boolean( - selection && !selection.isCollapsed && range && containerRef.current?.contains(range.commonAncestorContainer), - ); + const range = selectionRangeInsideContainer(selection, containerRef.current); + const selectionIsActive = Boolean(range); if (selectionDebugEnabled) recordSelectionChange(selectionIsActive); + if (selectionIsActive) { + setSelectionGestureActiveValue(true); + scheduleSelectionGestureSettle(coarsePointer ? COARSE_SELECTION_SETTLE_MS : FINE_SELECTION_SETTLE_MS); + } else { + settleSelectionGesture(); + } if (coarsePointer) { scheduleSelectionCapture(COARSE_SELECTION_SETTLE_MS); return; @@ -543,13 +663,15 @@ export function DocumentAnnotationLayer({ } return () => { clearSelectionCaptureTimeout(); + clearSelectionGestureIdleTimeout(); + selectionGestureActiveRef.current = false; document.removeEventListener("selectionchange", handleSelectionChange); document.removeEventListener("pointerup", handleGestureEnd); document.removeEventListener("touchend", handleGestureEnd); }; - }, [captureSelection, containerRef, onPendingAnchorChange, selectionDebugEnabled]); + }, [captureSelection, coarsePointer, containerRef, onPendingAnchorChange, selectionDebugEnabled]); - useEffect(() => { + useLayoutEffect(() => { if (captureSelectionRequestId === undefined) return; if (captureSelectionRequestId === 0) return; if (lastCaptureSelectionRequestIdRef.current === captureSelectionRequestId) return; @@ -613,6 +735,7 @@ export function DocumentAnnotationLayer({ if (rect.threadId === PENDING_HIGHLIGHT_THREAD_ID) return null; const isFocused = rect.focused; const isHovered = rect.threadId === hoveredThreadId; + const hitTargetInteractive = !coarsePointer && !selectionGestureActive; return ( + ) : null, + ) + : null} {highlightRects.map((rect, index) => - rect.isTail && rect.anchorState === "stale" ? ( + !coarsePointer && rect.isTail && rect.anchorState === "stale" ? (