From 82e2bbec970ee8240e11722ba244386cd06a08ed Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 26 May 2026 10:59:36 +0000 Subject: [PATCH] Use native text highlights for annotations Co-Authored-By: Paperclip --- .../DocumentAnnotationLayer.test.tsx | 57 ++++ ui/src/components/DocumentAnnotationLayer.tsx | 287 ++++++++++++------ ui/src/index.css | 28 ++ 3 files changed, 277 insertions(+), 95 deletions(-) diff --git a/ui/src/components/DocumentAnnotationLayer.test.tsx b/ui/src/components/DocumentAnnotationLayer.test.tsx index fbc075fe481..cec5c12317b 100644 --- a/ui/src/components/DocumentAnnotationLayer.test.tsx +++ b/ui/src/components/DocumentAnnotationLayer.test.tsx @@ -135,4 +135,61 @@ describe("DocumentAnnotationLayer", () => { expect(container.querySelector(".paperclip-doc-annotation-highlight")).toBeNull(); expect(container.querySelector(".paperclip-doc-annotation-hit-target")).toBeNull(); }); + + 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; + 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"); + body.textContent = "Annotated body text."; + root = createRoot(container); + + await act(async () => { + root?.render( + , + ); + await new Promise((resolve) => window.requestAnimationFrame(resolve)); + }); + + expect(container.querySelector(".paperclip-doc-annotation-highlight")).toBeNull(); + expect(container.querySelector(".paperclip-doc-annotation-hit-target")).not.toBeNull(); + const openHighlightCall = setHighlight.mock.calls.find(([name]) => name === "paperclip-doc-annotation-open"); + expect(openHighlightCall).toBeTruthy(); + expect((openHighlightCall?.[1] as MockHighlight).ranges).toHaveLength(1); + + await act(async () => root?.unmount()); + root = null; + expect(deleteHighlight).toHaveBeenCalledWith("paperclip-doc-annotation-open"); + + (globalThis as { CSS?: unknown }).CSS = originalCss; + (globalThis as { Highlight?: unknown }).Highlight = originalHighlight; + }); }); diff --git a/ui/src/components/DocumentAnnotationLayer.tsx b/ui/src/components/DocumentAnnotationLayer.tsx index 4c2bc7febee..6859922ab60 100644 --- a/ui/src/components/DocumentAnnotationLayer.tsx +++ b/ui/src/components/DocumentAnnotationLayer.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; import { AlertTriangle, MessageSquarePlus } from "lucide-react"; import type { DocumentAnnotationAnchorState, @@ -67,6 +67,68 @@ interface ToolbarPosition { left: number; } +type NativeHighlightKind = "open" | "focused" | "stale" | "resolved"; + +type NativeHighlightRanges = Record; + +type CssHighlight = object; + +type HighlightConstructor = new (...ranges: Range[]) => CssHighlight; + +type HighlightRegistry = { + set: (name: string, highlight: CssHighlight) => void; + delete: (name: string) => void; +}; + +const NATIVE_HIGHLIGHT_NAMES: Record = { + open: "paperclip-doc-annotation-open", + focused: "paperclip-doc-annotation-focused", + stale: "paperclip-doc-annotation-stale", + resolved: "paperclip-doc-annotation-resolved", +}; + +const nativeHighlightInstances = new Map(); + +function getNativeHighlightApi(): { registry: HighlightRegistry; HighlightCtor: HighlightConstructor } | null { + const css = (globalThis as { CSS?: { highlights?: HighlightRegistry } }).CSS; + const HighlightCtor = (globalThis as { Highlight?: HighlightConstructor }).Highlight; + if (!css?.highlights || typeof HighlightCtor !== "function") return null; + return { registry: css.highlights, HighlightCtor }; +} + +function emptyNativeHighlightRanges(): NativeHighlightRanges { + return { + open: [], + focused: [], + stale: [], + resolved: [], + }; +} + +function syncNativeHighlights(api = getNativeHighlightApi()) { + if (!api) return; + for (const kind of Object.keys(NATIVE_HIGHLIGHT_NAMES) as NativeHighlightKind[]) { + const ranges = Array.from(nativeHighlightInstances.values()).flatMap((entry) => entry[kind]); + const name = NATIVE_HIGHLIGHT_NAMES[kind]; + if (ranges.length === 0) { + api.registry.delete(name); + } else { + api.registry.set(name, new api.HighlightCtor(...ranges)); + } + } +} + +function setNativeHighlightRanges(instanceId: string, ranges: NativeHighlightRanges) { + if (!getNativeHighlightApi()) return; + nativeHighlightInstances.set(instanceId, ranges); + syncNativeHighlights(); +} + +function clearNativeHighlightRanges(instanceId: string) { + if (!nativeHighlightInstances.delete(instanceId)) return; + syncNativeHighlights(); +} + function elementFromNode(node: Node | null | undefined): HTMLElement | null { if (!node) return null; if (node instanceof HTMLElement) return node; @@ -117,6 +179,17 @@ function visibleClipRectForRange(range: Range, container: HTMLElement): DOMRect return clipRect; } +function nativeHighlightKind(input: { + focused: boolean; + stale: boolean; + resolved: boolean; +}): NativeHighlightKind { + if (input.resolved) return "resolved"; + if (input.stale) return "stale"; + if (input.focused) return "focused"; + return "open"; +} + export function DocumentAnnotationLayer({ containerRef, markdown, @@ -135,6 +208,12 @@ export function DocumentAnnotationLayer({ const [toolbarPosition, setToolbarPosition] = useState(null); const overlayRef = useRef(null); const lastCaptureSelectionRequestIdRef = useRef(0); + const reactId = useId(); + const nativeHighlightInstanceId = useMemo( + () => `document-annotation-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`, + [reactId], + ); + const nativeHighlightsSupported = getNativeHighlightApi() !== null; const visibleThreads = useMemo(() => { if (!hideResolved) return threads; @@ -145,13 +224,23 @@ export function DocumentAnnotationLayer({ const container = containerRef.current; const overlay = overlayRef.current; if (!container || !overlay) { + clearNativeHighlightRanges(nativeHighlightInstanceId); setHighlightRects([]); return; } const overlayRect = overlay.getBoundingClientRect(); const next: HighlightRect[] = []; + const nativeRanges = emptyNativeHighlightRanges(); for (const thread of visibleThreads) { if (thread.anchorState === "orphaned") continue; + const isFocused = thread.id === focusedThreadId; + const isStale = thread.anchorState === "stale"; + const isResolved = thread.status === "resolved"; + const nativeKind = nativeHighlightKind({ + focused: isFocused, + stale: isStale, + resolved: isResolved, + }); const ranges = rangesForNormalizedSpan({ container, selectedText: thread.selectedText, @@ -160,10 +249,12 @@ export function DocumentAnnotationLayer({ for (const range of ranges) { const visibleClipRect = visibleClipRectForRange(range, container); if (!visibleClipRect) continue; + let rangeIsVisible = false; for (const rect of Array.from(range.getClientRects())) { if (rect.width === 0 || rect.height === 0) continue; const visibleRect = intersectRects(rect, visibleClipRect); if (!visibleRect) continue; + rangeIsVisible = true; next.push({ threadId: thread.id, status: thread.status, @@ -175,18 +266,22 @@ export function DocumentAnnotationLayer({ isTail: false, }); } + if (rangeIsVisible) nativeRanges[nativeKind].push(range); } if (next.length > startIndex) { next[next.length - 1].isTail = true; } } + setNativeHighlightRanges(nativeHighlightInstanceId, nativeRanges); setHighlightRects(next); - }, [containerRef, visibleThreads]); + }, [containerRef, focusedThreadId, nativeHighlightInstanceId, visibleThreads]); useLayoutEffect(() => { computeHighlightRects(); }, [computeHighlightRects]); + useEffect(() => () => clearNativeHighlightRanges(nativeHighlightInstanceId), [nativeHighlightInstanceId]); + useEffect(() => { if (typeof window === "undefined") return; const container = containerRef.current; @@ -299,29 +394,62 @@ export function DocumentAnnotationLayer({ return ( <> - diff --git a/ui/src/index.css b/ui/src/index.css index efac23dfba3..5fb6eddfd7c 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -89,6 +89,10 @@ --chip-match-identifier-bg: var(--muted); --chip-match-identifier-fg: var(--muted-foreground); --chip-match-identifier-border: var(--border); + --paperclip-doc-annotation-highlight-open: #fef08a; + --paperclip-doc-annotation-highlight-focused: #fde047; + --paperclip-doc-annotation-highlight-stale: #fef08a; + --paperclip-doc-annotation-highlight-resolved: #fef9c3; } .dark { @@ -136,6 +140,30 @@ --chip-match-identifier-bg: var(--muted); --chip-match-identifier-fg: var(--muted-foreground); --chip-match-identifier-border: var(--border); + --paperclip-doc-annotation-highlight-open: #a16207; + --paperclip-doc-annotation-highlight-focused: #ca8a04; + --paperclip-doc-annotation-highlight-stale: #854d0e; + --paperclip-doc-annotation-highlight-resolved: #713f12; +} + +::highlight(paperclip-doc-annotation-open) { + background-color: var(--paperclip-doc-annotation-highlight-open); + color: inherit; +} + +::highlight(paperclip-doc-annotation-focused) { + background-color: var(--paperclip-doc-annotation-highlight-focused); + color: inherit; +} + +::highlight(paperclip-doc-annotation-stale) { + background-color: var(--paperclip-doc-annotation-highlight-stale); + color: inherit; +} + +::highlight(paperclip-doc-annotation-resolved) { + background-color: var(--paperclip-doc-annotation-highlight-resolved); + color: inherit; } @layer base {