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.
@@ -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
- // The page texts are already in memory, so the fallback is CPU only.
415
- // Pages are compared nearest to the hint first, a batch at a time,
416
- // and the scan stops at the first batch that holds the passage; a
417
- // yield between batches keeps a long document from freezing the UI.
418
- const order = pagesNearestFirst(firstPage, lastPage, nearPage);
419
- for (let offset = 0; offset < order.length && matches.length === 0; offset += fuzzy.pagesPerBatch) {
420
- if (offset > 0)
421
- await yieldToEventLoop();
422
- if (controller.signal.aborted)
423
- throw abortError();
424
- const batch = order
425
- .slice(offset, offset + fuzzy.pagesPerBatch)
426
- .map((pageIndex) => ({
427
- pageIndex,
428
- text: texts.get(pageIndex) ?? "",
429
- }));
430
- matches.push(...findFuzzyPageMatches(batch, cleanQuery, fuzzy, caseSensitive));
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.#goToPage(result.matches[result.activeIndex].pageIndex, true);
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.#goToPage(result.matches[activeIndex].pageIndex, true);
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;
@@ -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" ? 12 : (metrics.offsets[pageIndex] ?? 0) + 12;
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`;