# Search

Index document text, display matches, and jump to highlighted results.

Source: https://anara.com/lector/docs/code/search

These docs describe the source deployed with this site, tracking main rather than a versioned npm release. Check your installed @anaralabs/lector and pdfjs-dist versions before applying examples.

Search has three parts: `Search` extracts the document's text, `useSearch` finds matches, and `HighlightLayer` draws a selected result. Mount `Search` once per viewer. Keep `Pages` outside it so indexing does not replace the document with a loading message.

## A searchable viewer

This example searches on submit to avoid doing fuzzy matching on every keystroke. It uses the `pdf-setup.ts` from [installation](https://anara.com/lector/docs/installation).

```tsx title="searchable-viewer.tsx"
"use client";

import {
  CanvasLayer, HighlightLayer, Page, Pages, Root, Search, TextLayer,
  calculateHighlightRects, usePdf, usePdfJump, useSearch,
  type SearchResult,
} from "@anaralabs/lector";
import { useState } from "react";
import "./pdf-setup";

function SearchPanel() {
  const [query, setQuery] = useState("");
  const [submitted, setSubmitted] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const { search, searchResults } = useSearch();
  const getPage = usePdf((state) => state.getPdfPageProxy);
  const { jumpToHighlightRects } = usePdfJump();
  const setHighlight = usePdf((state) => state.setHighlight);
  const results = [...searchResults.exactMatches, ...searchResults.fuzzyMatches];

  async function openResult(result: SearchResult) {
    setError(null);
    try {
      const rects = await calculateHighlightRects(getPage(result.pageNumber), result);
      jumpToHighlightRects(rects, "pixels", "center");
    } catch {
      setError("Couldn't locate this match on the page.");
    }
  }

  return (
    <div>
      <form onSubmit={(event) => {
        event.preventDefault();
        search(query, { limit: 20 });
        setHighlight([]);
        setSubmitted(true);
        setError(null);
      }}>
        <label>
          Find in document
          <input value={query} onChange={(event) => setQuery(event.target.value)} />
        </label>
        <button type="submit">Search</button>
      </form>
      <p role="status">
        {submitted ? `${results.length} results shown` : "Enter text to search."}
      </p>
      {error && <p role="alert">{error}</p>}
      <ul>
        {results.map((result) => (
          <li key={`${result.pageNumber}-${result.matchIndex}-${result.isExactMatch}`}>
            <button type="button" onClick={() => void openResult(result)}>
              Page {result.pageNumber}: {result.text}
              {!result.isExactMatch && " (similar match)"}
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default function SearchableViewer() {
  return (
    <Root source="/sample.pdf" style={{ height: 600, display: "flex", flexDirection: "column" }}>
      <div style={{ maxHeight: 200, overflow: "auto" }}>
        <Search loading={<p role="status">Indexing document…</p>}>
          <SearchPanel />
        </Search>
      </div>
      <div style={{ flex: 1, minHeight: 0 }}>
        <Pages>
          <Page>
            <CanvasLayer />
            <TextLayer />
            <HighlightLayer style={{ background: "#ffdf6080" }} />
          </Page>
        </Pages>
      </div>
    </Root>
  );
}
```

`calculateHighlightRects` uses the result's page number, match index, and `searchText` to locate the match. Passing the result itself keeps highlighting tied to the submitted query even if the user has since edited the input. It returns pixel rectangles in page coordinates.

## Search options and results

`search(text, options)` is synchronous. It returns the results and also updates `searchResults` in that hook instance.

| Option      | Default | Meaning                                                 |
| ----------- | ------- | ------------------------------------------------------- |
| `threshold` | `0.7`   | Minimum fuzzy similarity; use a value from 0 to 1       |
| `limit`     | `10`    | Result budget shared between exact and fuzzy groups     |
| `textSize`  | `100`   | Characters of trailing context to include after a match |

Results contain `exactMatches`, `fuzzyMatches`, and `hasMoreResults`. Each match has `pageNumber`, `text`, `matchIndex`, `score`, `isExactMatch`, and optional `searchText`. Matching is case-insensitive. Searching an empty or whitespace-only query clears results.

The current implementation allocates at most `ceil(limit / 2)` results to exact matches and the remaining budget to fuzzy matches. It can therefore return fewer than `limit` results even when more exact matches exist. `hasMoreResults` compares the total candidate count with `limit`; it is not a reliable count of everything omitted by the group allocation. If you build “show more,” rerun the submitted query with a higher limit rather than treating the result as paginated data.

## Practical limits

`Search` extracts text from all pages, even though `Pages` only renders a viewport's worth of pages. Mount the search panel on demand for large PDFs. For search-as-you-type, debounce the query and measure with representative documents; lowering the result limit does not stop the full-document matching pass.

Image-only scans need an OCR text layer before they can be searched. PDF text extraction can also differ from visual reading order or spacing. Check the extracted text when a phrase looks present but is not found.

## Live example

Open the documentation page linked above to use this interactive example.

## Responsive search in long documents

`useSearch()` also exposes `searchAsync`, `cancelSearch`, and `isSearching`.
`searchAsync` returns the same ranked results as `search`, but yields between
batches so the browser can process input. It defaults to an approximate 8 ms work
budget. Scheduling can increase total search time; the benefit is responsiveness.

```tsx
const { searchAsync, cancelSearch, searchResults, isSearching } = useSearch();
const [searchError, setSearchError] = useState<Error | null>(null);

useEffect(() => {
  setSearchError(null);
  void searchAsync(debouncedSearchText, { limit: 10 }).catch((error) => {
    if (error.name !== "AbortError") setSearchError(error);
  });
  return cancelSearch;
}, [debouncedSearchText, searchAsync, cancelSearch]);
```

A newer call to either search method cancels pending async work from that hook.
Unmounting or replacing document text also cancels it. Cancelled calls reject
with `AbortError` and never publish partial results. You can pass an external
`AbortSignal` through `{ signal }` or adjust `{ timeSliceMs }` when needed.
Keep the component inside `<Root>` and use `<Search>` to index the document.

## Indexing failures and retry

If text extraction fails, `Search` keeps its children unmounted and shows an error
with a Retry button. Retry starts a new indexing attempt without reloading the
viewer. To match your application's error presentation, provide a fallback:

```tsx
<Search
  errorFallback={({ retry }) => (
    <p role="alert">
      Search is unavailable. <button onClick={retry}>Try again</button>
    </p>
  )}
>
  <SearchPanel />
</Search>
```

This fragment replaces the existing `Search` wrapper in the example above.
The fallback also receives the original `error`. It handles indexing failures;
`Root.onError` handles document loading failures.
