Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 19 additions & 7 deletions packages/app-bundle/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
},
Expand Down Expand Up @@ -922,7 +928,7 @@
}
},
"counts": {
"overlay_total": 851,
"overlay_total": 857,
"deletions": 7,
"server_coupled": 0
},
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -601,7 +606,13 @@ export function PdfCanvasView(props: PdfCanvasViewProps) {
}

return (
<div ref={wrapperRef} class="inline-flex flex-col items-center gap-3 p-4 min-w-full min-h-full">
<div
ref={(el) => {
wrapperRef = el
props.onRootChange?.(el ?? null)
}}
class="inline-flex flex-col items-center gap-3 p-4 min-w-full min-h-full"
>
<style>{PDF_TEXT_LAYER_STYLE}</style>
<style>{PDF_ANNOTATION_LAYER_STYLE}</style>
{textAvailabilityMessage() && (
Expand Down
Original file line number Diff line number Diff line change
@@ -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<PdfMatch[]>([])
const [currentIndex, setCurrentIndex] = createSignal(0)
const [hasText, setHasText] = createSignal(true)

let index: PdfIndex | null = null
let debounce: ReturnType<typeof setTimeout> | 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()
},
}
}
Loading
Loading