@inkbar/core 0.6.0 → 0.7.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/dist/dom.mjs CHANGED
@@ -1,5 +1,5 @@
1
- import { $t as derived, B as isTextMarkup, Bt as withCalloutBounds, E as withoutReplies, G as resizedRect, Gt as isKnown, H as movedEndpoint, Ht as LINE_ENDINGS, I as hasCallout, J as withBox, Jt as cssVariableDefaults, K as scaled, L as hasCornerHandles, Lt as calloutStroke, O as createToolStyle, Qt as createRecordStore, R as hasRectGeometry, Rt as freeTextBox, S as isThreadRoot, T as threadRoot, U as movedTip, V as keepsAspect, W as resizedBox, Y as openDocument, Z as deriveRect, Zt as mergeLineRects, _ as defaultEnds, b as isAbandonedNote, bn as isInkError, d as recogniseStroke, dn as rectFromPoints, en as readonly, f as DEFAULT_CORNER_RADIUS, g as constrain, h as SHAPE_KINDS, i as createInkTemplate, in as invertMatrix, l as placeSignature, m as DEFAULT_SHAPE_SIZE, mn as rectsIntersect, nn as applyMatrix, o as createMemorySignatureStore, p as DEFAULT_SHAPE_OPTIONS, q as translated, r as createImageTemplate, s as createSignatureLibrary, tn as writable, tt as isProduction, v as isPointerOnlyKind, w as threadOf, y as shapeInput, yn as InkError, z as isBubble, zt as isStandardCallout } from "./signatures-D68fuLlt.mjs";
2
- import { ALargeSmall, AlignCenter, AlignJustify, AlignLeft, AlignRight, Bold, Check, ChevronDown, ChevronLeft, ChevronRight, Circle, CircleDashed, Hexagon, Highlighter, ICON_ATTRIBUTES, Italic, MessageCircle, MessageSquare, MessageSquareText, Minus, MoveUpRight, PenLine, PenTool, Plus, RectangleHorizontal, Redo2, Search, Signature, Slash, SpellCheck2, Square, SquareDashed, SquareDashedMousePointer, Star, Strikethrough, TextCursor, Trash2, Type, Underline, Undo2, ZoomIn, ZoomOut } from "@inkbar/icons";
1
+ import { $t as createRecordStore, B as isTextMarkup, Bt as isStandardCallout, E as withoutReplies, G as resizedRect, H as movedEndpoint, I as hasCallout, J as withBox, K as scaled, Kt as isKnown, L as hasCornerHandles, O as createToolStyle, Q as deriveRect, Qt as mergeLineRects, R as hasRectGeometry, Rt as calloutStroke, S as isThreadRoot, T as threadRoot, U as movedTip, Ut as LINE_ENDINGS, V as keepsAspect, Vt as withCalloutBounds, W as resizedBox, X as isBlankQuery, Y as openDocument, Yt as cssVariableDefaults, _ as defaultEnds, an as invertMatrix, b as isAbandonedNote, bn as InkError, d as recogniseStroke, en as derived, f as DEFAULT_CORNER_RADIUS, fn as rectFromPoints, g as constrain, h as SHAPE_KINDS, hn as rectsIntersect, i as createInkTemplate, l as placeSignature, m as DEFAULT_SHAPE_SIZE, nn as writable, nt as isProduction, o as createMemorySignatureStore, p as DEFAULT_SHAPE_OPTIONS, q as translated, r as createImageTemplate, rn as applyMatrix, s as createSignatureLibrary, tn as readonly, v as isPointerOnlyKind, w as threadOf, xn as isInkError, y as shapeInput, z as isBubble, zt as freeTextBox } from "./signatures-Do_l2IlM.mjs";
2
+ import { ALargeSmall, AlignCenter, AlignJustify, AlignLeft, AlignRight, Bold, CaseSensitive, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Circle, CircleDashed, Hexagon, Highlighter, ICON_ATTRIBUTES, Italic, MessageCircle, MessageSquare, MessageSquareText, Minus, MoveUpRight, PenLine, PenTool, Plus, RectangleHorizontal, Redo2, Search, Signature, Slash, SpellCheck2, Square, SquareDashed, SquareDashedMousePointer, Star, Strikethrough, TextCursor, Trash2, Type, Underline, Undo2, X, ZoomIn, ZoomOut } from "@inkbar/icons";
3
3
  import { getStroke } from "perfect-freehand";
4
4
  //#region src/dom/transform.ts
5
5
  /** Folds any integer multiple of 90 onto 0, 90, 180 or 270. Throws `RangeError` otherwise. */
@@ -3803,6 +3803,23 @@ function createPluginLayers(source) {
3803
3803
  }
3804
3804
  //#endregion
3805
3805
  //#region src/dom/plugins/host.ts
