From 7c8d3e7dcbd952ecfb77cc90feae056e8bbb8284 Mon Sep 17 00:00:00 2001 From: Jackson Turner Date: Mon, 5 Oct 2026 12:50:34 -0400 Subject: [PATCH] =?UTF-8?q?feat(app):=20Cmd+F=20find=20in=20Preview=20PDFs?= =?UTF-8?q?=20(S1)=20=E2=80=94=20shared=20DOM=20engine,=20Custom=20Highlig?= =?UTF-8?q?ht=20painting,=20focus=20routing=20+=20hijack=20guard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/app-bundle/manifest.json | 26 +- .../components/session/pdf-canvas-view.tsx | 13 +- .../components/session/pdf-find-controller.ts | 186 ++++++++++++ .../components/session/pdf-find-core.test.ts | 285 ++++++++++++++++++ .../src/components/session/pdf-find-core.ts | 275 +++++++++++++++++ .../src/components/session/pdf-find-guard.ts | 52 ++++ .../components/session/pdf-find-highlight.ts | 85 ++++++ .../src/components/session/pdf-find-pill.tsx | 106 +++++++ .../components/session/preview-file-view.tsx | 142 ++++++++- .../session/session-preview-tab.tsx | 69 +++++ .../overlay/packages/app/src/entry.tsx | 4 + 11 files changed, 1231 insertions(+), 12 deletions(-) create mode 100644 packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-controller.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-core.test.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-core.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-guard.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-highlight.ts create mode 100644 packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-pill.tsx diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json index 8e65c7f34..1f327d031 100644 --- a/packages/app-bundle/manifest.json +++ b/packages/app-bundle/manifest.json @@ -27,7 +27,7 @@ "packages/app/src/app.tsx": "07b292f88de59fad23b0e7564e0c536214917b65b50326552894925a9c0e17d1", "packages/app/src/design-polish.css": "6b860316d2d57a4a9a89c1ff2772de45eca0d0cef2bd3e2ea8572d817e1d1910", "packages/app/src/desktop-menu.ts": "350e0e9f2e4294bfe1b1f357b3cd2ea0908b14d20ca1e79fda2305db1958fb24", - "packages/app/src/entry.tsx": "f35e1017f4c9d478d254b2a38043e5750064b6bef25169c3e07ae9f72ff1049c", + "packages/app/src/entry.tsx": "080b3c11cdd61dfaf4bea8132970a1a300b77b0e8fc53065e42d5ccd75eaed01", "packages/app/src/index.css": "2c11df3dcebb381358e06626094b5dda3c35688ad7940d643b2cbb6cff1ea9e9", "packages/app/src/theme-preload.test.ts": "d9e4e96dd39a3491493637682611bedf6ddf4b6e4dbdddcb60bf006211b137a1", "packages/app/test-browser/empty-workspace-landing.test.ts": "83d19ecf5da37c234dbde1a004432865204d7f412a335c4e470a07e73d9bcda4", @@ -626,17 +626,23 @@ "packages/app/src/components/session/context-warning-banner.tsx": "f5fab4d7a9536238b70d64488fc30d44f6a4948fdcffd910a9666f4b6c296019", "packages/app/src/components/session/index.ts": "21473290d4a1a3d0670fd878372ddf21ff22d4a0b30d1aef6c81ea906c1488b4", "packages/app/src/components/session/panel-menu.tsx": "42f323046b7375ae158023a51506038eb15f2e1545c407f45acd47446a8ef3e0", - "packages/app/src/components/session/pdf-canvas-view.tsx": "591bac93a2e1e10a1b42404a29bcc5222ebbc5faf734c80556fbca35c90551dd", + "packages/app/src/components/session/pdf-canvas-view.tsx": "17c73d71799998741555191be71a8b8b1760ba35305c53a1077722fc80aaf666", + "packages/app/src/components/session/pdf-find-controller.ts": "0116c48bb5f463d9bfda938eb52d5ec52c49992a9cfde2ef3ed1051b0cb14b1c", + "packages/app/src/components/session/pdf-find-core.test.ts": "7c002a9e79bf10a167a643477fc2e9c0c3c8257db905c31ddc12cf9e15bab1cd", + "packages/app/src/components/session/pdf-find-core.ts": "9da5cd17d1140c0080f702e7ff7f74b7a771bcc1e910573a1af7021189761dac", + "packages/app/src/components/session/pdf-find-guard.ts": "61976e19ebae5d5bc17c87aea41332ba1cb650d0e0ef981e51c864a086d21292", + "packages/app/src/components/session/pdf-find-highlight.ts": "6299e9f68dc261d18100b5e3e96b42ea74e47db93ac49fe94ef567e5f6581454", + "packages/app/src/components/session/pdf-find-pill.tsx": "1e298a28d9b986a749ad15619623504be8126a4161af24ca674ffd978120505b", "packages/app/src/components/session/pdf-restore.test.ts": "4cd4cabbdc8063deb5fe95b5e637cf154ccdf864c5ff9f2f6833826bd69e4f90", "packages/app/src/components/session/pdf-restore.ts": "e5c92096b9302a452439babfc2732610a7e0d18e23793ce3bf6352fa32f91a36", "packages/app/src/components/session/preview-file-helpers.test.ts": "e0c1ccb40b48990c0c5b87ea62a35de60b860cd760067c7f1327cbb1d36584b1", "packages/app/src/components/session/preview-file-helpers.ts": "2244aaef25f7412df7b53a45a7d2f72380d45eb90caefa5e96c48d01e988eb4e", - "packages/app/src/components/session/preview-file-view.tsx": "7b221de755b851170e4b3dfc4d78d8446f6dfa40116b7529466db24e714d90f5", + "packages/app/src/components/session/preview-file-view.tsx": "038ff5687507e8c5450d5862211a8fbd94f3cd7137d3c7e2043ecd697bdeaf12", "packages/app/src/components/session/session-chats-dropdown.test.ts": "2003d2a15781337ea6bff2c68e730cc5b6df38697f15d927ab26913936444db7", "packages/app/src/components/session/session-context-tab.tsx": "227243b178b517f067d9ae0ae0eec3c559beeb6681828158b0600a17e98e7f81", "packages/app/src/components/session/session-header.tsx": "7113b3eca8ec950d4a547a2b7e2661b5e47f593653e12fca659b5b5f92cb90a6", "packages/app/src/components/session/session-new-view.tsx": "9510a4f550a3f0d4791e98e8025666f09d70a60fb66f193e48ee61feddae5a57", - "packages/app/src/components/session/session-preview-tab.tsx": "d3a210598d181c0aa29e9f3b5db3cd691169f3ee467001313a3d9a24bee93dbe", + "packages/app/src/components/session/session-preview-tab.tsx": "baaf8bfd25e481c59fba1b84aaefc3e92923b1360a7561e390c2175da7d52753", "packages/app/src/components/session/session-preview-tabs.test.ts": "773fc0116c5f61302eaa4a09fd5f91e6b26dee978c89e29c21714243ae3a5653", "packages/app/src/components/session/session-preview-tabs.ts": "672d147a6f1f46a7ac2eff7aff9da6cc069c476ff918e5ff4aed3a997310e6fe", "packages/app/src/components/session/session-preview-tree.test.ts": "6d1677caa56ec89fa5fcc8fc2f26fdcc1bc0a23217ce9c6d92888b272523c00a", @@ -873,10 +879,10 @@ "packages/ui/src/v2/components/text-shimmer-v2.tsx" ], "exceptions": [], - "extracted_at": "2026-09-24T04:46:38.771Z", + "extracted_at": "2026-10-05T16:28:00.091Z", "per_package": { "packages/app": { - "A": 186, + "A": 192, "M": 157, "D": 0 }, @@ -922,7 +928,7 @@ } }, "counts": { - "overlay_total": 851, + "overlay_total": 857, "deletions": 7, "server_coupled": 0 }, @@ -1546,6 +1552,12 @@ "packages/app/src/components/session/index.ts": "M", "packages/app/src/components/session/panel-menu.tsx": "A", "packages/app/src/components/session/pdf-canvas-view.tsx": "A", + "packages/app/src/components/session/pdf-find-controller.ts": "A", + "packages/app/src/components/session/pdf-find-core.test.ts": "A", + "packages/app/src/components/session/pdf-find-core.ts": "A", + "packages/app/src/components/session/pdf-find-guard.ts": "A", + "packages/app/src/components/session/pdf-find-highlight.ts": "A", + "packages/app/src/components/session/pdf-find-pill.tsx": "A", "packages/app/src/components/session/pdf-restore.test.ts": "A", "packages/app/src/components/session/pdf-restore.ts": "A", "packages/app/src/components/session/preview-file-helpers.test.ts": "A", diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/pdf-canvas-view.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/pdf-canvas-view.tsx index 878cec49f..2bcb927db 100644 --- a/packages/app-bundle/overlay/packages/app/src/components/session/pdf-canvas-view.tsx +++ b/packages/app-bundle/overlay/packages/app/src/components/session/pdf-canvas-view.tsx @@ -58,6 +58,10 @@ interface PdfCanvasViewProps { initialScrollTop?: number /** Reports page/scroll changes for persistence. */ onViewStateChange?: (state: { page: number; scrollTop: number }) => void + /** Called once with the wrapper div (the find index root), and again with + * null on unmount. Lets the per-pane find controller build its text-layer + * index without owning the viewer. */ + onRootChange?: (root: HTMLDivElement | null) => void } type PdfPageNavigation = { @@ -529,6 +533,7 @@ export function PdfCanvasView(props: PdfCanvasViewProps) { if (doc) doc.cleanup() if (navigationFrame !== undefined) cancelAnimationFrame(navigationFrame) props.onPageNavigationChange?.(null) + props.onRootChange?.(null) }) createEffect(() => { @@ -601,7 +606,13 @@ export function PdfCanvasView(props: PdfCanvasViewProps) { } return ( -
+
{ + wrapperRef = el + props.onRootChange?.(el ?? null) + }} + class="inline-flex flex-col items-center gap-3 p-4 min-w-full min-h-full" + > {textAvailabilityMessage() && ( diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-controller.ts b/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-controller.ts new file mode 100644 index 000000000..9755ab3bf --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-controller.ts @@ -0,0 +1,186 @@ +/** + * pdf-find-controller — per-pane find state for a Preview PDF pane (S1). + * + * One controller per PreviewFileView instance. Owns the reactive state + * { open, query, matches, currentIndex, hasText } and the wiring between the + * find pill, the CSS Custom Highlight painter, and the pane's scroll + * container. + * + * The searchable index is built LAZILY on first open (the PDF text layer is + * main-thread pdf.js work — don't pay it for panes that never search) and + * rebuilt whenever the pane content signal changes (recompile, tab restore) + * — the caller wires `rebuild` to the base64 prop. + * + * Scroll-to-match never uses scrollIntoView (it would scroll ancestors — + * the workspace grid, the side panel). It computes the current match's rect + * relative to the pane's own [data-preview-scroll] container and sets only + * that container's scrollTop/scrollLeft, landing the match ~50px below the + * floating controls. + * + * @module + */ + +import { createSignal } from "solid-js" +import { buildPdfIndex, findMatches, type PdfIndex, type PdfMatch } from "./pdf-find-core" +import { clearPdfFindHighlights, paintPdfFindHighlights } from "./pdf-find-highlight" + +const QUERY_DEBOUNCE_MS = 150 +/** Top margin so the match sits below the floating controls pill. */ +const SCROLL_TOP_MARGIN_PX = 50 + +export interface PdfFindController { + open: () => boolean + query: () => string + matches: () => PdfMatch[] + currentIndex: () => number + /** True once an index build found real text; false → the pill shows the + * "No searchable text in this PDF" message instead of a counter. */ + hasText: () => boolean + count: () => number + openFind: () => void + close: () => void + setQuery: (value: string) => void + next: () => void + prev: () => void + clearHighlights: () => void + /** Drop the cached index (content changed) and re-run the active query. */ + rebuild: () => void + dispose: () => void +} + +export function createPdfFindController(opts: { + /** The PdfCanvasView wrapper div — the index root. */ + pdfRoot: () => HTMLElement | undefined + /** The pane's own scroll container ([data-preview-scroll]). */ + scrollContainer: () => HTMLElement | undefined +}): PdfFindController { + const [open, setOpen] = createSignal(false) + const [query, setQuerySignal] = createSignal("") + const [matches, setMatches] = createSignal([]) + const [currentIndex, setCurrentIndex] = createSignal(0) + const [hasText, setHasText] = createSignal(true) + + let index: PdfIndex | null = null + let debounce: ReturnType | undefined + + const ensureIndex = (): PdfIndex => { + if (index) return index + const root = opts.pdfRoot() + index = root ? buildPdfIndex(root) : { text: "", steps: [], hasText: false } + setHasText(index.hasText) + return index + } + + const paint = () => { + paintPdfFindHighlights( + matches().map((m) => m.range), + currentIndex(), + ) + } + + const scrollToCurrent = () => { + const match = matches()[currentIndex()] + const scroll = opts.scrollContainer() + if (!match || !scroll) return + // Re-measure live — the cached rect is from build time and the user may + // have scrolled or zoomed since. + const rect = match.range.getBoundingClientRect() + const host = scroll.getBoundingClientRect() + // Vertical: land the match just below the floating controls. + const targetTop = scroll.scrollTop + (rect.top - host.top) - SCROLL_TOP_MARGIN_PX + // Horizontal: only scroll when the match is outside the visible band — + // never recenter a match the user can already see. + const matchLeft = rect.left - host.left + const matchRight = rect.right - host.right + let targetLeft = scroll.scrollLeft + if (matchLeft < 0) { + targetLeft = scroll.scrollLeft + matchLeft - 8 + } else if (matchRight > 0) { + targetLeft = scroll.scrollLeft + matchRight + 8 + } + scroll.scrollTop = Math.max(0, targetTop) + scroll.scrollLeft = Math.max(0, targetLeft) + } + + const run = (args?: { reset?: boolean; scroll?: boolean }) => { + const value = query().trim() + if (!value) { + setMatches([]) + setCurrentIndex(0) + clearPdfFindHighlights() + return + } + const idx = ensureIndex() + if (!idx.hasText) { + setMatches([]) + setCurrentIndex(0) + clearPdfFindHighlights() + return + } + const found = findMatches(idx, value, { caseSensitive: false, wholeWord: false }) + setMatches(found) + const desired = args?.reset ? 0 : currentIndex() + setCurrentIndex(found.length ? Math.min(desired, found.length - 1) : 0) + paint() + if (args?.scroll && found.length > 0) scrollToCurrent() + } + + const step = (dir: 1 | -1) => { + const found = matches() + if (!open() || found.length === 0) return + setCurrentIndex((currentIndex() + dir + found.length) % found.length) + paint() + scrollToCurrent() + } + + return { + open, + query, + matches, + currentIndex, + hasText, + count: () => matches().length, + + openFind: () => { + if (open()) return + setOpen(true) + ensureIndex() + run({ reset: true, scroll: true }) + }, + + close: () => { + setOpen(false) + setQuerySignal("") + setMatches([]) + setCurrentIndex(0) + clearPdfFindHighlights() + }, + + setQuery: (value: string) => { + setQuerySignal(value) + setCurrentIndex(0) + if (debounce) clearTimeout(debounce) + debounce = setTimeout(() => { + debounce = undefined + run({ reset: true, scroll: true }) + }, QUERY_DEBOUNCE_MS) + }, + + next: () => step(1), + prev: () => step(-1), + + clearHighlights: () => clearPdfFindHighlights(), + + rebuild: () => { + index = null + setHasText(true) + if (!open()) return + run({ reset: true, scroll: false }) + }, + + dispose: () => { + if (debounce) clearTimeout(debounce) + clearPdfFindHighlights() + }, + } +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-core.test.ts b/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-core.test.ts new file mode 100644 index 000000000..8ad558682 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-core.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, test } from "bun:test" +import { buildPdfIndex, findMatches } from "./pdf-find-core" +import { isPdfFindTarget } from "./pdf-find-guard" + +// --------------------------------------------------------------------------- +// DOM builders — mimic pdf.js's text-layer output +// --------------------------------------------------------------------------- + +function textLayer(page: number, parts: Array): HTMLDivElement { + const layer = document.createElement("div") + layer.setAttribute("data-pdf-text-layer", "") + layer.dataset.page = String(page) + for (const part of parts) { + if (part === "br") { + layer.appendChild(document.createElement("br")) + continue + } + const span = document.createElement("span") + span.textContent = part + layer.appendChild(span) + } + return layer +} + +function pdfRoot(...layers: HTMLDivElement[]): HTMLElement { + const root = document.createElement("div") + for (const layer of layers) root.appendChild(layer) + return root +} + +// --------------------------------------------------------------------------- +// buildPdfIndex +// --------------------------------------------------------------------------- + +describe("buildPdfIndex", () => { + test("extracts plain text from a single page", () => { + const root = pdfRoot(textLayer(1, ["hello world"])) + const index = buildPdfIndex(root) + expect(index.text).toBe("hello world") + expect(index.hasText).toBe(true) + }) + + test("joins multiple pages into one string with a space between", () => { + const root = pdfRoot(textLayer(1, ["page one"]), textLayer(2, ["page two"])) + const index = buildPdfIndex(root) + expect(index.text).toBe("page one page two") + }) + + test("collapses whitespace runs to a single space", () => { + const root = pdfRoot(textLayer(1, ["hello \t\n world"])) + const index = buildPdfIndex(root) + expect(index.text).toBe("hello world") + }) + + test("a
is a line break — emits a space", () => { + const root = pdfRoot(textLayer(1, ["line one", "br", "line two"])) + const index = buildPdfIndex(root) + expect(index.text).toBe("line one line two") + }) + + test("a line ending in - joins the next line WITHOUT the hyphen", () => { + const root = pdfRoot(textLayer(1, ["trans-", "br", "port"])) + const index = buildPdfIndex(root) + expect(index.text).toBe("transport") + }) + + test("hyphen join: offsets after the removed hyphen still land on real text", () => { + const root = pdfRoot(textLayer(1, ["trans-", "br", "port"])) + const index = buildPdfIndex(root) + // "transport" — 'p' of "port" is at normalized offset 5. It must map + // into the "port" text node, offset 0. + const step = index.steps.find((s) => s.node.textContent === "port") + expect(step).toBeDefined() + expect(step!.offset).toBe(0) + expect(step!.count).toBe(4) + }) + + test("empty text layer → hasText false", () => { + const root = pdfRoot(textLayer(1, [])) + const index = buildPdfIndex(root) + expect(index.text).toBe("") + expect(index.hasText).toBe(false) + }) + + test("whitespace-only text layer → hasText false", () => { + const root = pdfRoot(textLayer(1, [" ", " "])) + const index = buildPdfIndex(root) + expect(index.hasText).toBe(false) + }) + + test("NFKC-normalizes span text", () => { + // fi (U+FB01 LATIN SMALL LIGATURE FI) → "fi" + const root = pdfRoot(textLayer(1, ["fish"])) + const index = buildPdfIndex(root) + expect(index.text).toBe("fish") + }) + + test("spans within a page are concatenated without added spaces", () => { + const root = pdfRoot(textLayer(1, ["hel", "lo"])) + const index = buildPdfIndex(root) + expect(index.text).toBe("hello") + }) +}) + +// --------------------------------------------------------------------------- +// findMatches +// --------------------------------------------------------------------------- + +describe("findMatches", () => { + test("finds a single match with DOM endpoints", () => { + const root = pdfRoot(textLayer(1, ["hello world"])) + const index = buildPdfIndex(root) + const matches = findMatches(index, "world") + expect(matches).toHaveLength(1) + expect(matches[0].page).toBe(1) + expect(matches[0].start).toBe(6) + expect(matches[0].end).toBe(11) + expect(matches[0].startNode.textContent).toBe("hello world") + expect(matches[0].range.toString()).toBe("world") + }) + + test("an internal space keeps its own source (the whitespace char in the text node)", () => { + // Each normalized char maps to its own DOM char, so a match starting + // right after the space resolves to (node, offsetOfChar). + const root = pdfRoot(textLayer(1, ["hello world"])) + const index = buildPdfIndex(root) + const matches = findMatches(index, "world") + expect(matches).toHaveLength(1) + expect(matches[0].startOffset).toBe(6) + expect(matches[0].range.toString()).toBe("world") + }) + + test("case-insensitive by default", () => { + const root = pdfRoot(textLayer(1, ["Hello HELLO hello"])) + const index = buildPdfIndex(root) + expect(findMatches(index, "hello")).toHaveLength(3) + }) + + test("caseSensitive: true respects case", () => { + const root = pdfRoot(textLayer(1, ["Hello HELLO hello"])) + const index = buildPdfIndex(root) + expect(findMatches(index, "hello", { caseSensitive: true })).toHaveLength(1) + }) + + test("finds all occurrences, in document order", () => { + const root = pdfRoot(textLayer(1, ["a b a b a"])) + const index = buildPdfIndex(root) + const matches = findMatches(index, "a") + expect(matches).toHaveLength(3) + expect(matches.map((m) => m.start)).toEqual([0, 4, 8]) + }) + + test("a match spanning two spans gets a cross-node range", () => { + const root = pdfRoot(textLayer(1, ["hel", "lo world"])) + const index = buildPdfIndex(root) + const matches = findMatches(index, "hello") + expect(matches).toHaveLength(1) + expect(matches[0].range.toString()).toBe("hello") + expect(matches[0].startNode.textContent).toBe("hel") + expect(matches[0].endNode.textContent).toBe("lo world") + }) + + test("a match spanning a
line break matches across the space", () => { + const root = pdfRoot(textLayer(1, ["line one", "br", "line two"])) + const index = buildPdfIndex(root) + const matches = findMatches(index, "one line") + expect(matches).toHaveLength(1) + // The break-space maps to the char before the break ("e" of "one"), so + // the range covers "one" + the line-two prefix the space mapped to. + expect(matches[0].range.toString()).toBe("oneline") + expect(matches[0].startNode.textContent).toBe("line one") + expect(matches[0].endNode.textContent).toBe("line two") + }) + + test("hyphenated line break: 'transport' matches 'trans-\\nport'", () => { + const root = pdfRoot(textLayer(1, ["trans-", "br", "port"])) + const index = buildPdfIndex(root) + const matches = findMatches(index, "transport") + expect(matches).toHaveLength(1) + // The normalized string drops the hyphen, but the DOM range spans the + // real text — "trans-" + "port". + expect(matches[0].range.toString()).toBe("trans-port") + expect(matches[0].startNode.textContent).toBe("trans-") + expect(matches[0].endNode.textContent).toBe("port") + }) + + test("matches on page 2 are tagged page 2", () => { + const root = pdfRoot(textLayer(1, ["alpha"]), textLayer(2, ["beta alpha"])) + const index = buildPdfIndex(root) + const matches = findMatches(index, "alpha") + expect(matches).toHaveLength(2) + expect(matches[0].page).toBe(1) + expect(matches[1].page).toBe(2) + }) + + test("empty query returns no matches", () => { + const root = pdfRoot(textLayer(1, ["hello"])) + const index = buildPdfIndex(root) + expect(findMatches(index, "")).toHaveLength(0) + }) + + test("no match returns empty", () => { + const root = pdfRoot(textLayer(1, ["hello world"])) + const index = buildPdfIndex(root) + expect(findMatches(index, "zebra")).toHaveLength(0) + }) +}) + +// --------------------------------------------------------------------------- +// isPdfFindTarget (the hijack guard + command `when` predicate) +// --------------------------------------------------------------------------- + +describe("isPdfFindTarget", () => { + function previewPane(path: string, inner: HTMLElement): HTMLElement { + const host = document.createElement("div") + host.setAttribute("data-preview-host", path) + const scroll = document.createElement("div") + scroll.setAttribute("data-preview-scroll", "") + host.appendChild(scroll) + scroll.appendChild(inner) + document.body.appendChild(host) + return host + } + + test("true for a target inside a PDF pane's scroll container", () => { + const span = document.createElement("span") + const host = previewPane("docs/paper.pdf", span) + expect(isPdfFindTarget(span)).toBe(true) + host.remove() + }) + + test("true for the scroll container itself (focused pane)", () => { + const span = document.createElement("span") + const host = previewPane("paper.pdf", span) + const scroll = host.querySelector("[data-preview-scroll]")! + expect(isPdfFindTarget(scroll)).toBe(true) + host.remove() + }) + + test("false for a non-PDF preview pane", () => { + const span = document.createElement("span") + const host = previewPane("notes.md", span) + expect(isPdfFindTarget(span)).toBe(false) + host.remove() + }) + + test("false for a target outside any preview pane", () => { + const div = document.createElement("div") + document.body.appendChild(div) + expect(isPdfFindTarget(div)).toBe(false) + div.remove() + }) + + test("false for an input inside a PDF pane (editable owns its keys)", () => { + const input = document.createElement("input") + const host = previewPane("paper.pdf", input) + expect(isPdfFindTarget(input)).toBe(false) + host.remove() + }) + + test("false for a contenteditable inside a PDF pane", () => { + const editable = document.createElement("div") + editable.contentEditable = "true" + const host = previewPane("paper.pdf", editable) + expect(isPdfFindTarget(editable)).toBe(false) + host.remove() + }) + + test("false for a CodeMirror editor target even inside a PDF pane", () => { + const cm = document.createElement("div") + cm.classList.add("cm-editor") + const inner = document.createElement("div") + cm.appendChild(inner) + const host = previewPane("paper.pdf", cm) + expect(isPdfFindTarget(inner)).toBe(false) + host.remove() + }) + + test("case-insensitive .PDF extension", () => { + const span = document.createElement("span") + const host = previewPane("DOC.PDF", span) + expect(isPdfFindTarget(span)).toBe(true) + host.remove() + }) +}) diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-core.ts b/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-core.ts new file mode 100644 index 000000000..e2780b955 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-core.ts @@ -0,0 +1,275 @@ +/** + * pdf-find-core — Pure find machinery for PDF panes (S1 of Cmd+F in Preview). + * + * Walks the pdf.js text layer ([data-pdf-text-layer] — styled by attribute, + * NOT the `.textLayer` class) under a PDF pane root and builds a normalized + * searchable string plus an offset map back into DOM (textNode, offset) + * pairs. `findMatches` scans that string and returns Ranges in document + * order, each tagged with its page number. + * + * Normalization: + * - NFKC (the text layer is already NFKC-normalized by pdf.js's + * streamTextContent, so ligatures are plain letters — this is a belt for + * the suspenders). + * - Whitespace runs collapse to a single space. + * - A
sibling is a line break → emits a space. + * - A line ending in "-" joins the next line WITHOUT the hyphen, so + * "transport" matches a hyphenated "trans-\nport". The removed hyphen's + * span characters become zero-width stops in the map so offsets after the + * break still land on real text. + * + * No UI, no CSS Custom Highlight API — painting lives in + * pdf-find-highlight.ts. Pure functions so the whole module is unit-testable + * away from the viewer. + * + * @module + */ + +/** One step of the offset map: `count` normalized characters come from + * (node, offset..offset+count). Steps never straddle a node boundary. */ +export interface PdfIndexStep { + node: Text + offset: number + count: number + page: number +} + +export interface PdfIndex { + /** The normalized searchable string. */ + text: string + steps: PdfIndexStep[] + /** True when at least one real (non-whitespace) text node was found. + * False → the caller reports "No searchable text in this PDF". */ + hasText: boolean +} + +export interface PdfMatch { + /** 1-based page number of the span the match STARTS in. */ + page: number + /** Normalized-string offsets [start, end). */ + start: number + end: number + /** DOM endpoints — enough to build a Range. */ + startNode: Text + startOffset: number + endNode: Text + endOffset: number + /** Rect of the range in viewport coordinates at build time (cache — the + * caller re-measures live before scrolling). */ + rect: { top: number; left: number; bottom: number; right: number } + range: Range +} + +// --------------------------------------------------------------------------- +// Index build +// --------------------------------------------------------------------------- + +/** + * Build the searchable index for the PDF rendered under `root` (the + * PdfCanvasView wrapper div). Safe to call before the text layer renders — + * returns an empty index (hasText: false), and the caller may rebuild later. + */ +export function buildPdfIndex(root: HTMLElement): PdfIndex { + const layers = Array.from(root.querySelectorAll("[data-pdf-text-layer]")) + + // Raw item stream: text nodes (each carrying its page) interleaved with + // line-break markers. A page boundary is a break too — pages never join + // into one continuous line. The text layer's spans are absolutely + // positioned, so DOM order inside each page container is the pdf.js + // content order. + type Item = { kind: "text"; node: Text; page: number } | { kind: "break" } + const items: Item[] = [] + layers.forEach((layer, layerIndex) => { + if (layerIndex > 0) items.push({ kind: "break" }) + const page = layerIndex + 1 + // Walk ALL nodes (elements + text) so
siblings register as breaks. + // A
is a line end; text nodes contribute their characters. We walk + // elements rather than reading text-node nextSibling because happy-dom + // (the test DOM) doesn't implement sibling pointers on Text. + const walker = document.createTreeWalker(layer, NodeFilter.SHOW_ALL) + let node = walker.nextNode() + while (node) { + if (node.nodeName === "BR") { + items.push({ kind: "break" }) + } else if (node instanceof Text && node.data.length > 0) { + items.push({ kind: "text", node, page }) + } + node = walker.nextNode() + } + }) + + // Raw char stream + per-char source. Breaks carry no source (null). + const raw: string[] = [] + const rawSource: Array<{ node: Text; offset: number; page: number } | null> = [] + for (const item of items) { + if (item.kind === "break") { + raw.push(" ") + rawSource.push(null) + continue + } + // NFKC per text node (ligatures arrive pre-split by pdf.js's + // streamTextContent — this is a belt for the suspenders). + const normalized = item.node.data.normalize("NFKC") + for (let i = 0; i < normalized.length; i++) { + raw.push(normalized[i]) + rawSource.push({ node: item.node, offset: i, page: item.page }) + } + } + + // Whitespace runs collapse to a single space kept in the run's first + // slot, mapped to ITS OWN source (the whitespace char in the text node, + // or — for a break — the char that precedes the break). Leading and + // trailing runs are dropped. A break-space whose preceding char is a + // hyphen is a line-wrap hyphen ("trans-\nport") — drop BOTH so + // "transport" matches; the removed hyphen keeps its slot but contributes + // no character, and a real range never needs to land on it because the + // break it paired with is gone too. + const n = raw.length + const out: string[] = new Array(n).fill("") + const outSource: Array<{ node: Text; offset: number; page: number } | null> = new Array(n).fill(null) + + // The source a break maps to: the char before it. For the first char of + // the stream there is none — the run is leading and gets dropped anyway. + const breakFallback = (slot: number) => (slot > 0 ? rawSource[slot - 1] : null) + + let i = 0 + // Drop leading whitespace runs entirely. + while (i < n && /\s/.test(raw[i])) i++ + let hasText = false + while (i < n) { + if (/\s/.test(raw[i])) { + // Find the end of the run. + let j = i + while (j < n && /\s/.test(raw[j])) j++ + if (j >= n) break // trailing run — dropped + // Hyphen-wrap check: the char before the run is a hyphen AND the run + // contains at least one break (a null-sourced slot — real PDFs wrap + // at line ends; a plain "word- word" space is NOT a wrap). + const prevIsHyphen = raw[i - 1] === "-" + const runHasBreak = rawSource.slice(i, j).some((s) => s === null) + if (prevIsHyphen && runHasBreak) { + // Drop the hyphen and the whole run — no space between the joined + // halves. + out[i - 1] = "" + outSource[i - 1] = null + } else { + // Collapse the run to one space in its first slot. The space maps + // to its own source — a break-space maps to the char before the + // break (breakFallback), a real whitespace char to itself. + out[i] = " " + outSource[i] = rawSource[i] ?? breakFallback(i) + } + i = j + continue + } + out[i] = raw[i] + outSource[i] = rawSource[i] + if (raw[i].trim().length > 0) hasText = true + i++ + } + + // Assemble the normalized text + run-length steps. + let text = "" + const steps: PdfIndexStep[] = [] + for (let k = 0; k < n; k++) { + const ch = out[k] + if (!ch) continue + text += ch + const src = outSource[k] + if (!src) continue + const last = steps[steps.length - 1] + if (last && last.node === src.node && last.offset + last.count === src.offset && last.page === src.page) { + last.count++ + } else { + steps.push({ node: src.node, offset: src.offset, count: 1, page: src.page }) + } + } + + return { text, steps, hasText } +} + +// --------------------------------------------------------------------------- +// Offset mapping +// --------------------------------------------------------------------------- + +/** Map a normalized-string offset to a DOM (textNode, offset) boundary. */ +function locate(steps: PdfIndexStep[], offset: number): { node: Text; offset: number } | null { + let base = 0 + for (const step of steps) { + const end = base + step.count + if (offset < end) return { node: step.node, offset: step.offset + (offset - base) } + if (offset === end) { + // A step boundary — return this step's end. Either side is a valid + // Range boundary; end-of-step plays best with client rects. + return { node: step.node, offset: step.offset + step.count } + } + base = end + } + const last = steps[steps.length - 1] + return last ? { node: last.node, offset: last.offset + last.count } : null +} + +/** Page of the span a normalized offset starts in (for the match counter + * and per-page tagging). */ +function pageAt(steps: PdfIndexStep[], offset: number): number { + let base = 0 + for (const step of steps) { + const end = base + step.count + if (offset < end) return step.page + base = end + } + return steps[steps.length - 1]?.page ?? 1 +} + +// --------------------------------------------------------------------------- +// Search +// --------------------------------------------------------------------------- + +export interface PdfFindOptions { + caseSensitive?: boolean + wholeWord?: boolean +} + +/** + * Find all matches of `query` in the index. Case-insensitive by default + * (S1); wholeWord is part of the signature but unused until S2. Returns + * matches in document order. Empty query → empty list. + */ +export function findMatches(index: PdfIndex, query: string, opts: PdfFindOptions = {}): PdfMatch[] { + const needleRaw = query.normalize("NFKC") + if (!needleRaw) return [] + const haystack = opts.caseSensitive ? index.text : index.text.toLowerCase() + const needle = opts.caseSensitive ? needleRaw : needleRaw.toLowerCase() + if (!needle) return [] + + const matches: PdfMatch[] = [] + let at = haystack.indexOf(needle) + while (at !== -1) { + const start = locate(index.steps, at) + const end = locate(index.steps, at + needle.length) + if (start && end) { + const range = document.createRange() + try { + range.setStart(start.node, start.offset) + range.setEnd(end.node, end.offset) + } catch { + at = haystack.indexOf(needle, at + needle.length) + continue + } + const bounds = range.getBoundingClientRect() + matches.push({ + page: pageAt(index.steps, at), + start: at, + end: at + needle.length, + startNode: start.node, + startOffset: start.offset, + endNode: end.node, + endOffset: end.offset, + rect: { top: bounds.top, left: bounds.left, bottom: bounds.bottom, right: bounds.right }, + range, + }) + } + at = haystack.indexOf(needle, at + needle.length) + } + return matches +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-guard.ts b/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-guard.ts new file mode 100644 index 000000000..8b8c381f5 --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-guard.ts @@ -0,0 +1,52 @@ +/** + * pdf-find-guard — boot-time shield for Cmd+F on Preview PDF panes. + * + * Upstream's file-find (session-ui/pierre/file-find.ts) installs a + * window-capture keydown listener the first time a `File` (diff/tool card) + * component mounts anywhere in the app. That listener calls + * preventDefault + stopPropagation on Cmd+F for non-editable targets, + * stealing the key from our Preview PDF panes. Its one escape hatch: it + * skips events where `event.defaultPrevented` is already true. + * + * This module installs a window-capture keydown listener at boot — before + * any `File` can mount — that, when the event target is inside a + * find-capable Preview PDF pane (a [data-preview-scroll] container under a + * [data-preview-host] whose file is a PDF), calls `event.preventDefault()` + * ONLY. Never stopPropagation: the command registry listens on + * document-capture and must still see the key to open the pane's find pill. + * + * @module + */ + +/** True when the event target is inside a find-capable Preview PDF pane. + * Editable fields (the find input, page input, zoom input) are NOT find + * targets — their Cmd+F reaches the pane via the registry's own fallback, + * and we never want to preventDefault a key an editable might own. */ +export function isPdfFindTarget(target: EventTarget | null): boolean { + if (!(target instanceof Element)) return false + if (target instanceof HTMLElement) { + if (target.isContentEditable) return false + if (target.closest("[contenteditable='true']")) return false + if (target.closest(".cm-editor")) return false + if (target.closest("input, textarea, select")) return false + } + const scroll = target.closest("[data-preview-scroll]") + if (!scroll) return false + const host = scroll.closest("[data-preview-host]") + const path = host?.getAttribute("data-preview-host") ?? "" + return path.toLowerCase().endsWith(".pdf") +} + +export function installPdfFindGuard(win: Window = window): () => void { + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return + if (!(event.metaKey || event.ctrlKey)) return + if (event.key.toLowerCase() !== "f") return + if (!isPdfFindTarget(event.target)) return + // preventDefault ONLY — file-find skips defaultPrevented events, and the + // command registry (document-capture) still fires to open the pill. + event.preventDefault() + } + win.addEventListener("keydown", onKeyDown, true) + return () => win.removeEventListener("keydown", onKeyDown, true) +} diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-highlight.ts b/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-highlight.ts new file mode 100644 index 000000000..e828b55ef --- /dev/null +++ b/packages/app-bundle/overlay/packages/app/src/components/session/pdf-find-highlight.ts @@ -0,0 +1,85 @@ +/** + * pdf-find-highlight — CSS Custom Highlight API painter for PDF find (S1). + * + * Paints matches via CSS.highlights under the "amicode-pdf-find*" names — + * upstream's file-find owns "opencode-find*" and deletes them globally, so + * those names are off-limits. The DOM (text layer, canvas) is NEVER touched. + * + * The PDF page is always white, so the colors are fixed and theme- + * independent: all-matches a translucent yellow, the current match a + * stronger orange. A small