@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/contract-6pr6LBhA.cjs +42 -0
- package/dist/contract-6pr6LBhA.cjs.map +1 -0
- package/dist/contract-B7maCczh.d.ts +214 -0
- package/dist/contract-DqeptOSn.d.cts +214 -0
- package/dist/contract-uWSW5IYM.js +31 -0
- package/dist/contract-uWSW5IYM.js.map +1 -0
- package/dist/contract.cjs +2 -1
- package/dist/contract.d.cts +2 -2
- package/dist/contract.d.ts +2 -2
- package/dist/contract.js +2 -2
- package/dist/index.cjs +335 -290
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +36 -8
- package/dist/index.d.ts +36 -8
- package/dist/index.js +336 -294
- package/dist/index.js.map +1 -1
- package/package.json +5 -5
- package/dist/contract-4PZsI7Ty.cjs +0 -13
- package/dist/contract-4PZsI7Ty.cjs.map +0 -1
- package/dist/contract-CSb66POC.d.cts +0 -170
- package/dist/contract-Cj4xgdMj.d.ts +0 -170
- package/dist/contract-YzH14CUn.js +0 -8
- package/dist/contract-YzH14CUn.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,361 +1,403 @@
|
|
|
1
|
-
import { n as
|
|
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 {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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
|
-
*
|
|
26
|
-
*
|
|
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
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
63
|
-
if (!
|
|
64
|
-
const segments = match.segments
|
|
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
|
-
|
|
74
|
-
pageIndex:
|
|
75
|
-
|
|
76
|
-
|
|
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((
|
|
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
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
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,
|
|
93
|
-
const
|
|
94
|
-
if (!doc) return false;
|
|
158
|
+
async function collect(query, options, sink) {
|
|
159
|
+
const { signal } = options;
|
|
95
160
|
sink.onStart();
|
|
96
|
-
let
|
|
97
|
-
const
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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 &&
|
|
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
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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,
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
|
173
|
-
const { hits } =
|
|
257
|
+
function goToHit(target, options) {
|
|
258
|
+
const { hits } = state();
|
|
174
259
|
if (hits.length === 0) return null;
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
...
|
|
183
|
-
...
|
|
184
|
-
...reveal
|
|
268
|
+
...settings.get().reveal,
|
|
269
|
+
...options
|
|
185
270
|
};
|
|
186
|
-
ctx.tryGet(StageToken)?.reveal(hit.
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
if (
|
|
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
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
323
|
-
|
|
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
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|