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 (