web-doc 0.4.0 → 0.6.0
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/THIRD_PARTY_NOTICES.md +1 -0
- package/dist/adapters/docx-images.d.ts +26 -0
- package/dist/adapters/docx-images.js +74 -0
- package/dist/adapters/office.d.ts +5 -0
- package/dist/adapters/office.js +25 -4
- package/dist/contracts.d.ts +20 -2
- package/dist/fuzzy-search-worker.d.ts +1 -0
- package/dist/fuzzy-search-worker.js +6 -0
- package/dist/fuzzy-search.d.ts +26 -3
- package/dist/fuzzy-search.js +78 -34
- package/dist/fuzzy-worker-client.d.ts +28 -0
- package/dist/fuzzy-worker-client.js +75 -0
- package/dist/fuzzy-worker-protocol.d.ts +39 -0
- package/dist/fuzzy-worker-protocol.js +35 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/search-reveal.d.ts +21 -0
- package/dist/search-reveal.js +29 -0
- package/dist/spreadsheet-viewport.d.ts +2 -0
- package/dist/spreadsheet-viewport.js +2 -0
- package/dist/viewer.js +96 -20
- package/dist/viewport.d.ts +9 -0
- package/dist/viewport.js +39 -1
- package/dist/workers/fuzzy-search-worker.js +1680 -0
- package/package.json +1 -1
|
@@ -24,6 +24,8 @@ export declare class SpreadsheetViewport {
|
|
|
24
24
|
setDocument(info: DocumentInfo | undefined): void;
|
|
25
25
|
update(): void;
|
|
26
26
|
panBy(deltaX: number, deltaY: number): void;
|
|
27
|
+
/** Sheet matches are cell-addressed; the sheet viewport scrolls per cell already. */
|
|
28
|
+
revealMatch(): Promise<void>;
|
|
27
29
|
goToPage(pageIndex: number): void;
|
|
28
30
|
fitWidth(): number;
|
|
29
31
|
fitPage(): number;
|
|
@@ -241,6 +241,8 @@ export class SpreadsheetViewport {
|
|
|
241
241
|
this.#reportPan();
|
|
242
242
|
this.schedule();
|
|
243
243
|
}
|
|
244
|
+
/** Sheet matches are cell-addressed; the sheet viewport scrolls per cell already. */
|
|
245
|
+
async revealMatch() { }
|
|
244
246
|
goToPage(pageIndex) {
|
|
245
247
|
if (pageIndex === this.#sheetIndex)
|
|
246
248
|
return;
|
package/dist/viewer.js
CHANGED
|
@@ -3,6 +3,7 @@ import { detectFormat } from "./detect.js";
|
|
|
3
3
|
import { abortError, normalizeError, ViewerError } from "./errors.js";
|
|
4
4
|
import { cellRangeToTsv, cellRangesToTsv, findNormalizedMatches, normalizeCellRange, } from "./interaction.js";
|
|
5
5
|
import { findFuzzyPageMatches, nearestMatchIndex, pagesNearestFirst, resolveFuzzySearchOptions, } from "./fuzzy-search.js";
|
|
6
|
+
import { FuzzyWorkerClient } from "./fuzzy-worker-client.js";
|
|
6
7
|
import { enforceContainerLimits, resolveLimits } from "./limits.js";
|
|
7
8
|
import { loadDocumentSource } from "./source.js";
|
|
8
9
|
import { AdaptiveViewport } from "./viewport.js";
|
|
@@ -31,6 +32,12 @@ export class DocumentViewer {
|
|
|
31
32
|
#activeSearch;
|
|
32
33
|
#selection = null;
|
|
33
34
|
#searchResult = null;
|
|
35
|
+
/** Off-thread fuzzy matcher holding the current document's index. */
|
|
36
|
+
#fuzzyWorker;
|
|
37
|
+
/** Which pages/options the worker's index was built from; rebuilt on change. */
|
|
38
|
+
#fuzzyIndexKey;
|
|
39
|
+
/** Set once the worker failed to start, so the main thread takes over for good. */
|
|
40
|
+
#fuzzyWorkerUnavailable = false;
|
|
34
41
|
#generation = 0;
|
|
35
42
|
#searchGeneration = 0;
|
|
36
43
|
#viewEventScheduled = false;
|
|
@@ -411,24 +418,37 @@ export class DocumentViewer {
|
|
|
411
418
|
matches.push(...findNormalizedMatches(text, cleanQuery, pageIndex, caseSensitive));
|
|
412
419
|
}
|
|
413
420
|
if (matches.length === 0 && fuzzy) {
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
const
|
|
419
|
-
for (
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
421
|
+
// With a hint the passage sits near it, so only that neighbourhood is
|
|
422
|
+
// worth the fuzzy cost; without one every page is a candidate.
|
|
423
|
+
const order = pagesNearestFirst(firstPage, lastPage, nearPage).slice(0, nearPage === undefined ? undefined : fuzzy.pageWindow);
|
|
424
|
+
const pattern = cleanQuery.slice(0, fuzzy.maxQueryLength);
|
|
425
|
+
const pages = [];
|
|
426
|
+
for (const [pageIndex, text] of texts)
|
|
427
|
+
pages.push({ pageIndex, text });
|
|
428
|
+
const offThread = fuzzy.worker
|
|
429
|
+
? await this.#searchFuzzyInWorker(pages, pattern, order, fuzzy, caseSensitive, controller.signal)
|
|
430
|
+
: undefined;
|
|
431
|
+
if (offThread)
|
|
432
|
+
matches.push(...offThread);
|
|
433
|
+
else
|
|
434
|
+
for (let offset = 0; offset < order.length && matches.length === 0; offset += fuzzy.pagesPerBatch) {
|
|
435
|
+
// The page texts are already in memory, so the fallback is CPU
|
|
436
|
+
// only. Pages are compared nearest to the hint first, a batch at
|
|
437
|
+
// a time, and the scan stops at the first batch that holds the
|
|
438
|
+
// passage; a yield between batches keeps a long document from
|
|
439
|
+
// freezing the UI.
|
|
440
|
+
if (offset > 0)
|
|
441
|
+
await yieldToEventLoop();
|
|
442
|
+
if (controller.signal.aborted)
|
|
443
|
+
throw abortError();
|
|
444
|
+
const batch = order
|
|
445
|
+
.slice(offset, offset + fuzzy.pagesPerBatch)
|
|
446
|
+
.map((pageIndex) => ({
|
|
447
|
+
pageIndex,
|
|
448
|
+
text: texts.get(pageIndex) ?? "",
|
|
449
|
+
}));
|
|
450
|
+
matches.push(...findFuzzyPageMatches(batch, pattern, fuzzy, caseSensitive));
|
|
451
|
+
}
|
|
432
452
|
if (matches.length > 0)
|
|
433
453
|
strategy = "fuzzy";
|
|
434
454
|
}
|
|
@@ -442,7 +462,7 @@ export class DocumentViewer {
|
|
|
442
462
|
});
|
|
443
463
|
this.#searchResult = result;
|
|
444
464
|
if (result.activeIndex >= 0)
|
|
445
|
-
this.#
|
|
465
|
+
this.#revealSearchMatch(result.matches[result.activeIndex]);
|
|
446
466
|
this.#emit("searchchange", result);
|
|
447
467
|
this.#viewport?.update();
|
|
448
468
|
return result;
|
|
@@ -452,6 +472,56 @@ export class DocumentViewer {
|
|
|
452
472
|
this.#activeSearch = undefined;
|
|
453
473
|
}
|
|
454
474
|
}
|
|
475
|
+
/**
|
|
476
|
+
* Fuzzy scan in the worker. The document's pages are indexed once per
|
|
477
|
+
* (range, options) and reused by every search until the document closes;
|
|
478
|
+
* `undefined` hands the scan back to the main thread when the worker is
|
|
479
|
+
* unavailable or failed, so a missing worker asset degrades to slowness,
|
|
480
|
+
* never to a lost match.
|
|
481
|
+
*/
|
|
482
|
+
async #searchFuzzyInWorker(pages, pattern, pageIndices, fuzzy, caseSensitive, signal) {
|
|
483
|
+
if (this.#fuzzyWorkerUnavailable)
|
|
484
|
+
return undefined;
|
|
485
|
+
try {
|
|
486
|
+
this.#fuzzyWorker ??= FuzzyWorkerClient.create(this.#fuzzyWorkerUrl());
|
|
487
|
+
if (!this.#fuzzyWorker) {
|
|
488
|
+
this.#fuzzyWorkerUnavailable = true;
|
|
489
|
+
return undefined;
|
|
490
|
+
}
|
|
491
|
+
const key = `${pages.map((page) => page.pageIndex).join(",")}|${fuzzy.threshold}|${fuzzy.maxPageTextLength}|${caseSensitive}`;
|
|
492
|
+
if (this.#fuzzyIndexKey !== key) {
|
|
493
|
+
this.#fuzzyIndexKey = undefined;
|
|
494
|
+
await this.#fuzzyWorker.index(pages, {
|
|
495
|
+
threshold: fuzzy.threshold,
|
|
496
|
+
maxPageTextLength: fuzzy.maxPageTextLength,
|
|
497
|
+
caseSensitive,
|
|
498
|
+
});
|
|
499
|
+
this.#fuzzyIndexKey = key;
|
|
500
|
+
}
|
|
501
|
+
const matches = await this.#fuzzyWorker.search(pattern, fuzzy.maxScore, pageIndices);
|
|
502
|
+
if (signal.aborted)
|
|
503
|
+
throw abortError();
|
|
504
|
+
return matches;
|
|
505
|
+
}
|
|
506
|
+
catch (error) {
|
|
507
|
+
if (signal.aborted)
|
|
508
|
+
throw error;
|
|
509
|
+
this.#runtime.logger?.warn?.("Fuzzy search worker unavailable; matching on the main thread", { message: error instanceof Error ? error.message : String(error) });
|
|
510
|
+
this.#dropFuzzyWorker();
|
|
511
|
+
this.#fuzzyWorkerUnavailable = true;
|
|
512
|
+
return undefined;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
#fuzzyWorkerUrl() {
|
|
516
|
+
return this.#runtime.assetBaseUrl
|
|
517
|
+
? new URL("workers/fuzzy-search-worker.js", this.#runtime.assetBaseUrl)
|
|
518
|
+
: new URL("./workers/fuzzy-search-worker.js", import.meta.url);
|
|
519
|
+
}
|
|
520
|
+
#dropFuzzyWorker() {
|
|
521
|
+
this.#fuzzyWorker?.terminate();
|
|
522
|
+
this.#fuzzyWorker = undefined;
|
|
523
|
+
this.#fuzzyIndexKey = undefined;
|
|
524
|
+
}
|
|
455
525
|
searchNext() {
|
|
456
526
|
return this.#moveSearch(1);
|
|
457
527
|
}
|
|
@@ -683,11 +753,16 @@ export class DocumentViewer {
|
|
|
683
753
|
current.matches.length;
|
|
684
754
|
const result = immutableSearchResult({ ...current, activeIndex });
|
|
685
755
|
this.#searchResult = result;
|
|
686
|
-
this.#
|
|
756
|
+
this.#revealSearchMatch(result.matches[activeIndex]);
|
|
687
757
|
this.#emit("searchchange", result);
|
|
688
758
|
this.#viewport?.update();
|
|
689
759
|
return result;
|
|
690
760
|
}
|
|
761
|
+
/** Land on the match's page, then bring the match itself into view. */
|
|
762
|
+
#revealSearchMatch(match) {
|
|
763
|
+
this.#goToPage(match.pageIndex, true);
|
|
764
|
+
void this.#viewport?.revealMatch(match);
|
|
765
|
+
}
|
|
691
766
|
#goToPage(pageIndex, scrollViewport) {
|
|
692
767
|
this.#assertAlive();
|
|
693
768
|
const upperBound = Math.max(0, this.#state.pageCount - 1);
|
|
@@ -714,6 +789,7 @@ export class DocumentViewer {
|
|
|
714
789
|
this.#activeSearch?.abort();
|
|
715
790
|
this.#activeSearch = undefined;
|
|
716
791
|
this.#searchResult = null;
|
|
792
|
+
this.#dropFuzzyWorker();
|
|
717
793
|
this.#selection = null;
|
|
718
794
|
this.#textMaps.clear();
|
|
719
795
|
this.#textMapBytes = 0;
|
package/dist/viewport.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ interface ViewportStrategy {
|
|
|
18
18
|
update(): void;
|
|
19
19
|
panBy(deltaX: number, deltaY: number): void;
|
|
20
20
|
goToPage(pageIndex: number): void;
|
|
21
|
+
revealMatch(match: SearchMatch): Promise<void>;
|
|
21
22
|
fitWidth(): number;
|
|
22
23
|
fitPage(): number;
|
|
23
24
|
destroy(): void;
|
|
@@ -32,6 +33,7 @@ export declare class AdaptiveViewport implements ViewportStrategy {
|
|
|
32
33
|
update(): void;
|
|
33
34
|
panBy(deltaX: number, deltaY: number): void;
|
|
34
35
|
goToPage(pageIndex: number): void;
|
|
36
|
+
revealMatch(match: SearchMatch): Promise<void>;
|
|
35
37
|
fitWidth(): number;
|
|
36
38
|
fitPage(): number;
|
|
37
39
|
destroy(): void;
|
|
@@ -46,6 +48,13 @@ export declare class ViewerViewport {
|
|
|
46
48
|
update(): void;
|
|
47
49
|
panBy(deltaX: number, deltaY: number): void;
|
|
48
50
|
goToPage(pageIndex: number): void;
|
|
51
|
+
/**
|
|
52
|
+
* Scroll so the match itself is in view, not just its page: a page can be
|
|
53
|
+
* taller than the viewport, and a search that only lands on the page top
|
|
54
|
+
* leaves a match further down invisible until the reader scrolls. Resolves
|
|
55
|
+
* once the text runs are known; a navigation that happened meanwhile wins.
|
|
56
|
+
*/
|
|
57
|
+
revealMatch(match: SearchMatch): Promise<void>;
|
|
49
58
|
fitWidth(): number;
|
|
50
59
|
fitPage(): number;
|
|
51
60
|
schedule(): void;
|
package/dist/viewport.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
import { matchTopInPage, revealScrollTop } from "./search-reveal.js";
|
|
1
2
|
import { snapGraphemeOffset } from "./interaction.js";
|
|
2
3
|
import { SpreadsheetViewport } from "./spreadsheet-viewport.js";
|
|
3
4
|
const BASE_WIDTH = 816;
|
|
4
5
|
const BASE_HEIGHT = 1056;
|
|
5
6
|
const PAGE_GAP = 24;
|
|
7
|
+
/** Page slots sit this far inside the spacer (top and left). */
|
|
8
|
+
const SLOT_INSET = 12;
|
|
6
9
|
export class AdaptiveViewport {
|
|
7
10
|
#container;
|
|
8
11
|
#host;
|
|
@@ -36,6 +39,9 @@ export class AdaptiveViewport {
|
|
|
36
39
|
goToPage(pageIndex) {
|
|
37
40
|
this.#strategy.goToPage(pageIndex);
|
|
38
41
|
}
|
|
42
|
+
revealMatch(match) {
|
|
43
|
+
return this.#strategy.revealMatch(match);
|
|
44
|
+
}
|
|
39
45
|
fitWidth() {
|
|
40
46
|
return this.#strategy.fitWidth();
|
|
41
47
|
}
|
|
@@ -154,6 +160,36 @@ export class ViewerViewport {
|
|
|
154
160
|
}
|
|
155
161
|
this.schedule();
|
|
156
162
|
}
|
|
163
|
+
/**
|
|
164
|
+
* Scroll so the match itself is in view, not just its page: a page can be
|
|
165
|
+
* taller than the viewport, and a search that only lands on the page top
|
|
166
|
+
* leaves a match further down invisible until the reader scrolls. Resolves
|
|
167
|
+
* once the text runs are known; a navigation that happened meanwhile wins.
|
|
168
|
+
*/
|
|
169
|
+
async revealMatch(match) {
|
|
170
|
+
if (this.#layout !== "continuous" || !this.#info)
|
|
171
|
+
return;
|
|
172
|
+
let runs;
|
|
173
|
+
try {
|
|
174
|
+
runs = await this.#host.getTextRuns(match.pageIndex);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (this.#destroyed || this.#host.state.pageIndex !== match.pageIndex)
|
|
180
|
+
return;
|
|
181
|
+
const top = matchTopInPage(runs, match);
|
|
182
|
+
if (top === undefined)
|
|
183
|
+
return;
|
|
184
|
+
const zoom = this.#host.state.zoom;
|
|
185
|
+
const metrics = pageMetrics(this.#info, zoom);
|
|
186
|
+
this.#root.scrollTop = revealScrollTop({
|
|
187
|
+
pageTop: metrics.offsets[match.pageIndex] ?? 0,
|
|
188
|
+
matchTop: SLOT_INSET + top * zoom,
|
|
189
|
+
viewportHeight: this.#root.clientHeight,
|
|
190
|
+
});
|
|
191
|
+
this.schedule();
|
|
192
|
+
}
|
|
157
193
|
fitWidth() {
|
|
158
194
|
const size = naturalPageSize(this.#info, this.#host.state.pageIndex);
|
|
159
195
|
return Math.max(0.1, Math.min(8, (this.#root.clientWidth - 24) / size.width));
|
|
@@ -217,7 +253,9 @@ export class ViewerViewport {
|
|
|
217
253
|
const slot = this.#slots.get(pageIndex) ?? this.#createSlot(pageIndex);
|
|
218
254
|
const width = metrics.widths[pageIndex] ?? BASE_WIDTH * state.zoom;
|
|
219
255
|
const height = metrics.heights[pageIndex] ?? BASE_HEIGHT * state.zoom;
|
|
220
|
-
const top = this.#layout === "single"
|
|
256
|
+
const top = this.#layout === "single"
|
|
257
|
+
? SLOT_INSET
|
|
258
|
+
: (metrics.offsets[pageIndex] ?? 0) + SLOT_INSET;
|
|
221
259
|
const contentWidth = Math.max(this.#root.clientWidth, metrics.maxWidth + 24);
|
|
222
260
|
slot.root.style.top = `${top}px`;
|
|
223
261
|
slot.root.style.left = `${Math.max(12, (contentWidth - width) / 2)}px`;
|