mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
fix(ui): improve touch annotation toolbar placement
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
540f27a024
commit
efc2386523
3 files changed
+554
-46
No files matched your search
@@ -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(
|
||||
<DocumentAnnotationLayer
|
||||
containerRef={{ current: body }}
|
||||
markdown="Annotated body text."
|
||||
threads={[
|
||||
{ id: "thread-1", selectedText: "Annotated", status: "open", anchorState: "active" },
|
||||
]}
|
||||
focusedThreadId={null}
|
||||
onThreadFocus={onThreadFocus}
|
||||
pendingAnchor={null}
|
||||
onPendingAnchorChange={vi.fn()}
|
||||
onRequestComment={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await new Promise((resolve) => window.requestAnimationFrame(resolve));
|
||||
});
|
||||
|
||||
const hitTarget = container.querySelector<HTMLButtonElement>(".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<HTMLButtonElement>('[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) => (
|
||||
<DocumentAnnotationLayer
|
||||
containerRef={containerRef}
|
||||
markdown="Annotated body text."
|
||||
threads={[
|
||||
{ id: "thread-1", selectedText: "Annotated", status: "open", anchorState: "active" },
|
||||
]}
|
||||
focusedThreadId={focusedThreadId}
|
||||
onThreadFocus={onThreadFocus}
|
||||
pendingAnchor={null}
|
||||
onPendingAnchorChange={onPendingAnchorChange}
|
||||
onRequestComment={onRequestComment}
|
||||
/>
|
||||
);
|
||||
|
||||
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(
|
||||
<DocumentAnnotationLayer
|
||||
containerRef={containerRef}
|
||||
markdown="Annotated body text."
|
||||
threads={[
|
||||
{ id: "thread-1", selectedText: "Annotated", status: "open", anchorState: "active" },
|
||||
]}
|
||||
focusedThreadId={null}
|
||||
onThreadFocus={vi.fn()}
|
||||
pendingAnchor={null}
|
||||
onPendingAnchorChange={vi.fn()}
|
||||
onRequestComment={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
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<typeof DocumentAnnotationLayer>["pendingAnchor"] = null;
|
||||
const renderLayer = () => (
|
||||
<DocumentAnnotationLayer
|
||||
containerRef={{ current: body }}
|
||||
markdown="Select this text."
|
||||
threads={[]}
|
||||
focusedThreadId={null}
|
||||
onThreadFocus={vi.fn()}
|
||||
pendingAnchor={capturedAnchor}
|
||||
onPendingAnchorChange={(anchor) => {
|
||||
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<HTMLElement>('[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<typeof DocumentAnnotationLayer>["pendingAnchor"] = null;
|
||||
const renderLayer = () => (
|
||||
<DocumentAnnotationLayer
|
||||
containerRef={{ current: body }}
|
||||
markdown="Select this text."
|
||||
threads={[]}
|
||||
focusedThreadId={null}
|
||||
onThreadFocus={vi.fn()}
|
||||
pendingAnchor={capturedAnchor}
|
||||
onPendingAnchorChange={(anchor) => {
|
||||
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<HTMLElement>('[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<typeof DocumentAnnotationLayer>["pendingAnchor"] = null;
|
||||
const containerRef = { current: body };
|
||||
const onPendingAnchorChange = (anchor: React.ComponentProps<typeof DocumentAnnotationLayer>["pendingAnchor"]) => {
|
||||
pendingAnchor = anchor;
|
||||
};
|
||||
root = createRoot(container);
|
||||
const renderLayer = (captureSelectionRequestId: number) => (
|
||||
<DocumentAnnotationLayer
|
||||
containerRef={{ current: body }}
|
||||
containerRef={containerRef}
|
||||
markdown="Select this text."
|
||||
threads={[]}
|
||||
focusedThreadId={null}
|
||||
onThreadFocus={vi.fn()}
|
||||
pendingAnchor={pendingAnchor}
|
||||
onPendingAnchorChange={(anchor) => {
|
||||
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));
|
||||
});
|
||||
|
||||
@@ -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<HighlightRect[]>([]);
|
||||
const [hoveredThreadId, setHoveredThreadId] = useState<string | null>(null);
|
||||
const [toolbarPosition, setToolbarPosition] = useState<ToolbarPosition | null>(null);
|
||||
const [coarsePointer, setCoarsePointer] = useState(() =>
|
||||
typeof window === "undefined" ? false : isCoarsePointerDevice(),
|
||||
);
|
||||
const [selectionGestureActive, setSelectionGestureActive] = useState(false);
|
||||
const overlayRef = useRef<HTMLDivElement | null>(null);
|
||||
const toolbarRef = useRef<HTMLDivElement | null>(null);
|
||||
const toolbarAnchorRangeRef = useRef<Range | null>(null);
|
||||
const lastCaptureSelectionRequestIdRef = useRef<number>(0);
|
||||
const selectionCaptureTimeoutRef = useRef<number | null>(null);
|
||||
const selectionGestureActiveRef = useRef(false);
|
||||
const selectionGestureIdleTimeoutRef = useRef<number | null>(null);
|
||||
const pendingHighlightSyncRef = useRef(false);
|
||||
const lastProcessedSelectionRef = useRef<SelectionSnapshot | null>(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 (
|
||||
<button
|
||||
key={`${rect.threadId}-${index}`}
|
||||
@@ -624,9 +747,10 @@ export function DocumentAnnotationLayer({
|
||||
data-hovered={isHovered || undefined}
|
||||
aria-label="Open annotation thread"
|
||||
className={cn(
|
||||
"paperclip-doc-annotation-hit-target pointer-events-auto absolute cursor-pointer rounded-none bg-transparent transition-colors",
|
||||
"paperclip-doc-annotation-hit-target absolute rounded-none bg-transparent transition-colors",
|
||||
hitTargetInteractive ? "pointer-events-auto cursor-pointer" : "pointer-events-none",
|
||||
// Tint the run on hover so it's obvious which highlight you're over.
|
||||
isHovered && "bg-amber-400/40 dark:bg-amber-300/30",
|
||||
hitTargetInteractive && isHovered && "bg-amber-400/40 dark:bg-amber-300/30",
|
||||
isFocused && "ring-1 ring-transparent",
|
||||
)}
|
||||
style={{
|
||||
@@ -639,15 +763,40 @@ export function DocumentAnnotationLayer({
|
||||
onMouseLeave={() =>
|
||||
setHoveredThreadId((current) => (current === rect.threadId ? null : current))
|
||||
}
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
onThreadFocus(rect.threadId);
|
||||
}}
|
||||
onClick={() => onThreadFocus(rect.threadId)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{coarsePointer
|
||||
? highlightRects.map((rect, index) =>
|
||||
rect.isTail && rect.threadId !== PENDING_HIGHLIGHT_THREAD_ID ? (
|
||||
<button
|
||||
key={`focus-tail-${rect.threadId}-${index}`}
|
||||
type="button"
|
||||
data-testid="document-annotation-focus-tail"
|
||||
data-thread-id={rect.threadId}
|
||||
aria-label="Open annotation thread"
|
||||
className="paperclip-doc-annotation-focus-tail pointer-events-auto absolute inline-flex items-center justify-center rounded-sm bg-amber-500/95 text-amber-50 shadow-sm dark:bg-amber-500/90 dark:text-amber-50"
|
||||
style={{
|
||||
top: rect.top + Math.max(0, rect.height / 2 - 8),
|
||||
left: rect.left + rect.width + 2,
|
||||
width: 16,
|
||||
height: 16,
|
||||
}}
|
||||
title={rect.anchorState === "stale" ? "Anchor moved — needs review" : "Open annotation thread"}
|
||||
onClick={() => onThreadFocus(rect.threadId)}
|
||||
>
|
||||
{rect.anchorState === "stale" ? (
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
) : (
|
||||
<MessageSquare className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
) : null,
|
||||
)
|
||||
: null}
|
||||
{highlightRects.map((rect, index) =>
|
||||
rect.isTail && rect.anchorState === "stale" ? (
|
||||
!coarsePointer && rect.isTail && rect.anchorState === "stale" ? (
|
||||
<span
|
||||
key={`tail-${rect.threadId}-${index}`}
|
||||
aria-hidden="true"
|
||||
@@ -667,6 +816,7 @@ export function DocumentAnnotationLayer({
|
||||
)}
|
||||
{pendingAnchor && toolbarPosition ? (
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
data-testid="document-annotation-selection-toolbar"
|
||||
role="toolbar"
|
||||
aria-label="Selection actions"
|
||||
|
||||
@@ -120,6 +120,9 @@
|
||||
--paperclip-doc-annotation-highlight-focused: #fde047;
|
||||
--paperclip-doc-annotation-highlight-stale: #fef08a;
|
||||
--paperclip-doc-annotation-highlight-resolved: #fef9c3;
|
||||
--motion-doc-annotation-toolbar-duration: 120ms;
|
||||
--motion-doc-annotation-toolbar-offset: 4px;
|
||||
--motion-doc-annotation-toolbar-easing: ease-out;
|
||||
|
||||
/* Brand agent-capsule gradient stops (paperclip.ing/brand/).
|
||||
"The capsule is the agent" — each agent gets a 2-stop gradient,
|
||||
@@ -264,6 +267,31 @@
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.paperclip-doc-annotation-selection-toolbar {
|
||||
animation: paperclip-doc-annotation-toolbar-settle var(--motion-doc-annotation-toolbar-duration)
|
||||
var(--motion-doc-annotation-toolbar-easing);
|
||||
}
|
||||
|
||||
@keyframes paperclip-doc-annotation-toolbar-settle {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(var(--motion-doc-annotation-toolbar-offset));
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.paperclip-doc-annotation-selection-toolbar {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
|
||||
Reference in new issue
Block a user