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