3806
+ /** What `ctx.search` is when the host has no search: closed, and every action does nothing. */
3807
+ const inertSearch = {
3808
+ state: readonly(writable({
3809
+ open: false,
3810
+ query: "",
3811
+ matchCase: false,
3812
+ hits: [],
3813
+ current: -1,
3814
+ scanning: false
3815
+ })),
3816
+ open() {},
3817
+ setQuery() {},
3818
+ setMatchCase() {},
3819
+ next() {},
3820
+ previous() {},
3821
+ close() {}
3822
+ };
3806
3823
  /**
3807
3824
  * `value` seen with `extra` laid over it, read-only. Everything else is read from and written to
3808
3825
  * `value` itself, and its methods are bound to it, so a method always runs on the original
@@ -3960,7 +3977,8 @@ function createPluginHost(opts) {
3960
3977
  },
3961
3978
  scrollTo(page, rect, scrollOpts) {
3962
3979
  opts.viewport?.()?.scrollToRect(page, rect, scrollOpts);
3963
- }
3980
+ },
3981
+ search: opts.search ?? inertSearch
3964
3982
  };
3965
3983
  };
3966
3984
  for (const plugin of opts.plugins) {
@@ -4053,6 +4071,7 @@ function createPageMount(opts) {
4053
4071
  transform,
4054
4072
  ...callbacks("text")
4055
4073
  });
4074
+ const marks = opts.highlights?.mount(page, host, transform);
4056
4075
  const overlay = createOverlay({
4057
4076
  host,
4058
4077
  page,
@@ -4074,10 +4093,12 @@ function createPageMount(opts) {
4074
4093
  textLayer.update(next);
4075
4094
  overlay.update(next);
4076
4095
  layers?.update(next);
4096
+ marks?.update(next);
4077
4097
  },
4078
4098
  destroy() {
4079
4099
  layers?.destroy();
4080
4100
  overlay.destroy();
4101
+ marks?.destroy();
4081
4102
  textLayer.destroy();
4082
4103
  renderer.destroy();
4083
4104
  }
@@ -6629,6 +6650,109 @@ function trapTab(e, container) {
6629
6650
  }
6630
6651
  }
6631
6652
  //#endregion
6653
+ //#region src/dom/search/highlights.ts
6654
+ const NO_HITS = [];
6655
+ /** One box over the quad: its CSS bounding box, clipped to the quad when it is not upright. */
6656
+ function hitBox(doc, t, quad) {
6657
+ const { p1, p2, p3, p4 } = t.toCssQuad(quad);
6658
+ const xs = [
6659
+ p1.x,
6660
+ p2.x,
6661
+ p3.x,
6662
+ p4.x
6663
+ ];
6664
+ const ys = [
6665
+ p1.y,
6666
+ p2.y,
6667
+ p3.y,
6668
+ p4.y
6669
+ ];
6670
+ const left = Math.min(...xs);
6671
+ const top = Math.min(...ys);
6672
+ const width = Math.max(...xs) - left;
6673
+ const height = Math.max(...ys) - top;
6674
+ const el = doc.createElement("div");
6675
+ el.dataset.searchHit = "";
6676
+ let css = `position:absolute;left:${left}px;top:${top}px;width:${width}px;height:${height}px;`;
6677
+ if (!(Math.abs(p1.y - p2.y) < .01 && Math.abs(p3.y - p4.y) < .01 || Math.abs(p1.x - p2.x) < .01 && Math.abs(p3.x - p4.x) < .01) && width > 0 && height > 0) {
6678
+ const pt = (p) => `${((p.x - left) / width * 100).toFixed(3)}% ${((p.y - top) / height * 100).toFixed(3)}%`;
6679
+ css += `clip-path:polygon(${[
6680
+ p1,
6681
+ p2,
6682
+ p4,
6683
+ p3
6684
+ ].map(pt).join(",")});`;
6685
+ }
6686
+ el.style.cssText = css;
6687
+ return el;
6688
+ }
6689
+ function createSearchHighlights(search) {
6690
+ const pages = /* @__PURE__ */ new Map();
6691
+ const sameHits = (a, b) => a.length === b.length && a.every((hit, i) => hit === b[i]);
6692
+ const draw = (page, force, hits) => {
6693
+ const { current: index, hits: all } = search.state.get();
6694
+ const current = all[index];
6695
+ const shown = current && hits.includes(current) ? current : void 0;
6696
+ if (!force && sameHits(page.hits, hits) && page.current === shown) return;
6697
+ page.hits = hits;
6698
+ page.current = shown;
6699
+ const doc = page.el.ownerDocument;
6700
+ page.el.replaceChildren(...hits.flatMap((hit) => hit.quads.map((quad) => {
6701
+ const box = hitBox(doc, page.transform, quad);
6702
+ if (hit === shown) box.dataset.current = "";
6703
+ return box;
6704
+ })));
6705
+ };
6706
+ /** The hits on `page`: a run of the state's hits, which are in `(page, start)` order. */
6707
+ const hitsOn = (all, page) => {
6708
+ const first = (p) => {
6709
+ let low = 0;
6710
+ let high = all.length;
6711
+ while (low < high) {
6712
+ const mid = low + high >>> 1;
6713
+ if (all[mid].page < p) low = mid + 1;
6714
+ else high = mid;
6715
+ }
6716
+ return low;
6717
+ };
6718
+ const from = first(page);
6719
+ const to = first(page + 1);
6720
+ return to > from ? all.slice(from, to) : NO_HITS;
6721
+ };
6722
+ return {
6723
+ mount(index, host, transform) {
6724
+ const el = host.ownerDocument.createElement("div");
6725
+ el.className = "ink-search-layer";
6726
+ el.dataset.inkSearchLayer = "";
6727
+ el.setAttribute("aria-hidden", "true");
6728
+ el.style.cssText = "position:absolute;inset:0;pointer-events:none;";
6729
+ const overlay = host.querySelector(":scope > .ink-overlay");
6730
+ host.insertBefore(el, overlay);
6731
+ const page = {
6732
+ el,
6733
+ transform,
6734
+ hits: NO_HITS,
6735
+ current: void 0
6736
+ };
6737
+ pages.set(index, page);
6738
+ draw(page, true, hitsOn(search.state.get().hits, index));
6739
+ return {
6740
+ update(next) {
6741
+ page.transform = next;
6742
+ draw(page, true, page.hits);
6743
+ },
6744
+ destroy() {
6745
+ if (pages.get(index) === page) pages.delete(index);
6746
+ el.remove();
6747
+ }
6748
+ };
6749
+ },
6750
+ destroy: search.state.subscribe(({ hits }) => {
6751
+ for (const [index, page] of pages) draw(page, false, hitsOn(hits, index));
6752
+ })
6753
+ };
6754
+ }
6755
+ //#endregion
6632
6756
  //#region src/dom/viewer-engine.ts
