@embedpdf/plugin-search 3.0.0-next.13 → 3.0.0-next.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1,364 +1,409 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_contract = require("./contract-4PZsI7Ty.cjs");
2
+ const require_contract = require("./contract-6pr6LBhA.cjs");
3
3
  let _embedpdf_core = require("@embedpdf/core");
4
4
  let _embedpdf_plugin_stage_contract = require("@embedpdf/plugin-stage/contract");
5
5
  let _embedpdf_core_geometry = require("@embedpdf/core-geometry");
6
6
  let _embedpdf_engine_core_runtime = require("@embedpdf/engine-core/runtime");
7
- //#region src/capability.ts
8
- const EMPTY = [];
9
- /** The browser find-bar feel: hit at the top-middle, no zoom change. */
10
- const DEFAULT_REVEAL = {
11
- anchor: { y: .35 },
12
- behavior: "smooth"
7
+ //#region src/model.ts
8
+ /**
9
+ * The search session: the query, the hits found so far (also grouped by
10
+ * page), the active hit, and progress. Every function below is a pure
11
+ * transition; the controller applies them with `ctx.state.update`.
12
+ */
13
+ /** The progress of no search, one object for every session that hasn't searched a page yet. */
14
+ const NO_PROGRESS = Object.freeze({
15
+ pagesSearched: 0,
16
+ pageCount: 0
17
+ });
18
+ const initialSearchState = () => ({
19
+ query: null,
20
+ status: "idle",
21
+ operationId: null,
22
+ hits: [],
23
+ hitsByPage: {},
24
+ activeHitIndex: -1,
25
+ progress: NO_PROGRESS,
26
+ error: null
27
+ });
28
+ /** A new scan starts: everything from the previous session is dropped. */
29
+ const startSession = (_state, query, operationId) => ({
30
+ ...initialSearchState(),
31
+ query,
32
+ status: "searching",
33
+ operationId
34
+ });
35
+ const sameProgress = (left, right) => left.pagesSearched === right.pagesSearched && left.pageCount === right.pageCount;
36
+ /**
37
+ * A slice of hits arrived. The first hit becomes active so "1 of N" reads
38
+ * right; moving the camera is navigation's job, not the stream's. Progress
39
+ * with the same counts keeps its object: readers, and `onProgressChanged`,
40
+ * see a change only when the counts change.
41
+ */
42
+ function appendHits(state, hits, slice) {
43
+ const progress = sameProgress(state.progress, slice) ? state.progress : slice;
44
+ if (hits.length === 0) return progress === state.progress ? state : {
45
+ ...state,
46
+ progress
47
+ };
48
+ const hitsByPage = { ...state.hitsByPage };
49
+ for (const hit of hits) {
50
+ const pageObjectNumber = hit.page.objectNumber;
51
+ hitsByPage[pageObjectNumber] = [...hitsByPage[pageObjectNumber] ?? [], hit];
52
+ }
53
+ return {
54
+ ...state,
55
+ hits: [...state.hits, ...hits],
56
+ hitsByPage,
57
+ activeHitIndex: state.activeHitIndex === -1 ? 0 : state.activeHitIndex,
58
+ progress
59
+ };
60
+ }
61
+ const completeSession = (state) => ({
62
+ ...state,
63
+ status: "complete"
64
+ });
65
+ const cancelSession = (state) => ({
66
+ ...state,
67
+ status: "cancelled"
68
+ });
69
+ const failSession = (state, error) => ({
70
+ ...state,
71
+ status: "error",
72
+ error
73
+ });
74
+ const setActiveHit = (state, index) => state.activeHitIndex === index ? state : {
75
+ ...state,
76
+ activeHitIndex: index
13
77
  };
14
- /** Local engines reject a denied scope before the worker; cloud maps 403. */
15
- const isPermissionDenied = (err) => err instanceof Error && err.name === "PermissionDenied" || _embedpdf_engine_core_runtime.EngineError.is(err, _embedpdf_engine_core_runtime.EngineErrorCode.Forbidden);
78
+ const clearSession = (state) => state.status === "idle" ? state : initialSearchState();
16
79
  /**
17
- * The search capability: a client-driven cursor loop over the engine's
18
- * budgeted slices. One generation counter arbitrates: a new `search()` (or
19
- * `clear()`, or a document mutation via `rerun()`) supersedes the running
20
- * loop, which aborts its in-flight slice and stops dispatching.
80
+ * Where `hit` is in `hits`: the same object, or else the hit that covers the
81
+ * same characters of the same page, so a hit kept from before the search ran
82
+ * again still finds its place. -1 when it isn't there.
83
+ */
84
+ function indexOfHit(hits, hit) {
85
+ const same = hits.indexOf(hit);
86
+ if (same >= 0) return same;
87
+ return hits.findIndex((candidate) => candidate.start === hit.start && candidate.count === hit.count && (0, _embedpdf_core.pageRefsEqual)(candidate.page, hit.page));
88
+ }
89
+ /**
90
+ * The pages that have at least one hit, in document order: `pages` is the
91
+ * document's page list, whose order moving a page changes (object numbers
92
+ * don't follow it).
93
+ */
94
+ const pagesWithHits = (state, pages) => pages.filter((page) => (state.hitsByPage[page.ref.objectNumber]?.length ?? 0) > 0).map((page) => page.ref);
95
+ //#endregion
96
+ //#region src/controller.ts
97
+ const EMPTY = Object.freeze([]);
98
+ const EMPTY_PAGES = Object.freeze([]);
99
+ /** Reruns after document mutations are coalesced: a burst of events triggers one rescan. */
100
+ const RERUN_DELAY_MS = 250;
101
+ /**
102
+ * The search controller. `search` runs on a `latest` lane (a newer search
103
+ * supersedes the older one, which can no longer publish); `findAll` runs on
104
+ * its own signal and never touches the session. Both share `collect`, the
105
+ * one cursor loop over the engine's budgeted slices.
21
106
  *
22
- * Rect conversion happens HERE, once per slice: the engine's PDF-space
23
- * line rects → content space through the same page geometry the selection
24
- * plugin uses, so search highlights and selection highlights are pixel-twins.
107
+ * The scan's own events (started, completed, cancelled, failed) fire where
108
+ * the scan reaches those points. The progress, active-hit and cleared events
109
+ * are derived from state changes, so they always agree with the getters.
25
110
  *
26
- * Stage integration is optional and additive: with a Stage present the scan
27
- * starts at the current page (viewport-first — the hits the user is looking
28
- * at arrive in the first slice) and `next()`/`prev()`/`goTo()` reveal the
29
- * hit's page. Without one, navigation still works; the host scrolls.
111
+ * The settings are read where they're used, never copied, so
112
+ * `updateSettings({ reveal })` applies to the next move at once.
30
113
  */
31
- function createSearchCapability(ctx, config = {}) {
32
- let generation = 0;
33
- let inflight = null;
114
+ function createSearchController(ctx) {
115
+ const settings = ctx.settings();
116
+ const session = ctx.latest("session");
117
+ const started = ctx.events.source();
118
+ const progressChanged = ctx.events.source();
119
+ const completed = ctx.events.source();
120
+ const cancelled = ctx.events.source();
121
+ const failed = ctx.events.source();
122
+ const activeHitChanged = ctx.events.source();
123
+ const cleared = ctx.events.source();
124
+ const state = () => ctx.state.get();
125
+ ctx.state.onChange(({ previous, next }) => {
126
+ if (previous.progress !== next.progress) progressChanged.emit({
127
+ ...next.progress,
128
+ hitCount: next.hits.length
129
+ });
130
+ if (previous.activeHitIndex !== next.activeHitIndex) activeHitChanged.emit({
131
+ index: next.activeHitIndex,
132
+ hit: next.hits[next.activeHitIndex] ?? null
133
+ });
134
+ if (previous.status !== "idle" && next.status === "idle") cleared.emit({});
135
+ });
34
136
  function hitsFromSlice(slice) {
35
- const converters = /* @__PURE__ */ new Map();
36
- const converterFor = (pon) => {
37
- let conv = converters.get(pon);
38
- if (conv !== void 0) return conv;
39
- const layout = ctx.document()?.pages.find((p) => p.pageObjectNumber === pon);
40
- if (!layout) conv = null;
41
- else {
42
- const { pdfToContent } = (0, _embedpdf_core_geometry.pageGeometry)({
43
- crop: layout.boxes.crop,
44
- rotation: layout.rotation,
45
- userUnit: layout.userUnit
46
- }, 1);
47
- const toPoint = (pt) => (0, _embedpdf_core_geometry.applyPoint)(pdfToContent, pt);
48
- conv = {
49
- pageIndex: layout.index,
50
- toQuad: (q) => ({
51
- upperStart: toPoint(q.p1),
52
- upperEnd: toPoint(q.p2),
53
- lowerStart: toPoint(q.p3),
54
- lowerEnd: toPoint(q.p4)
55
- })
56
- };
57
- }
58
- converters.set(pon, conv);
59
- return conv;
60
- };
61
137
  const hits = [];
62
138
  for (const match of slice.matches) {
63
- const conv = converterFor(match.pageObjectNumber);
64
- if (!conv) continue;
65
- const segments = match.segments.map((segment) => {
66
- const quad = conv.toQuad(segment.quad);
67
- return {
68
- quad,
69
- rect: (0, _embedpdf_core_geometry.textQuadBounds)(quad),
70
- advance: segment.advance
71
- };
72
- });
139
+ const page = ctx.getPage(match.page);
140
+ if (!page) continue;
141
+ const segments = match.segments;
73
142
  hits.push({
74
- pon: match.pageObjectNumber,
75
- pageIndex: conv.pageIndex,
76
- charStart: match.charStart,
77
- charCount: match.charCount,
143
+ page: match.page,
144
+ pageIndex: page.index,
145
+ start: match.start,
146
+ count: match.count,
78
147
  segments,
79
- ...segments.length ? { bounds: (0, _embedpdf_core_geometry.boundsOfRects)(segments.map((s) => s.rect)) ?? void 0 } : {},
148
+ ...segments.length ? { bounds: (0, _embedpdf_core_geometry.boundsOfRects)(segments.map((segment) => segment.rect)) ?? void 0 } : {},
80
149
  ...match.snippet ? { snippet: match.snippet } : {}
81
150
  });
82
151
  }
83
152
  return hits;
84
153
  }
85
154
  /**
86
- * THE MECHANISM — one cursor loop over the engine's budgeted slices,
87
- * shared by the session (`search`) and the session-free service
88
- * (`findAll`): permission fallback ('full' → 'rects' when snippets are
89
- * denied, unless the caller pinned a mode), restart-once on a stale
90
- * cursor, rect conversion per slice. Resolves true on exhaustion, false
91
- * when cancelled; throws on error.
155
+ * One cursor loop over budgeted slices: permission fallback (snippets → none
156
+ * when they are denied, unless pinned), restart-once on a stale cursor,
157
+ * projection per slice. Returns true on exhaustion, false when the signal
158
+ * aborted; throws on a real error (already a PluginError: ctx.doc is guarded).
92
159
  */
93
- async function collect(query, opts, sink) {
94
- const doc = ctx.doc;
95
- if (!doc) return false;
160
+ async function collect(query, options, sink) {
161
+ const { signal } = options;
96
162
  sink.onStart();
97
- let mode = opts.mode ?? "full";
98
- const modePinned = opts.mode !== void 0;
163
+ let snippets = options.snippets ?? true;
164
+ const snippetsPinned = options.snippets !== void 0;
99
165
  let cursor;
100
166
  let restarted = false;
101
167
  for (;;) {
168
+ if (signal?.aborted) return false;
102
169
  let slice;
170
+ const request = {
171
+ ...query,
172
+ snippets,
173
+ ...cursor !== void 0 ? { cursor } : options.from !== void 0 ? { from: options.from } : {}
174
+ };
103
175
  try {
104
- const request = {
105
- query,
106
- mode,
107
- ...cursor !== void 0 ? { cursor } : opts.startPage !== void 0 ? { startPage: opts.startPage } : {}
108
- };
109
- const pending = doc.search.query(request);
110
- sink.setInflight(pending);
111
- slice = await pending;
112
- } catch (err) {
113
- if (sink.isCancelled()) return false;
114
- if (!modePinned && mode === "full" && cursor === void 0 && isPermissionDenied(err)) {
115
- mode = "rects";
176
+ slice = await ctx.cancellable(signal, ctx.doc.with({ priority: "low" }).search.query(request));
177
+ } catch (error) {
178
+ if (signal?.aborted) return false;
179
+ if (!snippetsPinned && snippets && cursor === void 0 && (0, _embedpdf_core.isPluginError)(error, "permission-denied")) {
180
+ snippets = false;
116
181
  continue;
117
182
  }
118
- if (cursor !== void 0 && !restarted && _embedpdf_engine_core_runtime.EngineError.is(err, _embedpdf_engine_core_runtime.EngineErrorCode.InvalidArg)) {
183
+ if (cursor !== void 0 && !restarted && (0, _embedpdf_core.isPluginError)(error, "invalid-input")) {
119
184
  restarted = true;
120
185
  cursor = void 0;
121
186
  sink.onStart();
122
187
  continue;
123
188
  }
124
- throw err;
125
- }
126
- if (sink.isCancelled()) return false;
127
- sink.onSlice(hitsFromSlice(slice), slice.scannedPages, slice.totalPages);
128
- if (slice.nextCursor === null) {
129
- sink.setInflight(null);
130
- return true;
189
+ throw error;
131
190
  }
191
+ if (signal?.aborted) return false;
192
+ sink.onSlice(hitsFromSlice(slice), slice.pagesSearched, slice.pageCount);
193
+ if (slice.nextCursor === null) return true;
132
194
  cursor = slice.nextCursor;
133
195
  }
134
196
  }
135
- /** THE SESSION — collect() with state as the sink, generation-guarded. */
136
- async function runSession(query, startPage) {
137
- const gen = ++generation;
138
- inflight?.abort("superseded");
139
- inflight = null;
140
- if (!ctx.doc) {
141
- ctx.dispatch({ type: "CLEAR" });
142
- return;
143
- }
144
- if (startPage === void 0) {
145
- const stage = ctx.tryGet(_embedpdf_plugin_stage_contract.StageToken);
146
- if (stage) startPage = ctx.document()?.pages[stage.currentPage()]?.pageObjectNumber;
197
+ const viewportFirstPage = () => {
198
+ return ctx.tryGet(_embedpdf_plugin_stage_contract.StageToken)?.getCurrentPage() ?? void 0;
199
+ };
200
+ async function search(query, options = {}) {
201
+ if (query.text.length === 0) {
202
+ clear();
203
+ return {
204
+ status: "complete",
205
+ hitCount: 0
206
+ };
147
207
  }
148
- try {
149
- if (await collect(query, { startPage }, {
150
- onStart: () => ctx.dispatch({
151
- type: "START",
152
- query
208
+ ctx.assertAllowed("doc.text.search", "search");
209
+ const from = options.from === void 0 ? viewportFirstPage() : ctx.pageOf(options.from).ref;
210
+ let operationId = "";
211
+ return session.run(async (run) => {
212
+ operationId = run.id;
213
+ if (!await collect(query, {
214
+ from,
215
+ signal: run.signal
216
+ }, {
217
+ onStart: () => run.commit(() => {
218
+ ctx.state.update(startSession, query, run.id);
219
+ started.emit({ query });
153
220
  }),
154
- onSlice: (hits, scanned, total) => ctx.dispatch({
155
- type: "APPEND",
156
- hits,
157
- scanned,
158
- total
159
- }),
160
- isCancelled: () => gen !== generation,
161
- setInflight: (pending) => {
162
- if (gen === generation) inflight = pending;
163
- }
164
- }) && gen === generation) ctx.dispatch({ type: "COMPLETE" });
165
- } catch (err) {
166
- if (gen !== generation) return;
167
- ctx.dispatch({
168
- type: "ERROR",
169
- message: err instanceof Error ? err.message : String(err)
221
+ onSlice: (hits, pagesSearched, pageCount) => run.commit(() => ctx.state.update(appendHits, hits, {
222
+ pagesSearched,
223
+ pageCount
224
+ }))
225
+ })) throw new _embedpdf_core.PluginError("operation-cancelled", "search", "search aborted");
226
+ run.commit(() => {
227
+ ctx.state.update(completeSession);
228
+ completed.emit({ hitCount: state().hits.length });
170
229
  });
171
- }
230
+ return {
231
+ status: "complete",
232
+ hitCount: state().hits.length
233
+ };
234
+ }, options).catch((error) => {
235
+ if ((0, _embedpdf_core.isPluginError)(error, "instance-closed")) return {
236
+ status: "cancelled",
237
+ hitCount: 0
238
+ };
239
+ if ((0, _embedpdf_core.isPluginError)(error, "operation-cancelled")) {
240
+ const why = error.details?.reason ?? "cancelled";
241
+ const reason = why === "superseded" ? "superseded" : why === "cleared" ? "cleared" : "cancelled";
242
+ if (reason === "cancelled" && state().operationId === operationId) ctx.state.update(cancelSession);
243
+ cancelled.emit({ reason });
244
+ return {
245
+ status: reason === "superseded" ? "superseded" : "cancelled",
246
+ hitCount: state().hits.length
247
+ };
248
+ }
249
+ const info = (0, _embedpdf_core.toPluginErrorInfo)((0, _embedpdf_core.toPluginError)("search", error));
250
+ if (state().operationId === operationId) ctx.state.update(failSession, info);
251
+ failed.emit({ error: info });
252
+ throw error;
253
+ });
254
+ }
255
+ function clear() {
256
+ session.cancel("cleared");
257
+ ctx.state.update(clearSession);
172
258
  }
173
- function goTo(index, reveal) {
174
- const { hits } = ctx.getState();
259
+ function goToHit(target, options) {
260
+ const { hits } = state();
175
261
  if (hits.length === 0) return null;
176
- const wrapped = (index % hits.length + hits.length) % hits.length;
177
- ctx.dispatch({
178
- type: "SET_ACTIVE",
179
- index: wrapped
180
- });
181
- const hit = hits[wrapped];
262
+ const index = typeof target === "number" ? (target % hits.length + hits.length) % hits.length : indexOfHit(hits, target);
263
+ if (index < 0) return null;
264
+ ctx.state.update(setActiveHit, index);
265
+ reveal(hits[index], options);
266
+ return hits[index];
267
+ }
268
+ function reveal(hit, options) {
182
269
  const arrival = {
183
- ...DEFAULT_REVEAL,
184
- ...config.reveal,
185
- ...reveal
270
+ ...settings.get().reveal,
271
+ ...options
186
272
  };
187
- ctx.tryGet(_embedpdf_plugin_stage_contract.StageToken)?.reveal(hit.pageIndex, {
273
+ ctx.tryGet(_embedpdf_plugin_stage_contract.StageToken)?.reveal(hit.page, {
188
274
  rect: hit.bounds,
189
275
  anchor: arrival.anchor,
190
276
  behavior: arrival.behavior
191
277
  });
192
- return hit;
193
278
  }
194
- const clear = () => {
195
- generation++;
196
- inflight?.abort("cleared");
197
- inflight = null;
198
- ctx.dispatch({ type: "CLEAR" });
279
+ /**
280
+ * One page's hits, by the page's ref or index. A page that isn't in the
281
+ * document has none: a layer may read while its page is being deleted.
282
+ */
283
+ const hitsOn = (page) => {
284
+ const found = ctx.getPage(page);
285
+ return found ? state().hitsByPage[found.ref.objectNumber] ?? EMPTY : EMPTY;
199
286
  };
200
- return {
201
- canSearch: (mode) => {
202
- const sec = ctx.doc?.security;
203
- if (!sec) return false;
204
- return sec.allows("doc.text.search") && (mode !== "full" || sec.allows("doc.text.copy"));
205
- },
206
- search: (query, exec) => query.text.length === 0 ? clear() : void runSession(query, exec?.startPage),
207
- rerun: () => {
208
- const { query, status } = ctx.getState();
209
- if (query && status !== "idle") runSession(query);
287
+ const api = {
288
+ ...settings.api,
289
+ canSearch: (options) => ctx.allows("doc.text.search") && (!options?.snippets || ctx.allows("doc.text.copy")),
290
+ search,
291
+ refresh: (options) => {
292
+ const { query, status } = state();
293
+ if (!query || status === "idle") return Promise.resolve({
294
+ status: "complete",
295
+ hitCount: 0
296
+ });
297
+ return search(query, options);
210
298
  },
299
+ cancel: () => session.cancel("cancelled"),
211
300
  clear,
212
- next: () => goTo(ctx.getState().activeIndex + 1),
213
- prev: () => goTo(ctx.getState().activeIndex - 1),
214
- goTo,
215
- query: () => ctx.getState().query,
216
- findAll: async (query, opts = {}) => {
217
- if (!ctx.doc) return [];
218
- const all = [];
219
- const signal = opts.signal;
220
- let pending = null;
221
- const onAbort = () => pending?.abort(signal?.reason);
222
- signal?.addEventListener("abort", onAbort, { once: true });
223
- try {
224
- await collect(query, { mode: opts.mode }, {
225
- onStart: () => {
226
- all.length = 0;
227
- },
228
- onSlice: (hits) => all.push(...hits),
229
- isCancelled: () => signal?.aborted ?? false,
230
- setInflight: (p) => {
231
- pending = p;
232
- }
233
- });
234
- if (signal?.aborted) throw signal.reason ?? /* @__PURE__ */ new Error("findAll aborted");
235
- return all;
236
- } finally {
237
- signal?.removeEventListener("abort", onAbort);
238
- }
239
- },
240
- status: () => ctx.getState().status,
241
- hits: () => ctx.getState().hits,
242
- hitCount: () => ctx.getState().hits.length,
243
- activeIndex: () => ctx.getState().activeIndex,
244
- activeHit: () => {
245
- const { hits, activeIndex } = ctx.getState();
246
- return activeIndex >= 0 && activeIndex < hits.length ? hits[activeIndex] : null;
301
+ nextHit: (options) => goToHit(state().activeHitIndex + 1, options),
302
+ previousHit: (options) => goToHit(state().activeHitIndex - 1, options),
303
+ goToHit,
304
+ revealActiveHit: (options) => {
305
+ const hit = api.getActiveHit();
306
+ if (hit) reveal(hit, options);
247
307
  },
248
- hitsForPage: (pon) => {
249
- const { hits, hitsByPage } = ctx.getState();
250
- const indices = hitsByPage[pon];
251
- return indices?.length ? indices.map((i) => hits[i]) : EMPTY;
308
+ getQuery: () => state().query,
309
+ getStatus: () => state().status,
310
+ listHits: (filter) => filter?.page === void 0 ? state().hits : hitsOn(filter.page),
311
+ getHitCount: (page) => (page === void 0 ? state().hits : hitsOn(page)).length,
312
+ listPagesWithHits: () => state().hits.length ? pagesWithHits(state(), ctx.document()?.pages ?? []) : EMPTY_PAGES,
313
+ getActiveHitIndex: () => state().activeHitIndex,
314
+ getActiveHit: () => state().hits[state().activeHitIndex] ?? null,
315
+ getProgress: () => state().progress,
316
+ getError: () => state().error,
317
+ findAll: async (query, options = {}) => {
318
+ ctx.assertAllowed("doc.text.search", "findAll");
319
+ const all = [];
320
+ if (!await collect(query, {
321
+ ...options.snippets !== void 0 ? { snippets: options.snippets } : {},
322
+ ...options.signal ? { signal: options.signal } : {}
323
+ }, {
324
+ onStart: () => {
325
+ all.length = 0;
326
+ },
327
+ onSlice: (hits) => all.push(...hits)
328
+ })) throw new _embedpdf_core.PluginError("operation-cancelled", "search", "findAll aborted");
329
+ return all;
252
330
  },
253
- progress: () => ctx.getState().progress,
254
- errorMessage: () => ctx.getState().error
331
+ onStarted: started.on,
332
+ onProgressChanged: progressChanged.on,
333
+ onCompleted: completed.on,
334
+ onCancelled: cancelled.on,
335
+ onFailed: failed.on,
336
+ onActiveHitChanged: activeHitChanged.on,
337
+ onCleared: cleared.on
255
338
  };
256
- }
257
- //#endregion
258
- //#region src/effects.ts
259
- const RERUN_DELAY_MS = 250;
260
- function registerSearchEffects(ctx) {
261
- const doc = ctx.doc;
262
- if (!doc) return;
263
- let timer = null;
264
- const unsubscribe = doc.events.subscribe(() => {
265
- if (ctx.getState().status === "idle") return;
266
- if (timer !== null) clearTimeout(timer);
267
- timer = setTimeout(() => {
268
- timer = null;
269
- ctx.get(require_contract.SearchToken).rerun();
270
- }, RERUN_DELAY_MS);
271
- });
272
- ctx.cleanup(() => {
273
- if (timer !== null) clearTimeout(timer);
274
- unsubscribe();
275
- });
276
- }
277
- //#endregion
278
- //#region src/reducer.ts
279
- const initialSearchState = () => ({
280
- query: null,
281
- status: "idle",
282
- hits: [],
283
- hitsByPage: {},
284
- activeIndex: -1,
285
- progress: {
286
- scanned: 0,
287
- total: 0
288
- },
289
- error: null
290
- });
291
- const searchReducer = (state, action) => {
292
- switch (action.type) {
293
- case "START": return {
294
- ...initialSearchState(),
295
- query: action.query,
296
- status: "searching"
297
- };
298
- case "APPEND": {
299
- if (action.hits.length === 0) return {
300
- ...state,
301
- progress: {
302
- scanned: action.scanned,
303
- total: action.total
304
- }
305
- };
306
- const hits = state.hits.concat(action.hits);
307
- const hitsByPage = { ...state.hitsByPage };
308
- for (let i = state.hits.length; i < hits.length; i++) {
309
- const pon = hits[i].pon;
310
- hitsByPage[pon] = (hitsByPage[pon] ?? []).concat(i);
311
- }
312
- return {
313
- ...state,
314
- hits,
315
- hitsByPage,
316
- activeIndex: state.activeIndex === -1 ? 0 : state.activeIndex,
317
- progress: {
318
- scanned: action.scanned,
319
- total: action.total
320
- }
321
- };
339
+ return {
340
+ api,
341
+ connect() {
342
+ let cancelRerun = null;
343
+ ctx.listen(ctx.doc.events, () => {
344
+ if (state().status === "idle") return;
345
+ cancelRerun?.();
346
+ cancelRerun = ctx.clock.after(RERUN_DELAY_MS, () => {
347
+ cancelRerun = null;
348
+ api.refresh().catch(() => {});
349
+ });
350
+ });
322
351
  }
323
- case "COMPLETE": return {
324
- ...state,
325
- status: "complete"
326
- };
327
- case "ERROR": return {
328
- ...state,
329
- status: "error",
330
- error: action.message
331
- };
332
- case "SET_ACTIVE": return {
333
- ...state,
334
- activeIndex: action.index
335
- };
336
- case "CLEAR": return initialSearchState();
337
- default: return state;
338
- }
339
- };
352
+ };
353
+ }
340
354
  //#endregion
341
355
  //#region src/search.plugin.ts
342
356
  /**
343
- * Document text search over `doc.search` — the engine's budgeted,
344
- * cursor-resumable slices. Document-scoped; no pointer handling, so it
345
- * needs no interaction hub. The Stage is optional: when present, scans
346
- * start at the current page (viewport-first) and hit navigation reveals
347
- * the hit's page; without it the host owns scrolling.
357
+ * Document text search over the engine's budgeted, resumable slices.
358
+ * Document-scoped; no pointer handling. The stage is optional: with one,
359
+ * scans start at the current page and navigation reveals hits. `config` is
360
+ * the settings the app registers, over {@link SEARCH_DEFAULTS}.
348
361
  */
349
362
  const searchPlugin = (config) => (0, _embedpdf_core.definePlugin)({
350
363
  id: "search",
351
364
  token: require_contract.SearchToken,
352
365
  scope: "document",
353
366
  optional: [_embedpdf_plugin_stage_contract.StageToken],
354
- initialState: initialSearchState,
355
- reduce: searchReducer,
356
- capability: (ctx) => createSearchCapability(ctx, config),
357
- effects: registerSearchEffects
367
+ state: initialSearchState,
368
+ settings: {
369
+ defaults: require_contract.SEARCH_DEFAULTS,
370
+ registered: config
371
+ },
372
+ create: createSearchController
373
+ });
374
+ //#endregion
375
+ //#region src/state.ts
376
+ /**
377
+ * The search page's State table as code: what `useSearchState()` returns, and
378
+ * the same fields in every other framework. The matches themselves are not
379
+ * here: `useSearchHits()` reads them, so a component that only shows the
380
+ * count doesn't re-render for every match that arrives.
381
+ */
382
+ const searchState = (0, _embedpdf_core.defineState)(require_contract.SearchToken, {
383
+ read: (search) => ({
384
+ query: search.getQuery(),
385
+ status: search.getStatus(),
386
+ hitCount: search.getHitCount(),
387
+ activeHitIndex: search.getActiveHitIndex(),
388
+ activeHit: search.getActiveHit(),
389
+ progress: search.getProgress(),
390
+ error: search.getError()
391
+ }),
392
+ empty: {
393
+ query: null,
394
+ status: "idle",
395
+ hitCount: 0,
396
+ activeHitIndex: -1,
397
+ activeHit: null,
398
+ progress: NO_PROGRESS,
399
+ error: null
400
+ }
358
401
  });
359
402
  //#endregion
403
+ exports.SEARCH_DEFAULTS = require_contract.SEARCH_DEFAULTS;
360
404
  exports.SearchToken = require_contract.SearchToken;
361
405
  exports.searchPlugin = searchPlugin;
406
+ exports.searchState = searchState;
362
407
  Object.defineProperty(exports, "validateSearchQuery", {
363
408
  enumerable: true,
364
409
  get: function() {