0) Problem Restatement
Adobe asked a frontend system design question: in a PDF viewer, build a search box. The user types a word, the viewer highlights all matches in the whole PDF, shows "match 3 of 27", and Next / Previous buttons (and Enter / Shift+Enter) cycle through matches, scrolling to each one, even on pages that aren't rendered yet. The interviewer discussed pub/sub, React context, and state libraries like Redux, Zustand and MobX.
1) Components
Architecture Diagram
flowchart LR
SB["SearchBox - input, counter, next/prev"] -->|"setQuery / next / prev"| STORE["Search store"]
STORE --> VIEW["PdfViewer - virtualized page list"]
VIEW --> PAGE["Page - canvas + text layer + highlights"]
WK["Web worker - text index + search"] --> STORE
STORE --> WK- SearchBox: input (debounced), "i of N", next/prev buttons, and keyboard shortcuts.
- PdfViewer: a virtualized list of pages (only visible pages are rendered).
- Page: draws the page (canvas) plus a text layer (invisible positioned text spans, like PDF.js), and draws highlight boxes for matches on that page.
- Search store:
{ query, matches: [{page, start, end}], activeIndex }.
2) Searching
- At load time, extract text per page (PDF.js
getTextContent) in a Web Worker, so the UI thread isn't blocked. Build a per-page text string plus a mapping from character offsets to text spans (for highlight positions). - On query (debounced ~200 ms): the worker finds all matches (case-insensitive, normalized whitespace) across pages and returns an ordered list
(page, charStart, charEnd). Cancel an old search if the user keeps typing. - For large PDFs, stream results back page by page, so the counter grows ("27+ matches...").
3) State and Communication
- Where to keep state: a small store shared by the search box and the pages. Options:
- React Context + useReducer: fine, but any change re-renders every consumer. Avoid it for fast-changing state if many pages subscribe.
- Zustand / Redux with selectors (our choice): each Page subscribes only to its own matches and whether the active match is on it (
select(s => s.matchesByPage[pageNo])), so moving to the next match re-renders just 1–2 pages. - MobX: observable state with fine-grained updates, which also works well.
- Pub/sub view: the store acts as the event bus. The search box publishes
next(), and pages and the viewer react toactiveIndexchanges.
4) Cycling to a Match on an Unrendered Page
next()setsactiveIndex = (activeIndex + 1) % N(wrapping around).- The viewer reads the active match's page, and scrolls the virtualized list to that page (we know each page's height from PDF metadata, so we can compute the offset without rendering).
- When the page mounts, it renders its text layer and highlights, then scrolls the exact match rectangle into view (centered).
- The active match gets a stronger highlight color, and all others a lighter one.
5) Performance and Accessibility
- Only visible pages (plus a buffer) have canvases. Highlights are simple absolutely positioned divs over the text layer.
- The search runs in a worker, and the result list is compact. Memoize per-page highlight rectangles.
- Accessibility:
aria-liveannouncements ("Match 3 of 27"), keyboard shortcuts (Ctrl+F focuses the box, Enter/Shift+Enter navigates, Esc clears), and visible focus. - Tests: unit tests for the store logic (wrap-around, empty results), and integration tests that navigate to a match on page 80.
6) Wrap-Up
Extract page text in a web worker and search it there, producing an ordered list of (page, start, end) matches. Keep query, matches and the active index in a selector-based store (Zustand/Redux or MobX), so only affected pages re-render. Cycle with wrap-around, scroll the virtualized viewer to the target page using known page heights, then scroll the exact highlight into view once the page's text layer renders, with debounced input, keyboard shortcuts and live announcements.