6633
6757
  function mountViewer(opts) {
6634
6758
  const { container: el, document: doc } = opts;
@@ -6645,6 +6769,8 @@ function mountViewer(opts) {
6645
6769
  const hostTools = [...opts.tools ?? [], ...plugins?.tools ?? []];
6646
6770
  /** Plugin layers follow the host for the engine's life, across rebuilds. */
6647
6771
  const layers = plugins && createPluginLayers(plugins);
6772
+ /** Match highlights follow the controller for the engine's life, across rebuilds. */
6773
+ const highlights = opts.search && createSearchHighlights(opts.search);
6648
6774
  const decorate = plugins ? (a) => plugins.decorate(a) : (a) => opts.decorateAnnotation?.(a);
6649
6775
  /** A plugin's `ctx.redecorate()` reaches every page this engine has mounted. */
6650
6776
  const stopRedecorate = plugins?.watchRedecorate(() => {
@@ -6680,6 +6806,7 @@ function mountViewer(opts) {
6680
6806
  isCustom: () => isCustom,
6681
6807
  decorate,
6682
6808
  ...layers ? { layers } : {},
6809
+ ...highlights ? { highlights } : {},
6683
6810
  onError: (cause, layer) => {
6684
6811
  failed.add(layer);
6685
6812
  if (error) return;
@@ -6865,11 +6992,292 @@ function mountViewer(opts) {
6865
6992
  destroyed = true;
6866
6993
  teardown();
6867
6994
  layers?.destroy();
6995
+ highlights?.destroy();
6868
6996
  stopRedecorate?.();
6869
6997
  }
6870
6998
  };
6871
6999
  }
6872
7000
  //#endregion
7001
+ //#region src/dom/search/controller.ts
7002
+ function compare(a, b) {
7003
+ return a.page - b.page || a.start - b.start;
7004
+ }
7005
+ /** Where `hit` goes in `hits`, which is sorted by `(page, start)`. */
7006
+ function insertionIndex(hits, hit) {
7007
+ let low = 0;
7008
+ let high = hits.length;
7009
+ while (low < high) {
7010
+ const mid = low + high >>> 1;
7011
+ if (compare(hits[mid], hit) <= 0) low = mid + 1;
7012
+ else high = mid;
7013
+ }
7014
+ return low;
7015
+ }
7016
+ function createSearchController(opts) {
7017
+ const debounceMs = opts.debounceMs ?? 150;
7018
+ const state = writable({
7019
+ open: false,
7020
+ query: "",
7021
+ matchCase: false,
7022
+ hits: [],
7023
+ current: -1,
7024
+ scanning: false
7025
+ });
7026
+ const bars = writable(0);
7027
+ const patch = (changes) => state.update((s) => ({
7028
+ ...s,
7029
+ ...changes
7030
+ }));
7031
+ let timer;
7032
+ let scan;
7033
+ /** The current hit and its index in `hits`, kept in step as pages of hits arrive. */
7034
+ let currentHit;
7035
+ let currentIndex = -1;
7036
+ /** Hits of one page not yet in the state, in page order, and the task that adds them. */
7037
+ let pending = [];
7038
+ let flushTimer;
7039
+ let destroyed = false;
7040
+ function scrollTo(hit) {
7041
+ opts.viewport()?.scrollToRect(hit.page, hit.rect);
7042
+ }
7043
+ /** Stops the pending debounce and the running scan, and empties the hits. */
7044
+ function reset() {
7045
+ clearTimeout(timer);
7046
+ timer = void 0;
7047
+ scan?.abort();
7048
+ scan = void 0;
7049
+ currentHit = void 0;
7050
+ currentIndex = -1;
7051
+ pending = [];
7052
+ clearTimeout(flushTimer);
7053
+ flushTimer = void 0;
7054
+ patch({
7055
+ hits: [],
7056
+ current: -1,
7057
+ scanning: false
7058
+ });
7059
+ }
7060
+ /**
7061
+ * Adds the pending page of hits to the state in one change. A page's hits sit together in
7062
+ * `(page, start)` order, so they go in with one splice, and `current` moves by their count
7063
+ * when they land before it. The first hit becomes current and is scrolled to, unless
7064
+ * `scroll` is false: a step flushes first and scrolls to where it lands instead.
7065
+ */
7066
+ function flush(scroll = true) {
7067
+ clearTimeout(flushTimer);
7068
+ flushTimer = void 0;
7069
+ if (pending.length === 0) return;
7070
+ const batch = pending;
7071
+ pending = [];
7072
+ const first = batch[0];
7073
+ const { hits } = state.get();
7074
+ const at = insertionIndex(hits, first);
7075
+ const next = hits.slice(0, at).concat(batch, hits.slice(at));
7076
+ if (!currentHit) {
7077
+ currentHit = first;
7078
+ currentIndex = at;
7079
+ if (scroll) scrollTo(first);
7080
+ } else if (at <= currentIndex) currentIndex += batch.length;
7081
+ patch({
7082
+ hits: next,
7083
+ current: currentIndex
7084
+ });
7085
+ }
7086
+ /**
7087
+ * Holds `hit` until its page is done: the next page's first hit, the end of the scan, or the
7088
+ * next task, since a page's hits arrive together and the scan yields between pages.
7089
+ */
7090
+ function accept(hit) {
7091
+ if (pending.length > 0 && pending[0].page !== hit.page) flush();
7092
+ pending.push(hit);
7093
+ flushTimer ??= setTimeout(() => flush());
7094
+ }
7095
+ async function run(doc, controller) {
7096
+ const { query, matchCase } = state.get();
7097
+ const count = doc.pages.length;
7098
+ const from = Math.min(Math.max(opts.viewport()?.currentPage.get() ?? 0, 0), Math.max(count - 1, 0));
7099
+ const pages = [...Array.from({ length: count - from }, (_, i) => from + i), ...Array.from({ length: from }, (_, i) => i)];
7100
+ try {
7101
+ for await (const hit of doc.search(query, {
7102
+ matchCase,
7103
+ pages,
7104
+ signal: controller.signal
7105
+ })) {
7106
+ if (controller.signal.aborted) return;
7107
+ accept(hit);
7108
+ }
7109
+ } catch (error) {
7110
+ if (!controller.signal.aborted) {
7111
+ console.error(error);
7112
+ if (scan === controller) scan = void 0;
7113
+ flush();
7114
+ patch({ scanning: false });
7115
+ return;
7116
+ }
7117
+ }
7118
+ if (scan === controller) {
7119
+ scan = void 0;
7120
+ flush();
7121
+ patch({ scanning: false });
7122
+ }
7123
+ }
7124
+ function start() {
7125
+ timer = void 0;
7126
+ scan?.abort();
7127
+ scan = void 0;
7128
+ const doc = opts.document.get();
7129
+ if (!doc || isBlankQuery(state.get().query)) {
7130
+ patch({ scanning: false });
7131
+ return;
7132
+ }
7133
+ const controller = new AbortController();
7134
+ scan = controller;
7135
+ patch({ scanning: true });
7136
+ run(doc, controller);
7137
+ }
7138
+ /** Empties the hits now and scans again, after the debounce when `delay` is set. */
7139
+ function restart(delay) {
7140
+ reset();
7141
+ if (isBlankQuery(state.get().query)) return;
7142
+ patch({ scanning: true });
7143
+ if (delay) timer = setTimeout(start, debounceMs);
7144
+ else start();
7145
+ }
7146
+ function step(by) {
7147
+ const shown = currentHit !== void 0;
7148
+ flush(false);
7149
+ const { hits, current } = state.get();
7150
+ if (hits.length === 0) return;
7151
+ const index = shown ? (current + by + hits.length) % hits.length : current;
7152
+ const hit = hits[index];
7153
+ currentHit = hit;
7154
+ currentIndex = index;
7155
+ patch({ current: index });
7156
+ scrollTo(hit);
7157
+ }
7158
+ const stopDocument = opts.document.subscribe(() => {
7159
+ if (destroyed) return;
7160
+ reset();
7161
+ if (state.get().open) restart(false);
7162
+ });
7163
+ function setQuery(query) {
7164
+ patch({ query });
7165
+ restart(true);
7166
+ }
7167
+ function open(query) {
7168
+ const wasOpen = state.get().open;
7169
+ patch({ open: true });
7170
+ if (query !== void 0) setQuery(query);
7171
+ else if (!wasOpen && !isBlankQuery(state.get().query)) restart(false);
7172
+ }
7173
+ return {
7174
+ state: readonly(state),
7175
+ open,
7176
+ setQuery,
7177
+ setMatchCase(on) {
7178
+ patch({ matchCase: on });
7179
+ restart(false);
7180
+ },
7181
+ next: () => step(1),
7182
+ previous: () => step(-1),
7183
+ close() {
7184
+ reset();
7185
+ patch({ open: false });
7186
+ },
7187
+ attachBar() {
7188
+ let attached = true;
7189
+ bars.update((n) => n + 1);
7190
+ return () => {
7191
+ if (!attached) return;
7192
+ attached = false;
7193
+ bars.update((n) => n - 1);
7194
+ };
7195
+ },
7196
+ barAttached: {
7197
+ get: () => bars.get() > 0,
7198
+ subscribe(fn) {
7199
+ let last = bars.get() > 0;
7200
+ return bars.subscribe((n) => {
7201
+ if (n > 0 === last) return;
7202
+ last = n > 0;
7203
+ fn(last);
7204
+ });
7205
+ }
7206
+ },
7207
+ destroy() {
7208
+ destroyed = true;
7209
+ stopDocument();
7210
+ reset();
7211
+ }
7212
+ };
7213
+ }
7214
+ /**
7215
+ * The actions and state of `controller` without the bar registration: what callers of a viewer
7216
+ * or a plugin get.
7217
+ *
7218
+ * @internal Shared with the framework bindings.
7219
+ */
7220
+ function viewerSearchOf(controller) {
7221
+ return {
7222
+ state: controller.state,
7223
+ open: (query) => controller.open(query),
7224
+ setQuery: (query) => controller.setQuery(query),
7225
+ setMatchCase: (on) => controller.setMatchCase(on),
7226
+ next: () => controller.next(),
7227
+ previous: () => controller.previous(),
7228
+ close: () => controller.close()
7229
+ };
7230
+ }
7231
+ //#endregion
7232
+ //#region src/dom/search/find-key.ts
7233
+ /** Longest page selection that becomes the query. */
7234
+ const MAX_SELECTION = 200;
7235
+ const FIELD = "[data-ink-find-bar] input";
7236
+ /**
7237
+ * The text selected on the page inside `root`, on one line and trimmed; '' when there is none,
7238
+ * when it is not in `root`, when it is in the find bar, or when it is too long to be a query.
7239
+ */
7240
+ function pageSelectionText(root) {
7241
+ const sel = root.ownerDocument.defaultView?.getSelection();
7242
+ if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return "";
7243
+ const { anchorNode, focusNode } = sel;
7244
+ if (!anchorNode || !focusNode || !root.contains(anchorNode) || !root.contains(focusNode)) return "";
7245
+ const bar = (node) => (node instanceof Element ? node : node.parentElement)?.closest("[data-ink-find-bar]") != null;
7246
+ if (bar(anchorNode) || bar(focusNode)) return "";
7247
+ const text = sel.toString().replace(/\s+/g, " ").trim();
7248
+ return text.length > MAX_SELECTION ? "" : text;
7249
+ }
7250
+ /**
7251
+ * F by its letter, or by its place on a layout without Latin letters (Cyrillic, Greek), where
7252
+ * `key` is that layout's letter. A Latin layout with another letter there keeps that letter.
7253
+ */
7254
+ function isFindKey(e) {
7255
+ if (e.key.toLowerCase() === "f") return true;
7256
+ return e.code === "KeyF" && !/^[a-z]$/i.test(e.key);
7257
+ }
7258
+ /**
7259
+ * Ctrl/Cmd+F on a viewer's root. Call it from a `keydown` listener on that root (it reads
7260
+ * `currentTarget`). Takes the key only when a find bar is mounted for this viewer and the event
7261
+ * comes from inside the root; otherwise the browser keeps it. Opens the search with the page selection as
7262
+ * the query, if there is one, and focuses and selects the field. Returns whether it took the key.
7263
+ *
7264
+ * @internal Shared with the framework bindings.
7265
+ */
7266
+ function handleFindKey(e, controller, selection) {
7267
+ if (!(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey || !isFindKey(e)) return false;
7268
+ if (!controller.barAttached.get()) return false;
7269
+ const root = e.currentTarget;
7270
+ if (!(root instanceof HTMLElement)) return false;
7271
+ if (!(e.target instanceof Node) || !root.contains(e.target)) return false;
7272
+ e.preventDefault();
7273
+ const text = selection();
7274
+ controller.open(text === "" ? void 0 : text);
7275
+ const field = root.querySelector(FIELD);
7276
+ field?.focus();
7277
+ field?.select();
7278
+ return true;
7279
+ }
7280
+ //#endregion
6873
7281
  //#region src/dom/viewer/thread-popover.ts
6874
7282
  function createThreadPopover(opts) {
6875
7283
  const { root, store, activated, focusReturn } = opts;
@@ -7045,7 +7453,7 @@ const passThrough = {
7045
7453
  react: () => noop
7046
7454
  };
7047
7455
  function createViewer(el, opts) {
7048
- const { source: initial, assets, password, defaults, history, plugins, threads: withThreads = true, onReady, onError, onActivate, onBuild, onPageMount, onPageUnmount, onPageError, toolStyle: _toolStyle, toolOptions: _toolOptions, signatureLibrary: _signatureLibrary, signatures: signatureStore, ...view } = opts;
7456
+ const { source: initial, assets, password, defaults, history, plugins, threads: withThreads = true, onReady, onError, onActivate, onBuild, onPageMount, onPageUnmount, onPageError, toolStyle: _toolStyle, toolOptions: _toolOptions, signatureLibrary: _signatureLibrary, signatures: signatureStore, search: _search, ...view } = opts;
7049
7457
  /** One library for the viewer's life, so templates and the menu outlive each document. */
7050
7458
  const signatures = createSignatureLibrary(signatureStore ?? createMemorySignatureStore());
7051
7459
  /** Tool options persist for the viewer's life; the tool style is per document. */
@@ -7077,6 +7485,16 @@ function createViewer(el, opts) {
7077
7485
  root.append(viewportRoot);
7078
7486
  el.append(root);
7079
7487
  rootEl.set(scroll);
7488
+ /** One controller for the viewer's life: the host, the engine and the find bar share it. */
7489
+ const search = createSearchController({
7490
+ document: readonly(current),
7491
+ viewport: () => viewport.get()
7492
+ });
7493
+ /** What callers get: the actions, not the bar registration. */
7494
+ const viewerSearch = viewerSearchOf(search);
7495
+ root.addEventListener("keydown", (e) => {
7496
+ handleFindKey(e, search, () => pageSelectionText(root));
7497
+ });
7080
7498
  /**
7081
7499
  * One host for the viewer's life. It reads the history when a document opens, and `current`
7082
7500
  * is set before it notifies, so that is the new document's. With no document open, a
@@ -7092,7 +7510,8 @@ function createViewer(el, opts) {
7092
7510
  },
7093
7511
  root: readonly(rootEl),
7094
7512
  decorate: () => view.decorateAnnotation,
7095
- viewport: () => viewport.get()
7513
+ viewport: () => viewport.get(),
7514
+ search: viewerSearch
7096
7515
  });
7097
7516
  const focusReturn = createFocusReturn({
7098
7517
  root: () => root,
@@ -7144,6 +7563,7 @@ function createViewer(el, opts) {
7144
7563
  const mounted = mountViewer({
7145
7564
  ...view,
7146
7565
  plugins: host,
7566
+ search,
7147
7567
  container: scroll,
7148
7568
  document: doc,
7149
7569
  toolOptions,
@@ -7194,6 +7614,7 @@ function createViewer(el, opts) {
7194
7614
  }
7195
7615
  let loads = 0;
7196
7616
  let destroyed = false;
7617
+ const destroyers = [];
7197
7618
  let stop = () => void 0;
7198
7619
  /** Tears down the open document, if any, and destroys it. */
7199
7620
  function close() {
@@ -7252,18 +7673,26 @@ function createViewer(el, opts) {
7252
7673
  if (id === null || current.get()?.annotations.get(id)) activated.set(id);
7253
7674
  },
7254
7675
  commands: { get: (id) => host.command(id) },
7676
+ search: viewerSearch,
7255
7677
  open,
7256
7678
  destroy() {
7257
7679
  if (destroyed) return;
7258
7680
  destroyed = true;
7259
7681
  loads++;
7260
7682
  close();
7683
+ for (const fn of destroyers) fn();
7684
+ search.destroy();
7261
7685
  host.destroy();
7262
7686
  root.remove();
7263
7687
  }
7264
7688
  };
7265
7689
  toolsByViewer.set(viewer, [...view.tools ?? [], ...host.tools]);
7266
7690
  commandIdsByViewer.set(viewer, () => [...host.commands.keys()]);
7691
+ internals.set(viewer, {
7692
+ search,
7693
+ destroyers,
7694
+ destroyed: () => destroyed
7695
+ });
7267
7696
  return viewer;
7268
7697
  }
7269
7698
  const toolsByViewer = /* @__PURE__ */ new WeakMap();
@@ -7276,11 +7705,29 @@ const commandIdsByViewer = /* @__PURE__ */ new WeakMap();
7276
7705
  function hostCommandIdsOf(viewer) {
7277
7706
  return commandIdsByViewer.get(viewer)?.() ?? [];
7278
7707
  }
7708
+ const internals = /* @__PURE__ */ new WeakMap();
7709
+ /** @internal The viewer's search controller, which the find bar registers with. */
7710
+ function searchControllerOf(viewer) {
7711
+ const found = internals.get(viewer);
7712
+ if (!found) throw new Error("Not a viewer made by createViewer");
7713
+ return found.search;
7714
+ }
7715
+ /**
7716
+ * @internal Runs `fn` when the viewer is destroyed, for what a control set up on it; at once
7717
+ * when it already is.
7718
+ */
7719
+ function onViewerDestroy(viewer, fn) {
7720
+ const found = internals.get(viewer);
7721
+ if (!found) return;
7722
+ if (found.destroyed()) fn();
7723
+ else found.destroyers.push(fn);
7724
+ }
7279
7725
  //#endregion
7280
7726
  //#region src/dom/viewer/controls.ts
7281
7727
  /**
7282
7728
  * Runs `connect` for every value `source` takes while it is set, undoing the previous run's
7283
- * subscriptions first, and disables `control` while it is `null`.
7729
+ * subscriptions first, and disables `control` while it is `null`. Returns the function that
7730
+ * stops following.
7284
7731
  */
7285
7732
  function follow(source, control, connect, reset) {
7286
7733
  let stops = [];
@@ -7296,7 +7743,12 @@ function follow(source, control, connect, reset) {
7296
7743
  else connect(value, watch);
7297
7744
  };
7298
7745
  run(source.get());
7299
- source.subscribe(run);
7746
+ const stopSource = source.subscribe(run);
7747
+ return () => {
7748
+ stopSource();
7749
+ for (const s of stops) s();
7750
+ stops = [];
7751
+ };
7300
7752
  }
7301
7753
  const SVG = "http://www.w3.org/2000/svg";
7302
7754
  const TOOL_ICONS = {
@@ -7331,6 +7783,7 @@ function createIcon(icon) {
7331
7783
  }
7332
7784
  return svg;
7333
7785
  }
7786
+ /** @internal A labelled icon button, as the toolbar controls make them. */
7334
7787
  function button(label, attrs, icon) {
7335
7788
  const b = h("button", {
7336
7789
  type: "button",
@@ -7342,6 +7795,31 @@ function button(label, attrs, icon) {
7342
7795
  return b;
7343
7796
  }
7344
7797
  /**
7798
+ * Opens the viewer's find bar; pressed while it is open. Pressing it again focuses the field.
7799
+ * The bar itself is `createFindBar`.
7800
+ */
7801
+ function createSearchButton(viewer) {
7802
+ const b = button("Find", {
7803
+ class: "ink-search-button",
7804
+ "data-ink-search-button": "",
7805
+ "aria-pressed": "false"
7806
+ }, Search);
7807
+ b.addEventListener("click", () => {
7808
+ viewer.search.open();
7809
+ const field = viewer.element.querySelector("[data-ink-find-bar] input");
7810
+ field?.focus();
7811
+ field?.select();
7812
+ });
7813
+ const show = (s) => {
7814
+ b.dataset.state = s.open ? "on" : "off";
7815
+ b.setAttribute("aria-pressed", String(s.open));
7816
+ };
7817
+ show(viewer.search.state.get());
7818
+ onViewerDestroy(viewer, viewer.search.state.subscribe(show));
7819
+ onViewerDestroy(viewer, follow(viewer.document, b, () => void 0));
7820
+ return b;
7821
+ }
7822
+ /**
7345
7823
  * A toolbar following the WAI-ARIA toolbar pattern: its buttons share one Tab stop, with
7346
7824
  * arrow keys, Home and End to move between them. Fields and selects are Tab stops of their
7347
7825
  * own and keep their keys. Buttons added later join in.
@@ -7453,7 +7931,7 @@ function createZoomIn(viewer) {
7453
7931
  "data-ink-zoom-in": ""
7454
7932
  }, ZoomIn);
7455
7933
  b.addEventListener("click", () => viewer.viewport.get()?.zoomBy(1.25));
7456
- follow(viewer.viewport, b, () => void 0);
7934
+ onViewerDestroy(viewer, follow(viewer.viewport, b, () => void 0));
7457
7935
  return b;
7458
7936
  }
7459
7937
  function createZoomOut(viewer) {
@@ -7462,7 +7940,7 @@ function createZoomOut(viewer) {
7462
7940
  "data-ink-zoom-out": ""
7463
7941
  }, ZoomOut);
7464
7942
  b.addEventListener("click", () => viewer.viewport.get()?.zoomBy(.8));
7465
- follow(viewer.viewport, b, () => void 0);
7943
+ onViewerDestroy(viewer, follow(viewer.viewport, b, () => void 0));
7466
7944
  return b;
7467
7945
  }
7468
7946
  /** Fit width, fit page and the zoom levels; a zoom between levels gets an option of its own. */
@@ -7627,6 +8105,167 @@ function rovingTabindex(bar) {
7627
8105
  };
7628
8106
  }
7629
8107
  //#endregion
8108
+ //#region src/dom/search/find-text.ts
8109
+ /**
8110
+ * The count beside the field: "3 of 17", "3 of 12…" while scanning, "No results".
8111
+ *
8112
+ * @internal Shared with the framework bindings.
8113
+ */
8114
+ function findCountText(s) {
8115
+ if (isBlankQuery(s.query)) return "";
8116
+ if (s.hits.length === 0) return s.scanning ? "" : "No results";
8117
+ return `${s.current + 1} of ${s.hits.length}${s.scanning ? "…" : ""}`;
8118
+ }
8119
+ /**
8120
+ * Returns the function a bar calls with each search state for its live region's text.
8121
+ *
8122
+ * A settled scan reads "Match 3 of 17, page 2" or "No results". While a scan runs it says
8123
+ * nothing until a step moves the current hit, then "Match 3 of 12 so far, page 2", and keeps
8124
+ * that text as more hits arrive, so the region speaks once per step and once on settle rather
8125
+ * than once per hit.
8126
+ *
8127
+ * @internal Shared with the framework bindings; each bar keeps its own.
8128
+ */
8129
+ function findAnnouncer() {
8130
+ let shown;
8131
+ let said = "";
8132
+ return (s) => {
8133
+ const hit = s.hits[s.current];
8134
+ if (!s.open || isBlankQuery(s.query)) {
8135
+ shown = void 0;
8136
+ said = "";
8137
+ } else if (!s.scanning) {
8138
+ shown = hit;
8139
+ said = hit ? `Match ${s.current + 1} of ${s.hits.length}, page ${hit.page + 1}` : "No results";
8140
+ } else {
8141
+ if (!hit) said = "";
8142
+ else if (shown && hit !== shown) said = `Match ${s.current + 1} of ${s.hits.length} so far, page ${hit.page + 1}`;
8143
+ shown = hit;
8144
+ }
8145
+ return said;
8146
+ };
8147
+ }
8148
+ //#endregion
8149
+ //#region src/dom/search/find-bar.ts
8150
+ /**
8151
+ * The find bar for `viewer`: a field, the match count, previous and next, a match-case toggle
8152
+ * and close. Its buttons keep focus while used; Enter in the field steps to the next match,
8153
+ * Shift+Enter to the previous, Escape closes. Closing returns focus to where it was before the
8154
+ * bar opened, else to the document.
8155
+ */
8156
+ function createFindBar(viewer) {
8157
+ const controller = searchControllerOf(viewer);
8158
+ const { state } = controller;
8159
+ const field = h("input", {
8160
+ type: "text",
8161
+ "aria-label": "Find in document",
8162
+ autocomplete: "off",
8163
+ spellcheck: "false"
8164
+ });
8165
+ const count = h("span", {
8166
+ class: "ink-find-count",
8167
+ "data-ink-find-count": ""
8168
+ });
8169
+ const previous = button("Previous match", { "data-ink-find-previous": "" }, ChevronUp);
8170
+ const next = button("Next match", { "data-ink-find-next": "" }, ChevronDown);
8171
+ const matchCase = button("Match case", {
8172
+ "data-ink-find-match-case": "",
8173
+ "aria-pressed": "false"
8174
+ }, CaseSensitive);
8175
+ const close = button("Close find", { "data-ink-find-close": "" }, X);
8176
+ const live = h("div", {
8177
+ class: "ink-visually-hidden",
8178
+ "data-ink-find-status": "",
8179
+ "aria-live": "polite"
8180
+ });
8181
+ const bar = h("div", {
8182
+ class: "ink-find-bar",
8183
+ "data-ink-find-bar": "",
8184
+ role: "search",
8185
+ hidden: ""
8186
+ }, field, count, previous, next, matchCase, close, live);
8187
+ field.addEventListener("input", () => controller.setQuery(field.value));
8188
+ previous.addEventListener("click", () => controller.previous());
8189
+ next.addEventListener("click", () => controller.next());
8190
+ matchCase.addEventListener("click", () => controller.setMatchCase(!state.get().matchCase));
8191
+ close.addEventListener("click", () => controller.close());
8192
+ field.addEventListener("keydown", (e) => {
8193
+ if (e.key !== "Enter" || e.isComposing) return;
8194
+ e.preventDefault();
8195
+ if (e.shiftKey) controller.previous();
8196
+ else controller.next();
8197
+ });
8198
+ bar.addEventListener("keydown", (e) => {
8199
+ if (e.key !== "Escape" || e.defaultPrevented || e.isComposing) return;
8200
+ e.preventDefault();
8201
+ e.stopPropagation();
8202
+ controller.close();
8203
+ });
8204
+ /** Where focus was when the bar opened. */
8205
+ let before = null;
8206
+ let wasOpen = false;
8207
+ const announce = findAnnouncer();
8208
+ let announced = "";
8209
+ function restoreFocus() {
8210
+ const target = before?.isConnected && !bar.contains(before) ? before : viewer.element.querySelector("[data-ink-scroll]");
8211
+ before = null;
8212
+ target?.focus({ preventScroll: true });
8213
+ }
8214
+ function show(s) {
8215
+ if (s.open !== wasOpen) {
8216
+ wasOpen = s.open;
8217
+ if (s.open) {
8218
+ const active = bar.ownerDocument.activeElement;
8219
+ before = active instanceof HTMLElement && active !== bar.ownerDocument.body && !bar.contains(active) ? active : null;
8220
+ bar.hidden = false;
8221
+ field.focus();
8222
+ field.select();
8223
+ } else {
8224
+ const active = bar.ownerDocument.activeElement;
8225
+ const hadFocus = bar.contains(active) || active === bar.ownerDocument.body;
8226
+ bar.hidden = true;
8227
+ if (hadFocus) restoreFocus();
8228
+ else before = null;
8229
+ }
8230
+ }
8231
+ if (field.value !== s.query) {
8232
+ field.value = s.query;
8233
+ if (bar.ownerDocument.activeElement === field) field.select();
8234
+ }
8235
+ count.textContent = findCountText(s);
8236
+ matchCase.setAttribute("aria-pressed", String(s.matchCase));
8237
+ const say = announce(s);
8238
+ if (say !== announced) {
8239
+ announced = say;
8240
+ live.textContent = say;
8241
+ }
8242
+ }
8243
+ let detach = null;
8244
+ const sync = () => {
8245
+ const inside = viewer.element.contains(bar);
8246
+ if (inside && !detach) detach = controller.attachBar();
8247
+ else if (!inside && detach) {
8248
+ detach();
8249
+ detach = null;
8250
+ }
8251
+ };
8252
+ const observer = new MutationObserver(sync);
8253
+ observer.observe(viewer.element, {
8254
+ childList: true,
8255
+ subtree: true
8256
+ });
8257
+ const stopState = state.subscribe(show);
8258
+ show(state.get());
8259
+ sync();
8260
+ onViewerDestroy(viewer, () => {
8261
+ observer.disconnect();
8262
+ stopState();
8263
+ detach?.();
8264
+ detach = null;
8265
+ });
8266
+ return bar;
8267
+ }
8268
+ //#endregion
7630
8269
  //#region src/dom/viewer/menu.ts
7631
8270
  const FOCUSABLE = "select:not(:disabled), input:not(:disabled), button:not(:disabled):not([tabindex=\"-1\"])";
7632
8271
  function createMenu(trigger, opts) {
@@ -9189,6 +9828,6 @@ function createSignatureMenu(viewer, opts = {}) {
9189
9828
  return root;
9190
9829
  }
9191
9830
  //#endregion
9192
- export { ARROWHEAD_CHOICES, ARROWHEAD_ENDING, COLOR_COLUMNS, COLOR_LABELS, DASH_PATTERN, DEFAULT_CORNER_RADIUS, DEFAULT_FREETEXT_WIDTH, DEFAULT_HIGHLIGHT_OPTIONS, DEFAULT_SHAPE_SIZE, FONT_FAMILIES, LINE_WEIGHTS, MARKUP_ICONS, MARKUP_KINDS, MARKUP_LABELS, PALETTE, SHAPE_COLUMNS, SHAPE_ICONS, SHAPE_KINDS, SHAPE_LABELS, SIGNATURE_FONTS, SIGNATURE_IMAGE_MAX_LENGTH, SIGNATURE_IMAGE_MAX_SIDE, SIGNATURE_IMAGE_TYPES, SIGNATURE_LABELS, SIGNATURE_TABS, TOOL_LABELS, ZOOM_LEVELS, annotationElementFor, arrowheadChoice, attachViewportKeys, builtinTools, clampFontSize, colorMenuModel, computeCssLayout, createColorMenu, createCommandButton, createFocusReturn, createHighlightMenu, createIcon, createImageSignature, createLineStyleMenu, createMenu, createNextPage, createOverlay, createPageCount, createPageInput, createPageMount, createPageRenderer, createPageTransform, createPluginHost, createPluginLayers, createPopover, createPrevPage, createRedo, createShapeMenu, createSignatureDialog, createSignatureDraft, createSignatureMenu, createSignaturePad, createSignaturePreview, createTextLayer, createTextStyleMenu, createToolButton, createToolController, createToolOptions, createToolbar, createToolbarSeparator, createTypedSignature, createUndo, createViewer, createViewport, createZoomIn, createZoomOut, createZoomSelect, cssRectsToUserRects, cssVariableDefaults, definePlugin, editFreeText, fitZoomCss, focusInitialMenuItem, fontFamilyValue, freetextTool, getSelectionQuads, handleMenuKeys, handleRadioKeys, handleTabKeys, highlightMenuModel, highlightTool, hitFor, lineStyleMenuModel, menuItems, mostVisiblePageCss, mountViewer, normalizeRotation, noteTool, observeAnnotationElement, placePanel, redactAreaTool, renderAnnotationContent, renderTypedSignature, selectTextTool, selectTool, shapeMenuModel, shapeTool, shouldKeepOpenOnAnnotation, signatureMenuModel, signaturePreview, signatureTool, textStyleMenuModel, threadDeleteLabel, threadPopoverLabel, trackToolStyle, trapTab, unknownCommand, visibleRangeCss, withHostTools };
9831
+ export { ARROWHEAD_CHOICES, ARROWHEAD_ENDING, COLOR_COLUMNS, COLOR_LABELS, DASH_PATTERN, DEFAULT_CORNER_RADIUS, DEFAULT_FREETEXT_WIDTH, DEFAULT_HIGHLIGHT_OPTIONS, DEFAULT_SHAPE_SIZE, FONT_FAMILIES, LINE_WEIGHTS, MARKUP_ICONS, MARKUP_KINDS, MARKUP_LABELS, PALETTE, SHAPE_COLUMNS, SHAPE_ICONS, SHAPE_KINDS, SHAPE_LABELS, SIGNATURE_FONTS, SIGNATURE_IMAGE_MAX_LENGTH, SIGNATURE_IMAGE_MAX_SIDE, SIGNATURE_IMAGE_TYPES, SIGNATURE_LABELS, SIGNATURE_TABS, TOOL_LABELS, ZOOM_LEVELS, annotationElementFor, arrowheadChoice, attachViewportKeys, builtinTools, clampFontSize, colorMenuModel, computeCssLayout, createColorMenu, createCommandButton, createFindBar, createFocusReturn, createHighlightMenu, createIcon, createImageSignature, createLineStyleMenu, createMenu, createNextPage, createOverlay, createPageCount, createPageInput, createPageMount, createPageRenderer, createPageTransform, createPluginHost, createPluginLayers, createPopover, createPrevPage, createRedo, createSearchButton, createSearchController, createShapeMenu, createSignatureDialog, createSignatureDraft, createSignatureMenu, createSignaturePad, createSignaturePreview, createTextLayer, createTextStyleMenu, createToolButton, createToolController, createToolOptions, createToolbar, createToolbarSeparator, createTypedSignature, createUndo, createViewer, createViewport, createZoomIn, createZoomOut, createZoomSelect, cssRectsToUserRects, cssVariableDefaults, definePlugin, editFreeText, findAnnouncer, findCountText, fitZoomCss, focusInitialMenuItem, fontFamilyValue, freetextTool, getSelectionQuads, handleFindKey, handleMenuKeys, handleRadioKeys, handleTabKeys, highlightMenuModel, highlightTool, hitFor, lineStyleMenuModel, menuItems, mostVisiblePageCss, mountViewer, normalizeRotation, noteTool, observeAnnotationElement, pageSelectionText, placePanel, redactAreaTool, renderAnnotationContent, renderTypedSignature, selectTextTool, selectTool, shapeMenuModel, shapeTool, shouldKeepOpenOnAnnotation, signatureMenuModel, signaturePreview, signatureTool, textStyleMenuModel, threadDeleteLabel, threadPopoverLabel, trackToolStyle, trapTab, unknownCommand, viewerSearchOf, visibleRangeCss, withHostTools };
9193
9832
 
9194
9833
  //# sourceMappingURL=dom.mjs.map