@inkbar/core 0.5.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 readonly, 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, 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, _n as isInkError, b as isAbandonedNote, d as recogniseStroke, dn as rectsIntersect, en as writable, f as DEFAULT_CORNER_RADIUS, g as constrain, gn as InkError, h as SHAPE_KINDS, i as createInkTemplate, l as placeSignature, ln as rectFromPoints, m as DEFAULT_SHAPE_SIZE, o as createMemorySignatureStore, p as DEFAULT_SHAPE_OPTIONS, q as translated, r as createImageTemplate, rn as invertMatrix, s as createSignatureLibrary, tn as applyMatrix, tt as isProduction, v as isPointerOnlyKind, w as threadOf, y as shapeInput, z as isBubble, zt as isStandardCallout } from "./signatures-CMapTKrC.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. */
@@ -500,7 +500,7 @@ function createViewport(opts) {
500
500
  });
501
501
  sync();
502
502
  },
503
- scrollToRect(page, rect) {
503
+ scrollToRect(page, rect, o) {
504
504
  if (mode.get() === "single" && currentPage.get() !== page) {
505
505
  currentPage.set(page);
506
506
  relayout();
@@ -508,11 +508,13 @@ function createViewport(opts) {
508
508
  const box = boxFor(page);
509
509
  if (!box) return;
510
510
  const css = transformFor(page).toCssRect(rect);
511
- const o = origin();
512
- const top = o.y + box.top + css.top;
513
- if (top < container.scrollTop || top + css.height > container.scrollTop + container.clientHeight) container.scrollTop = Math.max(0, top - (container.clientHeight - css.height) / 2);
514
- const left = o.x + box.left + css.left;
515
- if (left < container.scrollLeft || left + css.width > container.scrollLeft + container.clientWidth) container.scrollLeft = Math.max(0, left - (container.clientWidth - css.width) / 2);
511
+ const at = origin();
512
+ const to = { behavior: o?.behavior ?? "auto" };
513
+ const top = at.y + box.top + css.top;
514
+ if (top < container.scrollTop || top + css.height > container.scrollTop + container.clientHeight) to.top = Math.max(0, top - (container.clientHeight - css.height) / 2);
515
+ const left = at.x + box.left + css.left;
516
+ if (left < container.scrollLeft || left + css.width > container.scrollLeft + container.clientWidth) to.left = Math.max(0, left - (container.clientWidth - css.width) / 2);
517
+ if (to.top !== void 0 || to.left !== void 0) container.scrollTo(to);
516
518
  sync();
517
519
  },
518
520
  transformFor,
@@ -629,7 +631,7 @@ function watchDevicePixelRatio(view, current, onChange) {
629
631
  }
630
632
  //#endregion
631
633
  //#region src/dom/page-renderer.ts
632
- const isCancellation$1 = (error) => error?.name === "RenderingCancelledException";
634
+ const isCancellation$2 = (error) => error?.name === "RenderingCancelledException";
633
635
  function createPageRenderer(opts) {
634
636
  const { document: doc, page, host } = opts;
635
637
  const view = host.ownerDocument.defaultView;
@@ -715,7 +717,7 @@ function createPageRenderer(opts) {
715
717
  opts.onRendered?.();
716
718
  } catch (error) {
717
719
  if (canvas && canvas !== current) release(canvas);
718
- if (!((destroyed || gen !== generation) && isCancellation$1(error)) && !destroyed) opts.onError?.(error);
720
+ if (!((destroyed || gen !== generation) && isCancellation$2(error)) && !destroyed) opts.onError?.(error);
719
721
  } finally {
720
722
  if (gen === generation) settle();
721
723
  }
@@ -2018,7 +2020,7 @@ function insertText(field, text) {
2018
2020
  }
2019
2021
  //#endregion
2020
2022
  //#region src/pdfjs/render-region.ts
2021
- const isCancellation = (error) => error?.name === "RenderingCancelledException";
2023
+ const isCancellation$1 = (error) => error?.name === "RenderingCancelledException";
2022
2024
  /**
2023
2025
  * Draws `userRect` of `page` (0-based) into `canvas` at `scale` device pixels per user-space
2024
2026
  * unit (times `/UserUnit`, as PDF.js applies it), turned by `rotation`: the page's /Rotate plus
@@ -2061,7 +2063,7 @@ async function renderRegion(doc, page, userRect, scale, canvas, signal, rotation
2061
2063
  try {
2062
2064
  await task.promise;
2063
2065
  } catch (error) {
2064
- if (signal?.aborted && isCancellation(error)) throw signal.reason;
2066
+ if (signal?.aborted && isCancellation$1(error)) throw signal.reason;
2065
2067
  throw error;
2066
2068
  } finally {
2067
2069
  signal?.removeEventListener("abort", cancel);
@@ -2310,7 +2312,7 @@ function ariaLabel(a) {
2310
2312
  return a.author ? `${named} by ${a.author}` : named;
2311
2313
  }
2312
2314
  /** Reading order in user space: top edge first (y grows upwards), then left edge. */
2313
- function readingOrder(a, b) {
2315
+ function readingOrder$1(a, b) {
2314
2316
  return b.rect.y + b.rect.height - (a.rect.y + a.rect.height) || a.rect.x - b.rect.x;
2315
2317
  }
2316
2318
  /** A decoration's data key: lower-case kebab, as written after `data-`. */
@@ -2727,7 +2729,7 @@ function createOverlay(opts) {
2727
2729
  }
2728
2730
  function render() {
2729
2731
  if (destroyed) return;
2730
- const current = withoutReplies(store.list.get()).filter((a) => a.page === page).sort(readingOrder);
2732
+ const current = withoutReplies(store.list.get()).filter((a) => a.page === page).sort(readingOrder$1);
2731
2733
  const ids = new Set(current.map((a) => a.id));
2732
2734
  const active = doc.activeElement;
2733
2735
  let focusId = [...elements].find(([, n]) => n === active)?.[0];
@@ -2818,153 +2820,1387 @@ function createOverlay(opts) {
2818
2820
  };
2819
2821
  }
2820
2822
  //#endregion
2821
- //#region src/dom/page-mount.ts
2822
- /** Canvas, text layer and annotation overlay for one page, stacked in that order. */
2823
- function createPageMount(opts) {
2824
- const { document, page, host, transform } = opts;
2825
- const callbacks = (layer) => ({
2826
- ...opts.onError ? { onError: (error) => opts.onError?.(error, layer) } : {},
2827
- ...opts.onRendered ? { onRendered: () => opts.onRendered?.(layer) } : {}
2828
- });
2829
- const renderer = createPageRenderer({
2830
- document,
2831
- page,
2832
- host,
2833
- transform,
2834
- ...opts.devicePixelRatio === void 0 ? {} : { devicePixelRatio: opts.devicePixelRatio },
2835
- ...callbacks("canvas")
2823
+ //#region src/dom/plugins/api/command.ts
2824
+ const DEFAULT_ERROR_STATUS = "Something went wrong.";
2825
+ /** A failure that is the run being cancelled, not a bug: not logged, no error status. */
2826
+ function isCancellation(error) {
2827
+ if (isInkError(error) && error.code === "DocumentDestroyed") return true;
2828
+ return error instanceof Error && error.name === "AbortError";
2829
+ }
2830
+ function createCommand(ctx, id, label, run, opts = {}) {
2831
+ const status = writable("");
2832
+ let controller = null;
2833
+ ctx.onDocument(() => () => {
2834
+ controller?.abort();
2835
+ status.set("");
2836
2836
  });
2837
- const textLayer = createTextLayer({
2838
- document,
2839
- page,
2840
- host,
2841
- transform,
2842
- ...callbacks("text")
2837
+ return ctx.commands.add({
2838
+ id,
2839
+ label,
2840
+ ...opts.icon && { icon: opts.icon },
2841
+ ...opts.enabled && { enabled: opts.enabled },
2842
+ status: readonly(status),
2843
+ async run() {
2844
+ const doc = ctx.document.get();
2845
+ if (!doc) return;
2846
+ const own = new AbortController();
2847
+ controller = own;
2848
+ status.set("");
2849
+ try {
2850
+ const result = await run({
2851
+ doc,
2852
+ signal: own.signal
2853
+ });
2854
+ if (!own.signal.aborted) status.set(result ?? "");
2855
+ } catch (error) {
2856
+ if (own.signal.aborted || isCancellation(error)) return;
2857
+ console.error(error);
2858
+ status.set(opts.errorStatus ?? DEFAULT_ERROR_STATUS);
2859
+ } finally {
2860
+ if (controller === own) controller = null;
2861
+ }
2862
+ }
2843
2863
  });
2844
- const overlay = createOverlay({
2845
- host,
2846
- page,
2847
- store: opts.store,
2848
- transform,
2849
- document,
2850
- ...opts.devicePixelRatio === void 0 ? {} : { devicePixelRatio: opts.devicePixelRatio },
2851
- ...opts.onActivate ? { onActivate: (id) => opts.onActivate?.(id) } : {},
2852
- ...opts.isCustom ? { isCustom: (a) => opts.isCustom?.(a) === true } : {},
2853
- ...opts.decorate ? { decorate: opts.decorate } : {}
2864
+ }
2865
+ //#endregion
2866
+ //#region src/dom/annotation-element.ts
2867
+ /** The overlay element drawn for annotation `id` under `root`, if it is in the DOM. */
2868
+ function annotationElementFor(root, id) {
2869
+ return root.querySelector(`[data-annotation="${CSS.escape(id)}"]`);
2870
+ }
2871
+ /**
2872
+ * Resolves the overlay element for `id` and keeps resolving while `root`'s subtree changes:
2873
+ * the overlay creates and removes these elements outside any framework (a page mounts as it
2874
+ * scrolls into view), so a popover anchored to one has to follow. Calls `onChange` once now
2875
+ * and again whenever the answer changes. The returned function stops observing.
2876
+ */
2877
+ function observeAnnotationElement(root, id, onChange) {
2878
+ let last;
2879
+ const resolve = () => {
2880
+ const found = annotationElementFor(root, id);
2881
+ if (found === last) return;
2882
+ last = found;
2883
+ onChange(found);
2884
+ };
2885
+ resolve();
2886
+ const observer = new MutationObserver(resolve);
2887
+ observer.observe(root, {
2888
+ childList: true,
2889
+ subtree: true
2854
2890
  });
2891
+ return () => observer.disconnect();
2892
+ }
2893
+ //#endregion
2894
+ //#region src/dom/popover-focus.ts
2895
+ /**
2896
+ * A press that will activate an annotation is not a dismissal: the popover follows the
2897
+ * activation. Mirrors the overlay's rule: a press on the annotation element or on its drawn
2898
+ * shape activates; a press on a custom element's own content belongs to that content.
2899
+ */
2900
+ function shouldKeepOpenOnAnnotation(target) {
2901
+ if (!(target instanceof Element)) return false;
2902
+ const annotation = target.closest("[data-annotation]");
2903
+ if (!annotation) return false;
2904
+ return target === annotation || !annotation.hasAttribute("data-custom");
2905
+ }
2906
+ function createFocusReturn(opts) {
2907
+ let fallbacks = [];
2908
+ /** Set by `beforeRemove`: the close comes from removing the anchor's annotation. */
2909
+ let removing = false;
2855
2910
  return {
2856
- renderer,
2857
- textLayer,
2858
- overlay,
2859
- update(next) {
2860
- renderer.update(next);
2861
- textLayer.update(next);
2862
- overlay.update(next);
2911
+ beforeRemove(anchor, prefer) {
2912
+ fallbacks = [];
2913
+ removing = true;
2914
+ const siblings = anchor?.parentElement ? [...anchor.parentElement.children].filter((el) => el instanceof HTMLElement && el.hasAttribute("data-annotation")) : [];
2915
+ const i = anchor ? siblings.indexOf(anchor) : -1;
2916
+ if (i >= 0) {
2917
+ const after = siblings.slice(i + 1);
2918
+ const preferred = prefer ? after.filter((el) => el.matches(prefer)) : [];
2919
+ fallbacks.push(...preferred, ...after, ...siblings.slice(0, i).reverse());
2920
+ }
2863
2921
  },
2864
- destroy() {
2865
- overlay.destroy();
2866
- textLayer.destroy();
2867
- renderer.destroy();
2922
+ onCloseAutoFocus(event, anchor) {
2923
+ event.preventDefault();
2924
+ const plan = fallbacks;
2925
+ const removed = removing;
2926
+ fallbacks = [];
2927
+ removing = false;
2928
+ const root = opts.root();
2929
+ if (!root) return;
2930
+ const activated = opts.activated();
2931
+ if (activated !== null && annotationElementFor(root, activated)) return;
2932
+ const active = root.ownerDocument.activeElement;
2933
+ if (active && active !== root && root.contains(active)) return;
2934
+ const scroll = root.querySelector("[data-ink-scroll]");
2935
+ (removed ? [...plan, scroll] : [
2936
+ anchor,
2937
+ ...plan,
2938
+ scroll
2939
+ ]).find((el) => el?.isConnected)?.focus({ preventScroll: true });
2868
2940
  }
2869
2941
  };
2870
2942
  }
2871
2943
  //#endregion
2872
- //#region src/dom/tools/options.ts
2873
- function createToolOptions(tools = []) {
2874
- const byId = /* @__PURE__ */ new Map();
2875
- const views = /* @__PURE__ */ new Map();
2876
- const entry = (id) => {
2877
- const w = byId.get(id);
2878
- if (!w) throw new RangeError(`Unknown tool ${id}`);
2879
- return w;
2880
- };
2881
- function register(list) {
2882
- for (const t of list) {
2883
- if (byId.has(t.id)) continue;
2884
- const w = writable({ ...t.defaultOptions ?? {} });
2885
- byId.set(t.id, w);
2886
- views.set(t.id, readonly(w));
2887
- }
2888
- }
2889
- register(tools);
2890
- return {
2891
- readable(id) {
2892
- entry(id);
2893
- return views.get(id);
2894
- },
2895
- get: (id) => entry(id).get(),
2896
- merge(id, patch) {
2897
- const w = entry(id);
2898
- w.set({
2899
- ...w.get(),
2900
- ...patch
2901
- });
2902
- },
2903
- register
2904
- };
2944
+ //#region src/dom/viewer/dom.ts
2945
+ /** `document.createElement` with attributes and children. */
2946
+ function h(tag, attrs = {}, ...children) {
2947
+ const el = document.createElement(tag);
2948
+ for (const [name, value] of Object.entries(attrs)) el.setAttribute(name, value);
2949
+ el.append(...children);
2950
+ return el;
2951
+ }
2952
+ function button$1(label, onClick) {
2953
+ const b = h("button", { type: "button" }, label);
2954
+ b.addEventListener("click", onClick);
2955
+ return b;
2905
2956
  }
2906
2957
  //#endregion
2907
- //#region src/dom/tools/selection-edit.ts
2908
- const CORNERS = [
2909
- "nw",
2910
- "ne",
2911
- "sw",
2912
- "se"
2913
- ];
2914
- const ENDS = ["start", "end"];
2915
- const CALLOUT = "callout";
2916
- /** Pointer travel, CSS px, before a press on an annotation becomes a move. */
2917
- const DRAG_THRESHOLD = 3;
2918
- function createSelectionEdit(ctx, opts = {}) {
2919
- let mode = { kind: "idle" };
2920
- const batch = (fn) => ctx.history ? ctx.history.batch(fn) : fn();
2921
- const reset = () => {
2922
- if (mode.kind === "marquee") mode.el.remove();
2923
- if (mode.kind === "move") {
2924
- for (const el of mode.els) el.style.transform = "";
2925
- ctx.root.removeAttribute("data-ink-dragging");
2926
- }
2927
- if (mode.kind === "resize" && mode.el) {
2928
- mode.el.style.transform = "";
2929
- mode.el.style.transformOrigin = "";
2930
- mode.el.style.visibility = "";
2931
- }
2932
- if (mode.kind === "resize") mode.preview?.remove();
2933
- if (mode.kind === "endpoint") {
2934
- mode.preview.remove();
2935
- if (mode.el) mode.el.style.visibility = "";
2936
- }
2937
- mode = { kind: "idle" };
2938
- };
2939
- const elementIn = (root, id) => root.querySelector(`[data-annotation="${CSS.escape(id)}"]`) ?? void 0;
2940
- const previewFor = (host, a) => {
2941
- const preview = host.ownerDocument.createElement("div");
2942
- preview.className = "ink-annotation ink-edit-preview";
2943
- preview.dataset.inkPreview = "";
2944
- preview.dataset.type = a.type;
2945
- preview.style.cssText = "position:absolute;pointer-events:none;";
2946
- return preview;
2947
- };
2948
- /** Shows `draft` in `preview` beside `el`, which is hidden meanwhile. */
2949
- const showPreview = (preview, el, draft, start) => {
2950
- drawPreview(preview, draft, start);
2951
- if (el) el.style.visibility = "hidden";
2952
- if (!preview.isConnected) (el?.parentElement ?? start.host).append(preview);
2958
+ //#region src/dom/viewer/popover.ts
2959
+ /** Space between the anchor and the panel, and between the panel and the window edge. */
2960
+ const GAP = 6;
2961
+ const MARGIN = 8;
2962
+ const TABBABLE$1 = "button, input, select, textarea, a[href], [tabindex]";
2963
+ /**
2964
+ * Positions `panel` (fixed) below the `anchor` rectangle, or above it when only that fits,
2965
+ * kept inside the window. For panels anchored to something other than an annotation.
2966
+ */
2967
+ function placePanel(panel, anchor) {
2968
+ const { innerWidth: vw, innerHeight: vh } = window;
2969
+ panel.style.position = "fixed";
2970
+ panel.style.left = "0px";
2971
+ panel.style.top = "0px";
2972
+ const w = panel.offsetWidth;
2973
+ const ht = panel.offsetHeight;
2974
+ let top = anchor.bottom + GAP;
2975
+ if (top + ht > vh - MARGIN && anchor.top - GAP - ht >= MARGIN) top = anchor.top - GAP - ht;
2976
+ top = Math.max(MARGIN, Math.min(top, vh - ht - MARGIN));
2977
+ const left = Math.max(MARGIN, Math.min(anchor.left, vw - w - MARGIN));
2978
+ panel.style.left = `${left}px`;
2979
+ panel.style.top = `${top}px`;
2980
+ }
2981
+ /**
2982
+ * Calls `onChange` with `el` while it is under `root` and null while it is not: once now, then
2983
+ * whenever that changes. Returns a function that stops observing.
2984
+ */
2985
+ function observeElement(root, el, onChange) {
2986
+ let last;
2987
+ const resolve = () => {
2988
+ const found = root.contains(el) ? el : null;
2989
+ if (found === last) return;
2990
+ last = found;
2991
+ onChange(found);
2953
2992
  };
2954
- function startHandle(e, hit, a, handle) {
2955
- const el = elementIn(hit.host, a.id);
2956
- if (CORNERS.includes(handle) && hasCornerHandles(a)) {
2957
- const text = a.type === "freetext";
2958
- mode = {
2959
- kind: "resize",
2960
- id: a.id,
2961
- start: hit,
2962
- corner: handle,
2963
- box: text ? hit.transform.toCssRect(freeTextBox(a)) : handleBox(a, hit.transform),
2964
- el,
2965
- locked: keepsAspect(a),
2966
- preview: text && (a.fringe !== void 0 || hasCallout(a)) ? previewFor(hit.host, a) : void 0
2967
- };
2993
+ resolve();
2994
+ const observer = new MutationObserver(resolve);
2995
+ observer.observe(root, {
2996
+ childList: true,
2997
+ subtree: true
2998
+ });
2999
+ return () => observer.disconnect();
3000
+ }
3001
+ /**
3002
+ * Creates a non-modal `role="dialog"` panel on `<body>`, marked `data-ink-popover` so the theme
3003
+ * gives it its tokens, that an app anchors to an annotation with `show(id)` (for instance on
3004
+ * `viewer.activated`), or to any element in the viewer with `showAt(el)`. The panel follows the annotation's element as pages mount and unmount:
3005
+ * it hides while the element is gone and comes back with it. Tab loops inside the panel, and
3006
+ * Escape or a press outside calls `onDismiss`. Call `destroy` when done.
3007
+ */
3008
+ function createPopover(opts) {
3009
+ const { root, focusReturn } = opts;
3010
+ const panel = h("div", {
3011
+ ...opts.attrs,
3012
+ role: "dialog",
3013
+ "data-ink-popover": "",
3014
+ hidden: ""
3015
+ });
3016
+ document.body.append(panel);
3017
+ /** What the panel is anchored to: an annotation id, or an element from `showAt`. */
3018
+ let anchored = null;
3019
+ let anchor = null;
3020
+ let stopObserving = () => void 0;
3021
+ /** The element that had focus in the panel when the anchor went away. */
3022
+ let stranded = null;
3023
+ const place = () => {
3024
+ if (!anchor || panel.hidden) return;
3025
+ placePanel(panel, anchor.getBoundingClientRect());
3026
+ };
3027
+ const resizes = new ResizeObserver(place);
3028
+ resizes.observe(panel);
3029
+ const onAnchor = (el, closing = false) => {
3030
+ if (anchor) resizes.unobserve(anchor);
3031
+ anchor = el;
3032
+ if (!el) {
3033
+ const active = document.activeElement;
3034
+ if (!closing && !panel.hidden && active instanceof HTMLElement && panel.contains(active)) {
3035
+ const scroll = root.querySelector("[data-ink-scroll]");
3036
+ if (scroll) {
3037
+ stranded = active;
3038
+ scroll.focus({ preventScroll: true });
3039
+ }
3040
+ }
3041
+ panel.hidden = true;
3042
+ return;
3043
+ }
3044
+ resizes.observe(el);
3045
+ const wasHidden = panel.hidden;
3046
+ panel.hidden = false;
3047
+ place();
3048
+ if (wasHidden && stranded) {
3049
+ const back = stranded;
3050
+ stranded = null;
3051
+ const scroll = root.querySelector("[data-ink-scroll]");
3052
+ if (panel.contains(back) && document.activeElement === scroll) back.focus({ preventScroll: true });
3053
+ }
3054
+ };
3055
+ const onKey = (e) => {
3056
+ if (e.key !== "Escape" || panel.hidden || e.defaultPrevented) return;
3057
+ const target = e.target;
3058
+ if (!(target instanceof Node) || !(panel.contains(target) || root.contains(target))) return;
3059
+ e.preventDefault();
3060
+ opts.onDismiss();
3061
+ };
3062
+ const onPress = (e) => {
3063
+ if (panel.hidden || panel.contains(e.target)) return;
3064
+ if (shouldKeepOpenOnAnnotation(e.target)) return;
3065
+ opts.onDismiss();
3066
+ };
3067
+ const onTab = (e) => {
3068
+ if (e.key !== "Tab" || e.altKey || e.ctrlKey || e.metaKey) return;
3069
+ const tabbable = [...panel.querySelectorAll(TABBABLE$1)].filter((el) => !el.disabled && el.tabIndex >= 0);
3070
+ const first = tabbable[0];
3071
+ const last = tabbable.at(-1);
3072
+ if (!first || !last) return;
3073
+ const active = document.activeElement;
3074
+ if (e.shiftKey ? active !== first : active !== last) return;
3075
+ e.preventDefault();
3076
+ (e.shiftKey ? last : first).focus();
3077
+ };
3078
+ panel.addEventListener("keydown", onTab);
3079
+ document.addEventListener("keydown", onKey);
3080
+ document.addEventListener("pointerdown", onPress, true);
3081
+ document.addEventListener("scroll", place, true);
3082
+ window.addEventListener("resize", place);
3083
+ return {
3084
+ panel,
3085
+ get anchor() {
3086
+ return anchor;
3087
+ },
3088
+ show(id) {
3089
+ if (id === anchored) return;
3090
+ stopObserving();
3091
+ anchored = id;
3092
+ stopObserving = observeAnnotationElement(root, id, onAnchor);
3093
+ },
3094
+ showAt(el) {
3095
+ if (el === anchored) return;
3096
+ stopObserving();
3097
+ anchored = el;
3098
+ stopObserving = observeElement(root, el, onAnchor);
3099
+ },
3100
+ hide() {
3101
+ if (anchored === null) return;
3102
+ stopObserving();
3103
+ stopObserving = () => void 0;
3104
+ const element = typeof anchored === "string" ? null : anchored;
3105
+ anchored = null;
3106
+ const back = anchor;
3107
+ stranded = null;
3108
+ onAnchor(null, true);
3109
+ queueMicrotask(() => {
3110
+ if (anchored !== null || !panel.isConnected) return;
3111
+ if (element?.isConnected) {
3112
+ const active = document.activeElement;
3113
+ if (!(active && active !== root && root.contains(active))) element.focus({ preventScroll: true });
3114
+ return;
3115
+ }
3116
+ focusReturn.onCloseAutoFocus(new Event("closeautofocus", { cancelable: true }), back);
3117
+ });
3118
+ },
3119
+ destroy() {
3120
+ stopObserving();
3121
+ resizes.disconnect();
3122
+ document.removeEventListener("keydown", onKey);
3123
+ document.removeEventListener("pointerdown", onPress, true);
3124
+ panel.removeEventListener("keydown", onTab);
3125
+ document.removeEventListener("scroll", place, true);
3126
+ window.removeEventListener("resize", place);
3127
+ panel.remove();
3128
+ }
3129
+ };
3130
+ }
3131
+ //#endregion
3132
+ //#region src/dom/plugins/api/card.ts
3133
+ function el(tag, className, ...children) {
3134
+ const e = document.createElement(tag);
3135
+ e.className = className;
3136
+ e.append(...children);
3137
+ return e;
3138
+ }
3139
+ function createCard(ctx, records, opts) {
3140
+ /** Made on first open, in the viewer root the mark is in. */
3141
+ let popover = null;
3142
+ let root = null;
3143
+ /** The record on show. */
3144
+ let shown = null;
3145
+ /** Set while an action runs: the action decides what happens after it. */
3146
+ let acting = false;
3147
+ /** The mark whose card is open: its `aria-expanded` is true. */
3148
+ let expanded = null;
3149
+ const expand = (mark) => {
3150
+ expanded?.setAttribute("aria-expanded", "false");
3151
+ expanded = mark;
3152
+ mark?.setAttribute("aria-expanded", "true");
3153
+ };
3154
+ /** The shown record as it stands now, while it still has a mark. */
3155
+ const current = () => {
3156
+ const record = shown === null ? void 0 : records.find(shown);
3157
+ return record && opts.marked(record) ? record : void 0;
3158
+ };
3159
+ const close = () => {
3160
+ shown = null;
3161
+ expand(null);
3162
+ popover?.hide();
3163
+ };
3164
+ /** Runs `fn` as one undo step, then re-renders, or closes and moves focus on. */
3165
+ const act = (fn) => {
3166
+ if (!popover || !root || shown === null) return;
3167
+ const marks = opts.marks(root);
3168
+ const i = popover.anchor ? marks.indexOf(popover.anchor) : -1;
3169
+ const after = i < 0 ? [] : [...marks.slice(i + 1), ...marks.slice(0, i).reverse()];
3170
+ acting = true;
3171
+ try {
3172
+ ctx.history.batch(fn);
3173
+ } finally {
3174
+ acting = false;
3175
+ }
3176
+ const record = current();
3177
+ if (record) return render(record);
3178
+ after.find((m) => m.isConnected)?.focus({ preventScroll: true });
3179
+ close();
3180
+ };
3181
+ /** Fills the panel from `record`, keeping focus on the same action. */
3182
+ const render = (record) => {
3183
+ const spec = opts.spec(record);
3184
+ const panel = popover.panel;
3185
+ const buttons = () => [...panel.querySelectorAll(".ink-card-actions > button")];
3186
+ const focused = buttons().indexOf(document.activeElement);
3187
+ panel.className = spec.className ? `ink-card ${spec.className}` : "ink-card";
3188
+ panel.setAttribute("aria-label", spec.label ?? spec.title);
3189
+ const parts = [el("p", "ink-card-title", spec.title)];
3190
+ if (spec.meta !== void 0) {
3191
+ const meta = el("p", "ink-card-meta", spec.meta);
3192
+ if (spec.swatch !== void 0) {
3193
+ const swatch = el("span", "ink-card-swatch");
3194
+ swatch.style.background = spec.swatch;
3195
+ meta.prepend(swatch);
3196
+ }
3197
+ parts.push(meta);
3198
+ }
3199
+ if (spec.body !== void 0) parts.push(el("p", "ink-card-body", spec.body));
3200
+ const actions = el("div", "ink-card-actions");
3201
+ for (const [label, fn] of Object.entries(spec.actions)) {
3202
+ const button = el("button", "", label);
3203
+ button.type = "button";
3204
+ button.addEventListener("click", () => act(fn));
3205
+ actions.append(button);
3206
+ }
3207
+ parts.push(actions);
3208
+ panel.replaceChildren(...parts);
3209
+ if (focused >= 0) {
3210
+ const now = buttons();
3211
+ (now[focused] ?? now[0])?.focus({ preventScroll: true });
3212
+ }
3213
+ };
3214
+ ctx.onDocument(() => {
3215
+ const stop = records.list.subscribe(() => {
3216
+ if (acting || shown === null) return;
3217
+ const record = current();
3218
+ if (record) render(record);
3219
+ else close();
3220
+ });
3221
+ return () => {
3222
+ stop();
3223
+ shown = null;
3224
+ expand(null);
3225
+ popover?.destroy();
3226
+ popover = null;
3227
+ root = null;
3228
+ };
3229
+ });
3230
+ return {
3231
+ open(record, mark) {
3232
+ const viewer = mark.closest("[data-ink-root]");
3233
+ if (!viewer) return;
3234
+ if (viewer !== root) {
3235
+ popover?.destroy();
3236
+ root = viewer;
3237
+ popover = createPopover({
3238
+ root: viewer,
3239
+ attrs: { class: "ink-card" },
3240
+ focusReturn: createFocusReturn({
3241
+ root: () => viewer,
3242
+ activated: () => null
3243
+ }),
3244
+ onDismiss: close
3245
+ });
3246
+ }
3247
+ shown = record.id;
3248
+ render(record);
3249
+ popover.showAt(mark);
3250
+ expand(mark);
3251
+ popover.panel.querySelector(".ink-card-actions > button")?.focus();
3252
+ },
3253
+ expanded: (mark) => mark === expanded
3254
+ };
3255
+ }
3256
+ //#endregion
3257
+ //#region src/dom/plugins/api/marks.ts
3258
+ /** Events stopped on a mark, so the viewer's root never sees a press on it. */
3259
+ const STOPPED = [
3260
+ "pointerdown",
3261
+ "pointerup",
3262
+ "mousedown",
3263
+ "mouseup",
3264
+ "click"
3265
+ ];
3266
+ /** Attributes the plugin's `attrs` cannot set: core sets them, or they have options of their own. */
3267
+ const RESERVED = /* @__PURE__ */ new Set([
3268
+ "style",
3269
+ "class",
3270
+ "type"
3271
+ ]);
3272
+ function createMarks(ctx, records, opts) {
3273
+ const applied = /* @__PURE__ */ new WeakMap();
3274
+ const marked = (record) => opts.where?.(record) ?? true;
3275
+ const spec = opts.card?.bind(opts);
3276
+ const card = spec && createCard(ctx, records, {
3277
+ spec,
3278
+ marked,
3279
+ marks: (root) => [...root.querySelectorAll(`.ink-plugin-layer[data-plugin="${ctx.id}"] > button`)].filter((mark) => applied.has(mark))
3280
+ });
3281
+ /** What activating a mark does. */
3282
+ const activate = (record, mark) => {
3283
+ card?.open(record, mark);
3284
+ opts.onActivate?.(record, mark);
3285
+ };
3286
+ const create = (doc) => {
3287
+ const mark = doc.createElement("button");
3288
+ if (opts.className) mark.className = opts.className;
3289
+ for (const type of STOPPED) mark.addEventListener(type, (e) => e.stopPropagation());
3290
+ mark.addEventListener("click", () => {
3291
+ const state = applied.get(mark);
3292
+ if (state) activate(state.record, mark);
3293
+ });
3294
+ return mark;
3295
+ };
3296
+ /** Brings `mark` up to date with `record`, placed through `transform`. */
3297
+ const apply = (mark, record, transform) => {
3298
+ const prev = applied.get(mark);
3299
+ const style = opts.style?.(record) ?? {};
3300
+ const styleNames = Object.keys(style).map(toCssName);
3301
+ const attrs = Object.fromEntries(Object.entries(opts.attrs?.(record) ?? {}).filter(([name]) => !RESERVED.has(name.toLowerCase())));
3302
+ for (const name of prev?.style ?? []) if (!styleNames.includes(name)) mark.style.removeProperty(name);
3303
+ for (const key of prev?.attrs ?? []) if (!(key in attrs)) mark.removeAttribute(key);
3304
+ for (const [key, value] of Object.entries(style)) mark.style.setProperty(toCssName(key), value);
3305
+ for (const [name, value] of Object.entries(attrs)) mark.setAttribute(name, value);
3306
+ mark.type = "button";
3307
+ mark.style.position = "absolute";
3308
+ mark.dataset.record = record.id;
3309
+ mark.dataset.plugin = ctx.id;
3310
+ mark.setAttribute("aria-label", opts.label(record));
3311
+ if (card) {
3312
+ mark.setAttribute("aria-haspopup", "dialog");
3313
+ mark.setAttribute("aria-expanded", String(card.expanded(mark)));
3314
+ }
3315
+ const r = transform.toCssRect(opts.rect(record));
3316
+ mark.style.left = `${r.left}px`;
3317
+ mark.style.top = `${r.top}px`;
3318
+ mark.style.width = `${r.width}px`;
3319
+ mark.style.height = `${r.height}px`;
3320
+ applied.set(mark, {
3321
+ record,
3322
+ style: styleNames,
3323
+ attrs: Object.keys(attrs)
3324
+ });
3325
+ };
3326
+ /**
3327
+ * Makes `el`'s marks match the page's records, in list order: marks that stay are moved,
3328
+ * new ones inserted, the rest removed.
3329
+ */
3330
+ const draw = (el, page, transform) => {
3331
+ const list = records.list.get().filter((r) => r.page === page && marked(r));
3332
+ const ids = new Set(list.map((r) => r.id));
3333
+ const kept = /* @__PURE__ */ new Map();
3334
+ for (const mark of [...el.children]) {
3335
+ const id = mark.dataset.record;
3336
+ if (id !== void 0 && ids.has(id) && !kept.has(id)) kept.set(id, mark);
3337
+ else mark.remove();
3338
+ }
3339
+ let next = el.firstElementChild;
3340
+ for (const record of list) {
3341
+ const mark = kept.get(record.id) ?? create(el.ownerDocument);
3342
+ apply(mark, record, transform);
3343
+ if (mark === next) next = mark.nextElementSibling;
3344
+ else el.insertBefore(mark, next);
3345
+ }
3346
+ };
3347
+ let scrolling = false;
3348
+ /** Ids that started to get a mark in the change under way, read once it settles. */
3349
+ const fresh = /* @__PURE__ */ new Set();
3350
+ /** Scrolls to the first fresh record still marked, so a batch of adds scrolls once. */
3351
+ const reveal = () => {
3352
+ const [first] = [...fresh].map((id) => records.find(id)).filter((r) => r !== void 0 && marked(r)).sort(readingOrder((r) => opts.rect(r)));
3353
+ fresh.clear();
3354
+ if (first) ctx.scrollTo(first.page, opts.rect(first), { behavior: reducedMotion() ? "instant" : "smooth" });
3355
+ };
3356
+ /** Notes a record that starts to get a mark; replaces (undo, redo, reset) never count. */
3357
+ const watch = (change) => {
3358
+ const record = change.kind === "add" ? change.record : change.kind === "update" && !marked(change.before) ? change.after : void 0;
3359
+ if (!record || !marked(record)) return;
3360
+ if (fresh.size === 0) queueMicrotask(reveal);
3361
+ fresh.add(record.id);
3362
+ };
3363
+ const layer = ctx.layer({ render: draw });
3364
+ ctx.onDocument(() => {
3365
+ const stopDraw = records.list.subscribe(() => layer.invalidate());
3366
+ const stopWatch = records.store()?.on("change", (change) => {
3367
+ if (scrolling) watch(change);
3368
+ });
3369
+ return () => {
3370
+ stopDraw();
3371
+ stopWatch?.();
3372
+ fresh.clear();
3373
+ };
3374
+ });
3375
+ const handle = {
3376
+ refresh: () => layer.invalidate(),
3377
+ scrollToFirst() {
3378
+ scrolling = true;
3379
+ return handle;
3380
+ }
3381
+ };
3382
+ return handle;
3383
+ }
3384
+ /** Page first, then top to bottom and left to right, by each record's rect in user space. */
3385
+ function readingOrder(rect) {
3386
+ return (a, b) => {
3387
+ const ra = rect(a);
3388
+ const rb = rect(b);
3389
+ return a.page - b.page || rb.y + rb.height - (ra.y + ra.height) || ra.x - rb.x;
3390
+ };
3391
+ }
3392
+ function reducedMotion() {
3393
+ return globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
3394
+ }
3395
+ /** `borderColor` → `border-color`, as `setProperty` takes it; `--custom` names stay as given. */
3396
+ function toCssName(key) {
3397
+ return key.startsWith("--") ? key : key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
3398
+ }
3399
+ //#endregion
3400
+ //#region src/dom/plugins/api/links.ts
3401
+ function isObject(value) {
3402
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3403
+ }
3404
+ /** Links `type` annotations to records; registers one `onAnnotationEdit` listener. */
3405
+ function createLink(deps, type, spec) {
3406
+ const linkOf = (a) => {
3407
+ if (a.type !== type) return void 0;
3408
+ const own = a.custom?.[deps.plugin];
3409
+ const id = isObject(own) ? own[deps.name] : void 0;
3410
+ return typeof id === "string" ? id : void 0;
3411
+ };
3412
+ deps.onAnnotationEdit((edit) => {
3413
+ if (edit.kind === "add") return;
3414
+ const annotation = edit.kind === "remove" ? edit.annotation : edit.after;
3415
+ const id = linkOf(annotation);
3416
+ const record = id === void 0 ? void 0 : deps.find(id);
3417
+ if (!record) return;
3418
+ if (edit.kind === "remove") spec.onRemoved?.(record, annotation);
3419
+ else spec.onChanged?.(record, annotation);
3420
+ });
3421
+ return {
3422
+ add(record) {
3423
+ const doc = deps.document();
3424
+ if (!doc) return void 0;
3425
+ const { color, custom, ...fields } = spec.from(record);
3426
+ const own = custom?.[deps.plugin];
3427
+ return doc.annotations.add({
3428
+ ...fields,
3429
+ type,
3430
+ color: color ?? doc.annotations.defaults.color(type),
3431
+ custom: {
3432
+ ...custom,
3433
+ [deps.plugin]: {
3434
+ ...isObject(own) ? own : {},
3435
+ [deps.name]: record.id
3436
+ }
3437
+ }
3438
+ });
3439
+ },
3440
+ of(record) {
3441
+ const doc = deps.document();
3442
+ return doc ? doc.annotations.list.get().filter((a) => linkOf(a) === record.id) : [];
3443
+ }
3444
+ };
3445
+ }
3446
+ //#endregion
3447
+ //#region src/dom/plugins/api/records.ts
3448
+ const EMPTY$1 = Object.freeze([]);
3449
+ /** The list of whichever store `source` holds, `[]` for none; emits on swaps and changes. */
3450
+ function listOf(source) {
3451
+ const read = (store) => store?.list.get() ?? EMPTY$1;
3452
+ return {
3453
+ get: () => read(source.get()),
3454
+ subscribe(fn) {
3455
+ let last = read(source.get());
3456
+ let stopList;
3457
+ const emit = (next) => {
3458
+ if (Object.is(next, last) || next.length === 0 && last.length === 0) return;
3459
+ last = next;
3460
+ fn(next);
3461
+ };
3462
+ const follow = (store) => {
3463
+ stopList?.();
3464
+ stopList = store?.list.subscribe(emit);
3465
+ emit(read(store));
3466
+ };
3467
+ follow(source.get());
3468
+ const stopSource = source.subscribe(follow);
3469
+ return () => {
3470
+ stopSource();
3471
+ stopList?.();
3472
+ };
3473
+ }
3474
+ };
3475
+ }
3476
+ function matches(record, match) {
3477
+ return Object.entries(match).every(([k, v]) => record[k] === v);
3478
+ }
3479
+ /**
3480
+ * Builds a handle over `source`, a plugin store from `ctx.store`. Every call reads the store
3481
+ * `source` holds then.
3482
+ */
3483
+ function createRecords(deps, source, base = source) {
3484
+ const current = () => source.get();
3485
+ const all = () => current()?.list.get() ?? EMPTY$1;
3486
+ return {
3487
+ list: listOf(source),
3488
+ all,
3489
+ find: (id) => current()?.get(id),
3490
+ has: (id) => current()?.get(id) !== void 0,
3491
+ where: (match) => all().filter(typeof match === "function" ? match : (r) => matches(r, match)),
3492
+ add: (record) => current()?.add(record),
3493
+ addMissing(records) {
3494
+ const store = current();
3495
+ if (!store) return [];
3496
+ const added = [];
3497
+ deps.batch(() => {
3498
+ for (const record of records) if (!store.get(record.id)) added.push(store.add(record));
3499
+ });
3500
+ return added;
3501
+ },
3502
+ update: (id, patch) => current()?.update(id, patch),
3503
+ remove: (id) => current()?.remove(id),
3504
+ in(doc) {
3505
+ const captured = deps.storesDocument() === doc ? base.get() : null;
3506
+ return createRecords(deps, derived(base, (store) => store && store === captured ? store : null), base);
3507
+ },
3508
+ store: current,
3509
+ annotates: (type, spec) => createLink({
3510
+ plugin: deps.plugin,
3511
+ name: deps.name,
3512
+ onAnnotationEdit: (fn) => deps.onAnnotationEdit(fn),
3513
+ document: () => current() ? deps.storesDocument() : null,
3514
+ find: (id) => current()?.get(id)
3515
+ }, type, spec)
3516
+ };
3517
+ }
3518
+ //#endregion
3519
+ //#region src/dom/plugins/api/api.ts
3520
+ function createPluginApi(ctx, options) {
3521
+ let storesDocument = null;
3522
+ ctx.onDocument((doc) => {
3523
+ storesDocument = doc;
3524
+ return () => {
3525
+ storesDocument = null;
3526
+ };
3527
+ });
3528
+ const batch = (fn) => ctx.history.batch(fn);
3529
+ return {
3530
+ ...ctx,
3531
+ records(name, opts) {
3532
+ const source = ctx.store(name, opts ?? options.records?.[name]);
3533
+ return createRecords({
3534
+ batch,
3535
+ storesDocument: () => storesDocument,
3536
+ plugin: ctx.id,
3537
+ name,
3538
+ onAnnotationEdit: (fn) => ctx.onAnnotationEdit(fn)
3539
+ }, source);
3540
+ },
3541
+ batch,
3542
+ command: (id, label, run, opts) => createCommand(ctx, id, label, run, opts),
3543
+ marks: (records, opts) => createMarks(ctx, records, opts)
3544
+ };
3545
+ }
3546
+ //#endregion
3547
+ //#region src/dom/plugins/define.ts
3548
+ const PLUGIN_ID = /^[a-z][a-z0-9-]*$/;
3549
+ function checkId(id) {
3550
+ if (!PLUGIN_ID.test(id)) throw new InkError("InvalidPluginId", `Plugin id ${JSON.stringify(id)} must be lower-case kebab`, { id });
3551
+ }
3552
+ function definePlugin(plugin, setup) {
3553
+ if (typeof plugin !== "string") {
3554
+ checkId(plugin.id);
3555
+ return plugin;
3556
+ }
3557
+ const id = plugin;
3558
+ checkId(id);
3559
+ return (options = {}) => ({
3560
+ id,
3561
+ setup: (ctx) => setup?.(createPluginApi(ctx, options), options)
3562
+ });
3563
+ }
3564
+ //#endregion
3565
+ //#region src/dom/plugins/commands.ts
3566
+ /**
3567
+ * The error for a command id no plugin registered, naming the ids that were. Bindings log it
3568
+ * rather than throw, and show the control as it is before the plugins are set up.
3569
+ */
3570
+ function unknownCommand(id, registered) {
3571
+ const known = registered.length > 0 ? registered.join(", ") : "none";
3572
+ return new InkError("UnknownCommand", `No plugin registered a command ${id} (registered: ${known})`, {
3573
+ id,
3574
+ registered
3575
+ });
3576
+ }
3577
+ const EMPTY = {
3578
+ get: () => "",
3579
+ subscribe: () => () => {}
3580
+ };
3581
+ /**
3582
+ * A boolean readable computed from `sources`. Like `derived`, it subscribes to the sources only
3583
+ * while it has subscribers of its own; `watch` re-reads them, so a source can be swapped while
3584
+ * subscribed (a document's status when the document changes).
3585
+ */
3586
+ function computed(compute, watch) {
3587
+ const subs = /* @__PURE__ */ new Set();
3588
+ let cached = false;
3589
+ let stop;
3590
+ const changed = () => {
3591
+ const next = compute();
3592
+ if (next === cached) return;
3593
+ cached = next;
3594
+ for (const sub of [...subs]) sub(next);
3595
+ };
3596
+ return {
3597
+ get: () => stop ? cached : compute(),
3598
+ subscribe(sub) {
3599
+ if (!stop) {
3600
+ cached = compute();
3601
+ stop = watch(changed);
3602
+ }
3603
+ subs.add(sub);
3604
+ return () => {
3605
+ if (!subs.delete(sub) || subs.size > 0) return;
3606
+ stop?.();
3607
+ stop = void 0;
3608
+ };
3609
+ }
3610
+ };
3611
+ }
3612
+ /** True while `document` holds a document whose status is ready. */
3613
+ function documentReady(document) {
3614
+ return computed(() => document.get()?.status.get().state === "ready", (changed) => {
3615
+ let stopStatus;
3616
+ const follow = (doc) => {
3617
+ stopStatus?.();
3618
+ stopStatus = doc?.status.subscribe(changed);
3619
+ changed();
3620
+ };
3621
+ const stopDocument = document.subscribe(follow);
3622
+ follow(document.get());
3623
+ return () => {
3624
+ stopDocument();
3625
+ stopStatus?.();
3626
+ };
3627
+ });
3628
+ }
3629
+ /**
3630
+ * Plugin commands by namespaced id. A command is busy from `run()` until the promise its spec
3631
+ * returned settles, and is disabled meanwhile, so a double click runs it once.
3632
+ */
3633
+ function createCommandRegistry(opts) {
3634
+ const commands = /* @__PURE__ */ new Map();
3635
+ const ready = documentReady(opts.document);
3636
+ return {
3637
+ commands,
3638
+ add(plugin, spec) {
3639
+ const id = `${plugin}.${spec.id}`;
3640
+ if (commands.has(id)) throw new InkError("DuplicateId", `A command with id ${id} already exists`, { id });
3641
+ if (!spec.label.trim()) throw new InkError("InvalidCommandLabel", `Command ${id} needs a label`, { id });
3642
+ const busy = writable(false);
3643
+ const allowed = spec.enabled ?? ready;
3644
+ const enabled = computed(() => allowed.get() && !busy.get(), (changed) => {
3645
+ const stops = [allowed.subscribe(changed), busy.subscribe(changed)];
3646
+ return () => {
3647
+ for (const stop of stops) stop();
3648
+ };
3649
+ });
3650
+ const command = {
3651
+ id,
3652
+ label: spec.label,
3653
+ icon: spec.icon,
3654
+ enabled,
3655
+ busy: readonly(busy),
3656
+ status: spec.status ?? EMPTY,
3657
+ async run() {
3658
+ if (!enabled.get()) return;
3659
+ busy.set(true);
3660
+ try {
3661
+ await spec.run();
3662
+ } catch (error) {
3663
+ if (!(isInkError(error) && error.code === "DocumentDestroyed")) console.error(error);
3664
+ } finally {
3665
+ busy.set(false);
3666
+ }
3667
+ }
3668
+ };
3669
+ commands.set(id, command);
3670
+ return command;
3671
+ }
3672
+ };
3673
+ }
3674
+ //#endregion
3675
+ //#region src/dom/plugins/layer.ts
3676
+ function createLayerRegistry() {
3677
+ const layers = [];
3678
+ const listeners = /* @__PURE__ */ new Set();
3679
+ const emit = (event) => {
3680
+ for (const listener of [...listeners]) listener(event);
3681
+ };
3682
+ return {
3683
+ layers,
3684
+ watchLayers(listener) {
3685
+ listeners.add(listener);
3686
+ return () => listeners.delete(listener);
3687
+ },
3688
+ add(layer) {
3689
+ layers.push(layer);
3690
+ emit({
3691
+ type: "add",
3692
+ layer
3693
+ });
3694
+ return { invalidate: (page) => emit({
3695
+ type: "invalidate",
3696
+ layer,
3697
+ page
3698
+ }) };
3699
+ }
3700
+ };
3701
+ }
3702
+ /** Runs a plugin's drawing; a throw is logged so one plugin cannot break the page. */
3703
+ function guard$1(fn) {
3704
+ try {
3705
+ fn();
3706
+ } catch (error) {
3707
+ console.error(error);
3708
+ }
3709
+ }
3710
+ const CHILD_RULE = ":where(.ink-plugin-layer > *) { pointer-events: auto; }";
3711
+ /** One sheet per window: a constructed sheet can only be adopted in its own window. */
3712
+ const childSheets = /* @__PURE__ */ new WeakMap();
3713
+ /**
3714
+ * Puts the children's `pointer-events: auto` rule in the document or shadow root `el` lives
3715
+ * in: an adopted sheet, again if the host has since replaced its adopted sheets, or one
3716
+ * `<style>` element where adopted sheets are missing. A layer not yet connected gets it on a
3717
+ * later draw, which calls this again.
3718
+ */
3719
+ function adoptChildRule(el) {
3720
+ if (!el.isConnected) return;
3721
+ const root = el.getRootNode();
3722
+ const doc = el.ownerDocument;
3723
+ const win = doc.defaultView;
3724
+ if (!root.adoptedStyleSheets || !win) {
3725
+ const parent = root === doc ? doc.head ?? doc.documentElement : root;
3726
+ if ([...parent.children].some((c) => c.matches("style[data-ink-plugin-layer]"))) return;
3727
+ const style = doc.createElement("style");
3728
+ style.dataset.inkPluginLayer = "";
3729
+ style.textContent = CHILD_RULE;
3730
+ parent.append(style);
3731
+ return;
3732
+ }
3733
+ let sheet = childSheets.get(win);
3734
+ if (!sheet) {
3735
+ sheet = new win.CSSStyleSheet();
3736
+ sheet.replaceSync(CHILD_RULE);
3737
+ childSheets.set(win, sheet);
3738
+ }
3739
+ if (!root.adoptedStyleSheets.includes(sheet)) root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
3740
+ }
3741
+ /**
3742
+ * Mounts `source`'s layers on the pages the engine mounts, and follows it: a layer added later
3743
+ * is drawn on every mounted page, and an invalidate redraws its page, or all of them.
3744
+ *
3745
+ * @internal For bindings, which mount plugin layers; plugin authors add a layer with
3746
+ * `ctx.layer`.
3747
+ */
3748
+ function createPluginLayers(source) {
3749
+ const pages = /* @__PURE__ */ new Map();
3750
+ const draw = (layer, el, page, t) => {
3751
+ adoptChildRule(el);
3752
+ guard$1(() => layer.render(el, page, t));
3753
+ };
3754
+ const add = (layer, page, state) => {
3755
+ const el = state.host.ownerDocument.createElement("div");
3756
+ el.className = "ink-plugin-layer";
3757
+ el.dataset.plugin = layer.plugin;
3758
+ el.style.cssText = "position:absolute;inset:0;pointer-events:none;";
3759
+ state.host.append(el);
3760
+ state.elements.set(layer, el);
3761
+ draw(layer, el, page, state.transform);
3762
+ };
3763
+ return {
3764
+ mount(page, host, transform) {
3765
+ const state = {
3766
+ host,
3767
+ transform,
3768
+ elements: /* @__PURE__ */ new Map()
3769
+ };
3770
+ pages.set(page, state);
3771
+ for (const layer of source.layers) add(layer, page, state);
3772
+ return {
3773
+ update(next) {
3774
+ state.transform = next;
3775
+ for (const [layer, el] of state.elements) draw(layer, el, page, next);
3776
+ },
3777
+ destroy() {
3778
+ if (pages.get(page) === state) pages.delete(page);
3779
+ for (const [layer, el] of state.elements) {
3780
+ if (layer.destroy) guard$1(() => layer.destroy?.(el, page));
3781
+ el.remove();
3782
+ }
3783
+ state.elements.clear();
3784
+ }
3785
+ };
3786
+ },
3787
+ destroy: source.watchLayers((event) => {
3788
+ if (event.type === "add") {
3789
+ for (const [page, state] of pages) add(event.layer, page, state);
3790
+ return;
3791
+ }
3792
+ const redraw = (page, state) => {
3793
+ const el = state.elements.get(event.layer);
3794
+ if (el) draw(event.layer, el, page, state.transform);
3795
+ };
3796
+ if (event.page === void 0) for (const [page, state] of pages) redraw(page, state);
3797
+ else {
3798
+ const state = pages.get(event.page);
3799
+ if (state) redraw(event.page, state);
3800
+ }
3801
+ })
3802
+ };
3803
+ }
3804
+ //#endregion
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
+ };
3823
+ /**
3824
+ * `value` seen with `extra` laid over it, read-only. Everything else is read from and written to
3825
+ * `value` itself, and its methods are bound to it, so a method always runs on the original
3826
+ * (a class instance keeps its `#private` fields, and `this.id` is the plugin's own id).
3827
+ */
3828
+ function withProps(value, extra) {
3829
+ const bound = /* @__PURE__ */ new Map();
3830
+ const own = (key) => Object.hasOwn(extra, key);
3831
+ return new Proxy(Object.create(null), {
3832
+ get(_, key) {
3833
+ if (own(key)) return extra[key];
3834
+ const v = Reflect.get(value, key, value);
3835
+ if (typeof v !== "function") return v;
3836
+ let entry = bound.get(key);
3837
+ if (entry?.fn !== v) {
3838
+ entry = {
3839
+ fn: v,
3840
+ bound: v.bind(value)
3841
+ };
3842
+ bound.set(key, entry);
3843
+ }
3844
+ return entry.bound;
3845
+ },
3846
+ set: (_, key, v) => !own(key) && Reflect.set(value, key, v, value),
3847
+ defineProperty(target, key, d) {
3848
+ if (own(key) || !Reflect.defineProperty(value, key, d)) return false;
3849
+ const defined = Reflect.getOwnPropertyDescriptor(value, key);
3850
+ if (defined && !defined.configurable) Reflect.defineProperty(target, key, defined);
3851
+ return true;
3852
+ },
3853
+ deleteProperty: (_, key) => !own(key) && Reflect.deleteProperty(value, key),
3854
+ has: (_, key) => own(key) || Reflect.has(value, key),
3855
+ ownKeys: () => [.../* @__PURE__ */ new Set([...Reflect.ownKeys(value), ...Reflect.ownKeys(extra)])],
3856
+ getOwnPropertyDescriptor(target, key) {
3857
+ const d = Reflect.getOwnPropertyDescriptor(own(key) ? extra : value, key);
3858
+ const fixed = Reflect.getOwnPropertyDescriptor(target, key)?.configurable === false;
3859
+ return d && {
3860
+ ...d,
3861
+ configurable: !fixed
3862
+ };
3863
+ },
3864
+ getPrototypeOf: () => Reflect.getPrototypeOf(value)
3865
+ });
3866
+ }
3867
+ /** Runs a plugin callback; a throw is logged so one plugin cannot break the others. */
3868
+ function guard(fn) {
3869
+ try {
3870
+ return fn();
3871
+ } catch (error) {
3872
+ console.error(error);
3873
+ return;
3874
+ }
3875
+ }
3876
+ /**
3877
+ * @internal For bindings and `createViewer`, which place a viewer's plugins; plugin authors use
3878
+ * `definePlugin` and never call it.
3879
+ */
3880
+ function createPluginHost(opts) {
3881
+ const seen = /* @__PURE__ */ new Set();
3882
+ for (const plugin of opts.plugins) {
3883
+ if (seen.has(plugin.id)) throw new InkError("DuplicatePlugin", `A plugin with id ${plugin.id} is registered twice`, { id: plugin.id });
3884
+ seen.add(plugin.id);
3885
+ }
3886
+ const registry = createCommandRegistry({ document: opts.document });
3887
+ const tools = [];
3888
+ const decorators = [];
3889
+ const layers = createLayerRegistry();
3890
+ const requests = [];
3891
+ const documentFns = [];
3892
+ const editFns = [];
3893
+ const setupCleanups = [];
3894
+ const redecorators = /* @__PURE__ */ new Set();
3895
+ let opened = null;
3896
+ let destroyed = false;
3897
+ /** Creates a requested store for the open document and has its history track it. */
3898
+ const openStore = (request, state, history) => {
3899
+ const store = createRecordStore(request.key, {
3900
+ initial: request.opts.initial?.(state.doc) ?? [],
3901
+ ...request.opts.onChange && { onChange: request.opts.onChange }
3902
+ });
3903
+ state.stores.push(store);
3904
+ state.detach.push(history.track(store));
3905
+ request.current.set(store);
3906
+ };
3907
+ const runOnDocument = (fn, state) => {
3908
+ const cleanup = guard(() => fn(state.doc));
3909
+ if (typeof cleanup === "function") state.cleanups.push(cleanup);
3910
+ };
3911
+ const open = (doc) => {
3912
+ const history = opts.history();
3913
+ const state = {
3914
+ doc,
3915
+ stores: [],
3916
+ detach: [],
3917
+ cleanups: []
3918
+ };
3919
+ opened = state;
3920
+ for (const request of requests) openStore(request, state, history);
3921
+ for (const fn of editFns) state.detach.push(history.react(fn));
3922
+ for (const fn of documentFns) runOnDocument(fn, state);
3923
+ };
3924
+ const close = () => {
3925
+ const state = opened;
3926
+ if (!state) return;
3927
+ opened = null;
3928
+ for (const detach of state.detach) detach();
3929
+ for (const store of state.stores) store.destroy();
3930
+ for (const request of requests) request.current.set(null);
3931
+ for (const cleanup of state.cleanups.reverse()) guard(cleanup);
3932
+ };
3933
+ const contextFor = (plugin) => {
3934
+ const storeNames = /* @__PURE__ */ new Set();
3935
+ return {
3936
+ id: plugin.id,
3937
+ document: opts.document,
3938
+ history: { batch: (fn) => opts.history().batch(fn) },
3939
+ activated: opts.activated,
3940
+ setActivated: (id) => opts.setActivated(id),
3941
+ root: opts.root,
3942
+ commands: { add: (spec) => registry.add(plugin.id, spec) },
3943
+ tools: { add(tool) {
3944
+ const id = `${plugin.id}.${tool.id}`;
3945
+ if (tools.some((t) => t.id === id)) throw new InkError("DuplicateId", `A tool with id ${id} already exists`, { id });
3946
+ tools.push(withProps(tool, { id }));
3947
+ } },
3948
+ decorate(fn) {
3949
+ decorators.push(fn);
3950
+ },
3951
+ redecorate() {
3952
+ for (const listener of [...redecorators]) listener();
3953
+ },
3954
+ store(name, storeOpts = {}) {
3955
+ const key = `${plugin.id}.${name}`;
3956
+ if (storeNames.has(name)) throw new InkError("DuplicateId", `A store with key ${key} already exists`, { key });
3957
+ storeNames.add(name);
3958
+ const request = {
3959
+ key,
3960
+ opts: storeOpts,
3961
+ current: writable(null)
3962
+ };
3963
+ requests.push(request);
3964
+ if (opened) openStore(request, opened, opts.history());
3965
+ return readonly(request.current);
3966
+ },
3967
+ onDocument(fn) {
3968
+ documentFns.push(fn);
3969
+ if (opened) runOnDocument(fn, opened);
3970
+ },
3971
+ onAnnotationEdit(fn) {
3972
+ editFns.push(fn);
3973
+ if (opened) opened.detach.push(opts.history().react(fn));
3974
+ },
3975
+ layer(spec) {
3976
+ return layers.add(withProps(spec, { plugin: plugin.id }));
3977
+ },
3978
+ scrollTo(page, rect, scrollOpts) {
3979
+ opts.viewport?.()?.scrollToRect(page, rect, scrollOpts);
3980
+ },
3981
+ search: opts.search ?? inertSearch
3982
+ };
3983
+ };
3984
+ for (const plugin of opts.plugins) {
3985
+ let cleanup;
3986
+ try {
3987
+ cleanup = plugin.setup(contextFor(plugin));
3988
+ } catch (error) {
3989
+ for (const done of setupCleanups.reverse()) guard(done);
3990
+ throw error;
3991
+ }
3992
+ if (typeof cleanup === "function") setupCleanups.push(cleanup);
3993
+ }
3994
+ const current = opts.document.get();
3995
+ if (current) open(current);
3996
+ const stopDocument = opts.document.subscribe((doc) => {
3997
+ close();
3998
+ if (doc) open(doc);
3999
+ });
4000
+ return {
4001
+ commands: registry.commands,
4002
+ command: (id) => registry.commands.get(id),
4003
+ tools,
4004
+ decorate(annotation) {
4005
+ const all = [opts.decorate?.(), ...decorators];
4006
+ let merged;
4007
+ for (const fn of all) {
4008
+ const d = fn && guard(() => fn(annotation));
4009
+ if (!d) continue;
4010
+ merged ??= {};
4011
+ if (d.data) merged = {
4012
+ ...merged,
4013
+ data: {
4014
+ ...d.data,
4015
+ ...merged.data
4016
+ }
4017
+ };
4018
+ if (d.classes) merged = {
4019
+ ...merged,
4020
+ classes: [...merged.classes ?? [], ...d.classes]
4021
+ };
4022
+ if (merged.description === void 0 && d.description !== void 0) merged = {
4023
+ ...merged,
4024
+ description: d.description
4025
+ };
4026
+ }
4027
+ return merged;
4028
+ },
4029
+ layers: layers.layers,
4030
+ watchLayers: (listener) => layers.watchLayers(listener),
4031
+ watchRedecorate(listener) {
4032
+ const run = () => listener();
4033
+ redecorators.add(run);
4034
+ return () => {
4035
+ redecorators.delete(run);
4036
+ };
4037
+ },
4038
+ destroy() {
4039
+ if (destroyed) return;
4040
+ destroyed = true;
4041
+ stopDocument();
4042
+ close();
4043
+ for (const cleanup of setupCleanups.reverse()) guard(cleanup);
4044
+ }
4045
+ };
4046
+ }
4047
+ //#endregion
4048
+ //#region src/dom/page-mount.ts
4049
+ /**
4050
+ * Canvas, text layer, annotation overlay and any plugin layers for one page, stacked in that
4051
+ * order.
4052
+ */
4053
+ function createPageMount(opts) {
4054
+ const { document, page, host, transform } = opts;
4055
+ const callbacks = (layer) => ({
4056
+ ...opts.onError ? { onError: (error) => opts.onError?.(error, layer) } : {},
4057
+ ...opts.onRendered ? { onRendered: () => opts.onRendered?.(layer) } : {}
4058
+ });
4059
+ const renderer = createPageRenderer({
4060
+ document,
4061
+ page,
4062
+ host,
4063
+ transform,
4064
+ ...opts.devicePixelRatio === void 0 ? {} : { devicePixelRatio: opts.devicePixelRatio },
4065
+ ...callbacks("canvas")
4066
+ });
4067
+ const textLayer = createTextLayer({
4068
+ document,
4069
+ page,
4070
+ host,
4071
+ transform,
4072
+ ...callbacks("text")
4073
+ });
4074
+ const marks = opts.highlights?.mount(page, host, transform);
4075
+ const overlay = createOverlay({
4076
+ host,
4077
+ page,
4078
+ store: opts.store,
4079
+ transform,
4080
+ document,
4081
+ ...opts.devicePixelRatio === void 0 ? {} : { devicePixelRatio: opts.devicePixelRatio },
4082
+ ...opts.onActivate ? { onActivate: (id) => opts.onActivate?.(id) } : {},
4083
+ ...opts.isCustom ? { isCustom: (a) => opts.isCustom?.(a) === true } : {},
4084
+ ...opts.decorate ? { decorate: opts.decorate } : {}
4085
+ });
4086
+ const layers = opts.layers?.mount(page, host, transform);
4087
+ return {
4088
+ renderer,
4089
+ textLayer,
4090
+ overlay,
4091
+ update(next) {
4092
+ renderer.update(next);
4093
+ textLayer.update(next);
4094
+ overlay.update(next);
4095
+ layers?.update(next);
4096
+ marks?.update(next);
4097
+ },
4098
+ destroy() {
4099
+ layers?.destroy();
4100
+ overlay.destroy();
4101
+ marks?.destroy();
4102
+ textLayer.destroy();
4103
+ renderer.destroy();
4104
+ }
4105
+ };
4106
+ }
4107
+ //#endregion
4108
+ //#region src/dom/tools/options.ts
4109
+ function createToolOptions(tools = []) {
4110
+ const byId = /* @__PURE__ */ new Map();
4111
+ const views = /* @__PURE__ */ new Map();
4112
+ const entry = (id) => {
4113
+ const w = byId.get(id);
4114
+ if (!w) throw new RangeError(`Unknown tool ${id}`);
4115
+ return w;
4116
+ };
4117
+ function register(list) {
4118
+ for (const t of list) {
4119
+ if (byId.has(t.id)) continue;
4120
+ const w = writable({ ...t.defaultOptions ?? {} });
4121
+ byId.set(t.id, w);
4122
+ views.set(t.id, readonly(w));
4123
+ }
4124
+ }
4125
+ register(tools);
4126
+ return {
4127
+ readable(id) {
4128
+ entry(id);
4129
+ return views.get(id);
4130
+ },
4131
+ get: (id) => entry(id).get(),
4132
+ merge(id, patch) {
4133
+ const w = entry(id);
4134
+ w.set({
4135
+ ...w.get(),
4136
+ ...patch
4137
+ });
4138
+ },
4139
+ register
4140
+ };
4141
+ }
4142
+ //#endregion
4143
+ //#region src/dom/tools/selection-edit.ts
4144
+ const CORNERS = [
4145
+ "nw",
4146
+ "ne",
4147
+ "sw",
4148
+ "se"
4149
+ ];
4150
+ const ENDS = ["start", "end"];
4151
+ const CALLOUT = "callout";
4152
+ /** Pointer travel, CSS px, before a press on an annotation becomes a move. */
4153
+ const DRAG_THRESHOLD = 3;
4154
+ function createSelectionEdit(ctx, opts = {}) {
4155
+ let mode = { kind: "idle" };
4156
+ const batch = (fn) => ctx.history ? ctx.history.batch(fn) : fn();
4157
+ const reset = () => {
4158
+ if (mode.kind === "marquee") mode.el.remove();
4159
+ if (mode.kind === "move") {
4160
+ for (const el of mode.els) el.style.transform = "";
4161
+ ctx.root.removeAttribute("data-ink-dragging");
4162
+ }
4163
+ if (mode.kind === "resize" && mode.el) {
4164
+ mode.el.style.transform = "";
4165
+ mode.el.style.transformOrigin = "";
4166
+ mode.el.style.visibility = "";
4167
+ }
4168
+ if (mode.kind === "resize") mode.preview?.remove();
4169
+ if (mode.kind === "endpoint") {
4170
+ mode.preview.remove();
4171
+ if (mode.el) mode.el.style.visibility = "";
4172
+ }
4173
+ mode = { kind: "idle" };
4174
+ };
4175
+ const elementIn = (root, id) => root.querySelector(`[data-annotation="${CSS.escape(id)}"]`) ?? void 0;
4176
+ const previewFor = (host, a) => {
4177
+ const preview = host.ownerDocument.createElement("div");
4178
+ preview.className = "ink-annotation ink-edit-preview";
4179
+ preview.dataset.inkPreview = "";
4180
+ preview.dataset.type = a.type;
4181
+ preview.style.cssText = "position:absolute;pointer-events:none;";
4182
+ return preview;
4183
+ };
4184
+ /** Shows `draft` in `preview` beside `el`, which is hidden meanwhile. */
4185
+ const showPreview = (preview, el, draft, start) => {
4186
+ drawPreview(preview, draft, start);
4187
+ if (el) el.style.visibility = "hidden";
4188
+ if (!preview.isConnected) (el?.parentElement ?? start.host).append(preview);
4189
+ };
4190
+ function startHandle(e, hit, a, handle) {
4191
+ const el = elementIn(hit.host, a.id);
4192
+ if (CORNERS.includes(handle) && hasCornerHandles(a)) {
4193
+ const text = a.type === "freetext";
4194
+ mode = {
4195
+ kind: "resize",
4196
+ id: a.id,
4197
+ start: hit,
4198
+ corner: handle,
4199
+ box: text ? hit.transform.toCssRect(freeTextBox(a)) : handleBox(a, hit.transform),
4200
+ el,
4201
+ locked: keepsAspect(a),
4202
+ preview: text && (a.fringe !== void 0 || hasCallout(a)) ? previewFor(hit.host, a) : void 0
4203
+ };
2968
4204
  } else if (ENDS.includes(handle) && a.type === "line" || handle === CALLOUT && hasCallout(a)) {
2969
4205
  const preview = previewFor(hit.host, a);
2970
4206
  mode = {
@@ -3611,6 +4847,8 @@ function onScrollBar(e, root) {
3611
4847
  function inCustomContent(target) {
3612
4848
  if (!(target instanceof Element)) return false;
3613
4849
  if (target.closest("[data-ink-editor]")) return true;
4850
+ const layer = target.closest(".ink-plugin-layer");
4851
+ if (layer && layer !== target) return true;
3614
4852
  const el = target.closest("[data-annotation]");
3615
4853
  return el !== null && el !== target && el.hasAttribute("data-custom") && target.closest("[data-handle]") === null;
3616
4854
  }
@@ -5364,284 +6602,154 @@ function handleRadioKeys(e, group) {
5364
6602
  const next = radios[(at + (forward ? 1 : -1) + radios.length) % radios.length];
5365
6603
  next.focus({ preventScroll: true });
5366
6604
  next.click();
5367
- }
5368
- /**
5369
- * Arrow keys, Home and End inside a `role="tablist"`: the next (or previous) enabled
5370
- * `role="tab"` takes focus and is chosen, wrapping at the ends.
5371
- */
5372
- function handleTabKeys(e, tablist) {
5373
- if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
5374
- const tabs = [...tablist.querySelectorAll("[role=\"tab\"]:not(:disabled):not([aria-disabled=\"true\"])")];
5375
- const at = tabs.indexOf(document.activeElement);
5376
- if (at < 0) return;
5377
- const last = tabs.length - 1;
5378
- const next = e.key === "ArrowRight" || e.key === "ArrowDown" ? at === last ? 0 : at + 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? at === 0 ? last : at - 1 : e.key === "Home" ? 0 : e.key === "End" ? last : -1;
5379
- if (next < 0) return;
5380
- e.preventDefault();
5381
- tabs[next].focus({ preventScroll: true });
5382
- tabs[next].click();
5383
- }
5384
- const TABBABLE$1 = [
5385
- "a[href]",
5386
- "button:not(:disabled)",
5387
- "input:not(:disabled):not([type=\"hidden\"])",
5388
- "select:not(:disabled)",
5389
- "textarea:not(:disabled)",
5390
- "[tabindex]"
5391
- ].map((s) => `${s}:not([tabindex="-1"])`).join(", ");
5392
- /**
5393
- * Keeps Tab inside `container`, a modal: Tab from its last stop goes to its first, and
5394
- * Shift+Tab from the first to the last.
5395
- */
5396
- function trapTab(e, container) {
5397
- if (e.key !== "Tab" || e.defaultPrevented) return;
5398
- const stops = [...container.querySelectorAll(TABBABLE$1)].filter((el) => !el.matches(":disabled") && el.getClientRects().length > 0);
5399
- if (stops.length === 0) {
5400
- e.preventDefault();
5401
- return;
5402
- }
5403
- const first = stops[0];
5404
- const last = stops[stops.length - 1];
5405
- const active = document.activeElement;
5406
- if (e.shiftKey && (active === first || !container.contains(active))) {
5407
- e.preventDefault();
5408
- last.focus();
5409
- } else if (!e.shiftKey && (active === last || !container.contains(active))) {
5410
- e.preventDefault();
5411
- first.focus();
5412
- }
5413
- }
5414
- //#endregion
5415
- //#region src/dom/annotation-element.ts
5416
- /** The overlay element drawn for annotation `id` under `root`, if it is in the DOM. */
5417
- function annotationElementFor(root, id) {
5418
- return root.querySelector(`[data-annotation="${CSS.escape(id)}"]`);
5419
- }
5420
- /**
5421
- * Resolves the overlay element for `id` and keeps resolving while `root`'s subtree changes:
5422
- * the overlay creates and removes these elements outside any framework (a page mounts as it
5423
- * scrolls into view), so a popover anchored to one has to follow. Calls `onChange` once now
5424
- * and again whenever the answer changes. The returned function stops observing.
5425
- */
5426
- function observeAnnotationElement(root, id, onChange) {
5427
- let last;
5428
- const resolve = () => {
5429
- const found = annotationElementFor(root, id);
5430
- if (found === last) return;
5431
- last = found;
5432
- onChange(found);
5433
- };
5434
- resolve();
5435
- const observer = new MutationObserver(resolve);
5436
- observer.observe(root, {
5437
- childList: true,
5438
- subtree: true
5439
- });
5440
- return () => observer.disconnect();
5441
- }
5442
- //#endregion
5443
- //#region src/dom/popover-focus.ts
5444
- /**
5445
- * A press that will activate an annotation is not a dismissal: the popover follows the
5446
- * activation. Mirrors the overlay's rule: a press on the annotation element or on its drawn
5447
- * shape activates; a press on a custom element's own content belongs to that content.
5448
- */
5449
- function shouldKeepOpenOnAnnotation(target) {
5450
- if (!(target instanceof Element)) return false;
5451
- const annotation = target.closest("[data-annotation]");
5452
- if (!annotation) return false;
5453
- return target === annotation || !annotation.hasAttribute("data-custom");
5454
- }
5455
- function createFocusReturn(opts) {
5456
- let fallbacks = [];
5457
- /** Set by `beforeRemove`: the close comes from removing the anchor's annotation. */
5458
- let removing = false;
5459
- return {
5460
- beforeRemove(anchor, prefer) {
5461
- fallbacks = [];
5462
- removing = true;
5463
- const siblings = anchor?.parentElement ? [...anchor.parentElement.children].filter((el) => el instanceof HTMLElement && el.hasAttribute("data-annotation")) : [];
5464
- const i = anchor ? siblings.indexOf(anchor) : -1;
5465
- if (i >= 0) {
5466
- const after = siblings.slice(i + 1);
5467
- const preferred = prefer ? after.filter((el) => el.matches(prefer)) : [];
5468
- fallbacks.push(...preferred, ...after, ...siblings.slice(0, i).reverse());
5469
- }
5470
- },
5471
- onCloseAutoFocus(event, anchor) {
5472
- event.preventDefault();
5473
- const plan = fallbacks;
5474
- const removed = removing;
5475
- fallbacks = [];
5476
- removing = false;
5477
- const root = opts.root();
5478
- if (!root) return;
5479
- const activated = opts.activated();
5480
- if (activated !== null && annotationElementFor(root, activated)) return;
5481
- const active = root.ownerDocument.activeElement;
5482
- if (active && active !== root && root.contains(active)) return;
5483
- const scroll = root.querySelector("[data-ink-scroll]");
5484
- (removed ? [...plan, scroll] : [
5485
- anchor,
5486
- ...plan,
5487
- scroll
5488
- ]).find((el) => el?.isConnected)?.focus({ preventScroll: true });
5489
- }
5490
- };
5491
- }
5492
- //#endregion
5493
- //#region src/dom/viewer/dom.ts
5494
- /** `document.createElement` with attributes and children. */
5495
- function h(tag, attrs = {}, ...children) {
5496
- const el = document.createElement(tag);
5497
- for (const [name, value] of Object.entries(attrs)) el.setAttribute(name, value);
5498
- el.append(...children);
5499
- return el;
5500
- }
5501
- function button$1(label, onClick) {
5502
- const b = h("button", { type: "button" }, label);
5503
- b.addEventListener("click", onClick);
5504
- return b;
5505
- }
5506
- //#endregion
5507
- //#region src/dom/viewer/popover.ts
5508
- /** Space between the anchor and the panel, and between the panel and the window edge. */
5509
- const GAP = 6;
5510
- const MARGIN = 8;
5511
- const TABBABLE = "button, input, select, textarea, a[href], [tabindex]";
5512
- /**
5513
- * Positions `panel` (fixed) below the `anchor` rectangle, or above it when only that fits,
5514
- * kept inside the window. For panels anchored to something other than an annotation.
5515
- */
5516
- function placePanel(panel, anchor) {
5517
- const { innerWidth: vw, innerHeight: vh } = window;
5518
- panel.style.position = "fixed";
5519
- panel.style.left = "0px";
5520
- panel.style.top = "0px";
5521
- const w = panel.offsetWidth;
5522
- const ht = panel.offsetHeight;
5523
- let top = anchor.bottom + GAP;
5524
- if (top + ht > vh - MARGIN && anchor.top - GAP - ht >= MARGIN) top = anchor.top - GAP - ht;
5525
- top = Math.max(MARGIN, Math.min(top, vh - ht - MARGIN));
5526
- const left = Math.max(MARGIN, Math.min(anchor.left, vw - w - MARGIN));
5527
- panel.style.left = `${left}px`;
5528
- panel.style.top = `${top}px`;
5529
- }
5530
- /**
5531
- * Creates a non-modal `role="dialog"` panel on `<body>`, marked `data-ink-popover` so the theme
5532
- * gives it its tokens, that an app anchors to an annotation with `show(id)` (for instance on
5533
- * `viewer.activated`). The panel follows the annotation's element as pages mount and unmount:
5534
- * it hides while the element is gone and comes back with it. Tab loops inside the panel, and
5535
- * Escape or a press outside calls `onDismiss`. Call `destroy` when done.
5536
- */
5537
- function createPopover(opts) {
5538
- const { root, focusReturn } = opts;
5539
- const panel = h("div", {
5540
- ...opts.attrs,
5541
- role: "dialog",
5542
- "data-ink-popover": "",
5543
- hidden: ""
5544
- });
5545
- document.body.append(panel);
5546
- let anchoredId = null;
5547
- let anchor = null;
5548
- let stopObserving = () => void 0;
5549
- /** The element that had focus in the panel when the anchor went away. */
5550
- let stranded = null;
5551
- const place = () => {
5552
- if (!anchor || panel.hidden) return;
5553
- placePanel(panel, anchor.getBoundingClientRect());
5554
- };
5555
- const resizes = new ResizeObserver(place);
5556
- resizes.observe(panel);
5557
- const onAnchor = (el, closing = false) => {
5558
- if (anchor) resizes.unobserve(anchor);
5559
- anchor = el;
5560
- if (!el) {
5561
- const active = document.activeElement;
5562
- if (!closing && !panel.hidden && active instanceof HTMLElement && panel.contains(active)) {
5563
- const scroll = root.querySelector("[data-ink-scroll]");
5564
- if (scroll) {
5565
- stranded = active;
5566
- scroll.focus({ preventScroll: true });
5567
- }
5568
- }
5569
- panel.hidden = true;
5570
- return;
5571
- }
5572
- resizes.observe(el);
5573
- const wasHidden = panel.hidden;
5574
- panel.hidden = false;
5575
- place();
5576
- if (wasHidden && stranded) {
5577
- const back = stranded;
5578
- stranded = null;
5579
- const scroll = root.querySelector("[data-ink-scroll]");
5580
- if (panel.contains(back) && document.activeElement === scroll) back.focus({ preventScroll: true });
5581
- }
5582
- };
5583
- const onKey = (e) => {
5584
- if (e.key !== "Escape" || panel.hidden || e.defaultPrevented) return;
5585
- const target = e.target;
5586
- if (!(target instanceof Node) || !(panel.contains(target) || root.contains(target))) return;
6605
+ }
6606
+ /**
6607
+ * Arrow keys, Home and End inside a `role="tablist"`: the next (or previous) enabled
6608
+ * `role="tab"` takes focus and is chosen, wrapping at the ends.
6609
+ */
6610
+ function handleTabKeys(e, tablist) {
6611
+ if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
6612
+ const tabs = [...tablist.querySelectorAll("[role=\"tab\"]:not(:disabled):not([aria-disabled=\"true\"])")];
6613
+ const at = tabs.indexOf(document.activeElement);
6614
+ if (at < 0) return;
6615
+ const last = tabs.length - 1;
6616
+ const next = e.key === "ArrowRight" || e.key === "ArrowDown" ? at === last ? 0 : at + 1 : e.key === "ArrowLeft" || e.key === "ArrowUp" ? at === 0 ? last : at - 1 : e.key === "Home" ? 0 : e.key === "End" ? last : -1;
6617
+ if (next < 0) return;
6618
+ e.preventDefault();
6619
+ tabs[next].focus({ preventScroll: true });
6620
+ tabs[next].click();
6621
+ }
6622
+ const TABBABLE = [
6623
+ "a[href]",
6624
+ "button:not(:disabled)",
6625
+ "input:not(:disabled):not([type=\"hidden\"])",
6626
+ "select:not(:disabled)",
6627
+ "textarea:not(:disabled)",
6628
+ "[tabindex]"
6629
+ ].map((s) => `${s}:not([tabindex="-1"])`).join(", ");
6630
+ /**
6631
+ * Keeps Tab inside `container`, a modal: Tab from its last stop goes to its first, and
6632
+ * Shift+Tab from the first to the last.
6633
+ */
6634
+ function trapTab(e, container) {
6635
+ if (e.key !== "Tab" || e.defaultPrevented) return;
6636
+ const stops = [...container.querySelectorAll(TABBABLE)].filter((el) => !el.matches(":disabled") && el.getClientRects().length > 0);
6637
+ if (stops.length === 0) {
5587
6638
  e.preventDefault();
5588
- opts.onDismiss();
5589
- };
5590
- const onPress = (e) => {
5591
- if (panel.hidden || panel.contains(e.target)) return;
5592
- if (shouldKeepOpenOnAnnotation(e.target)) return;
5593
- opts.onDismiss();
5594
- };
5595
- const onTab = (e) => {
5596
- if (e.key !== "Tab" || e.altKey || e.ctrlKey || e.metaKey) return;
5597
- const tabbable = [...panel.querySelectorAll(TABBABLE)].filter((el) => !el.disabled && el.tabIndex >= 0);
5598
- const first = tabbable[0];
5599
- const last = tabbable.at(-1);
5600
- if (!first || !last) return;
5601
- const active = document.activeElement;
5602
- if (e.shiftKey ? active !== first : active !== last) return;
6639
+ return;
6640
+ }
6641
+ const first = stops[0];
6642
+ const last = stops[stops.length - 1];
6643
+ const active = document.activeElement;
6644
+ if (e.shiftKey && (active === first || !container.contains(active))) {
5603
6645
  e.preventDefault();
5604
- (e.shiftKey ? last : first).focus();
6646
+ last.focus();
6647
+ } else if (!e.shiftKey && (active === last || !container.contains(active))) {
6648
+ e.preventDefault();
6649
+ first.focus();
6650
+ }
6651
+ }
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;
5605
6721
  };
5606
- panel.addEventListener("keydown", onTab);
5607
- document.addEventListener("keydown", onKey);
5608
- document.addEventListener("pointerdown", onPress, true);
5609
- document.addEventListener("scroll", place, true);
5610
- window.addEventListener("resize", place);
5611
6722
  return {
5612
- panel,
5613
- get anchor() {
5614
- return anchor;
5615
- },
5616
- show(id) {
5617
- if (id === anchoredId) return;
5618
- stopObserving();
5619
- anchoredId = id;
5620
- stopObserving = observeAnnotationElement(root, id, onAnchor);
5621
- },
5622
- hide() {
5623
- if (anchoredId === null) return;
5624
- stopObserving();
5625
- stopObserving = () => void 0;
5626
- anchoredId = null;
5627
- const back = anchor;
5628
- stranded = null;
5629
- onAnchor(null, true);
5630
- queueMicrotask(() => {
5631
- if (anchoredId !== null || !panel.isConnected) return;
5632
- focusReturn.onCloseAutoFocus(new Event("closeautofocus", { cancelable: true }), back);
5633
- });
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
+ };
5634
6749
  },
5635
- destroy() {
5636
- stopObserving();
5637
- resizes.disconnect();
5638
- document.removeEventListener("keydown", onKey);
5639
- document.removeEventListener("pointerdown", onPress, true);
5640
- panel.removeEventListener("keydown", onTab);
5641
- document.removeEventListener("scroll", place, true);
5642
- window.removeEventListener("resize", place);
5643
- panel.remove();
5644
- }
6750
+ destroy: search.state.subscribe(({ hits }) => {
6751
+ for (const [index, page] of pages) draw(page, false, hitsOn(hits, index));
6752
+ })
5645
6753
  };
5646
6754
  }
5647
6755
  //#endregion
@@ -5657,7 +6765,17 @@ function mountViewer(opts) {
5657
6765
  let stopKeys = () => void 0;
5658
6766
  let stopActive = () => void 0;
5659
6767
  let destroyed = false;
5660
- const hostTools = opts.tools ?? [];
6768
+ const plugins = opts.plugins;
6769
+ const hostTools = [...opts.tools ?? [], ...plugins?.tools ?? []];
6770
+ /** Plugin layers follow the host for the engine's life, across rebuilds. */
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);
6774
+ const decorate = plugins ? (a) => plugins.decorate(a) : (a) => opts.decorateAnnotation?.(a);
6775
+ /** A plugin's `ctx.redecorate()` reaches every page this engine has mounted. */
6776
+ const stopRedecorate = plugins?.watchRedecorate(() => {
6777
+ for (const mount of pages.values()) mount.overlay.redecorate();
6778
+ });
5661
6779
  /** Option values outlive rebuilds: every controller shares this registry. */
5662
6780
  const toolOptions = opts.toolOptions ?? createToolOptions();
5663
6781
  /** Without a host style, the engine's own follows the active tool across rebuilds. */
@@ -5686,7 +6804,9 @@ function mountViewer(opts) {
5686
6804
  store: doc.annotations,
5687
6805
  onActivate: (id) => opts.onActivate?.(id),
5688
6806
  isCustom: () => isCustom,
5689
- decorate: (a) => opts.decorateAnnotation?.(a),
6807
+ decorate,
6808
+ ...layers ? { layers } : {},
6809
+ ...highlights ? { highlights } : {},
5690
6810
  onError: (cause, layer) => {
5691
6811
  failed.add(layer);
5692
6812
  if (error) return;
@@ -5871,9 +6991,292 @@ function mountViewer(opts) {
5871
6991
  if (destroyed) return;
5872
6992
  destroyed = true;
5873
6993
  teardown();
6994
+ layers?.destroy();
6995
+ highlights?.destroy();
6996
+ stopRedecorate?.();
6997
+ }
6998
+ };
6999
+ }
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();
5874
7211
  }
5875
7212
  };
5876
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
+ }
5877
7280
  //#endregion
5878
7281
  //#region src/dom/viewer/thread-popover.ts
5879
7282
  function createThreadPopover(opts) {
@@ -6043,8 +7446,14 @@ function createThreadForm(n, actions) {
6043
7446
  }
6044
7447
  //#endregion
6045
7448
  //#region src/dom/viewer/create-viewer.ts
7449
+ const noop = () => void 0;
7450
+ const passThrough = {
7451
+ batch: (fn) => fn(),
7452
+ track: () => noop,
7453
+ react: () => noop
7454
+ };
6046
7455
  function createViewer(el, opts) {
6047
- const { source: initial, assets, password, defaults, history, 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;
6048
7457
  /** One library for the viewer's life, so templates and the menu outlive each document. */
6049
7458
  const signatures = createSignatureLibrary(signatureStore ?? createMemorySignatureStore());
6050
7459
  /** Tool options persist for the viewer's life; the tool style is per document. */
@@ -6056,6 +7465,7 @@ function createViewer(el, opts) {
6056
7465
  const tools = writable(null);
6057
7466
  /** The activated annotation: the note thread opens from it. */
6058
7467
  const activated = writable(null);
7468
+ const rootEl = writable(null);
6059
7469
  const root = h("div", {
6060
7470
  "data-ink-root": "",
6061
7471
  "data-state": "idle"
@@ -6074,6 +7484,35 @@ function createViewer(el, opts) {
6074
7484
  }, scroll);
6075
7485
  root.append(viewportRoot);
6076
7486
  el.append(root);
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
+ });
7498
+ /**
7499
+ * One host for the viewer's life. It reads the history when a document opens, and `current`
7500
+ * is set before it notifies, so that is the new document's. With no document open, a
7501
+ * command's `batch` just runs its function.
7502
+ */
7503
+ const host = createPluginHost({
7504
+ plugins: plugins ?? [],
7505
+ document: readonly(current),
7506
+ history: () => current.get()?.history ?? passThrough,
7507
+ activated: readonly(activated),
7508
+ setActivated: (id) => {
7509
+ if (id === null || current.get()?.annotations.get(id)) activated.set(id);
7510
+ },
7511
+ root: readonly(rootEl),
7512
+ decorate: () => view.decorateAnnotation,
7513
+ viewport: () => viewport.get(),
7514
+ search: viewerSearch
7515
+ });
6077
7516
  const focusReturn = createFocusReturn({
6078
7517
  root: () => root,
6079
7518
  activated: () => activated.get()
@@ -6123,6 +7562,8 @@ function createViewer(el, opts) {
6123
7562
  };
6124
7563
  const mounted = mountViewer({
6125
7564
  ...view,
7565
+ plugins: host,
7566
+ search,
6126
7567
  container: scroll,
6127
7568
  document: doc,
6128
7569
  toolOptions,
@@ -6173,6 +7614,7 @@ function createViewer(el, opts) {
6173
7614
  }
6174
7615
  let loads = 0;
6175
7616
  let destroyed = false;
7617
+ const destroyers = [];
6176
7618
  let stop = () => void 0;
6177
7619
  /** Tears down the open document, if any, and destroys it. */
6178
7620
  function close() {
@@ -6219,7 +7661,7 @@ function createViewer(el, opts) {
6219
7661
  });
6220
7662
  }
6221
7663
  open(initial);
6222
- return {
7664
+ const viewer = {
6223
7665
  element: root,
6224
7666
  status: readonly(status),
6225
7667
  document: readonly(current),
@@ -6227,24 +7669,65 @@ function createViewer(el, opts) {
6227
7669
  viewport: readonly(viewport),
6228
7670
  tools: readonly(tools),
6229
7671
  activated: readonly(activated),
6230
- setActivated(id) {
7672
+ setActivated: (id) => {
6231
7673
  if (id === null || current.get()?.annotations.get(id)) activated.set(id);
6232
7674
  },
7675
+ commands: { get: (id) => host.command(id) },
7676
+ search: viewerSearch,
6233
7677
  open,
6234
7678
  destroy() {
6235
7679
  if (destroyed) return;
6236
7680
  destroyed = true;
6237
7681
  loads++;
6238
7682
  close();
7683
+ for (const fn of destroyers) fn();
7684
+ search.destroy();
7685
+ host.destroy();
6239
7686
  root.remove();
6240
7687
  }
6241
7688
  };
7689
+ toolsByViewer.set(viewer, [...view.tools ?? [], ...host.tools]);
7690
+ commandIdsByViewer.set(viewer, () => [...host.commands.keys()]);
7691
+ internals.set(viewer, {
7692
+ search,
7693
+ destroyers,
7694
+ destroyed: () => destroyed
7695
+ });
7696
+ return viewer;
7697
+ }
7698
+ const toolsByViewer = /* @__PURE__ */ new WeakMap();
7699
+ /** The host tools a viewer was given (its own and its plugins'), for the controls' labels and icons. */
7700
+ function hostToolsOf(viewer) {
7701
+ return toolsByViewer.get(viewer) ?? [];
7702
+ }
7703
+ const commandIdsByViewer = /* @__PURE__ */ new WeakMap();
7704
+ /** The ids of the commands a viewer's plugins registered, for the error on an unknown one. */
7705
+ function hostCommandIdsOf(viewer) {
7706
+ return commandIdsByViewer.get(viewer)?.() ?? [];
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);
6242
7724
  }
6243
7725
  //#endregion
6244
7726
  //#region src/dom/viewer/controls.ts
6245
7727
  /**
6246
7728
  * Runs `connect` for every value `source` takes while it is set, undoing the previous run's
6247
- * 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.
6248
7731
  */
6249
7732
  function follow(source, control, connect, reset) {
6250
7733
  let stops = [];
@@ -6260,7 +7743,12 @@ function follow(source, control, connect, reset) {
6260
7743
  else connect(value, watch);
6261
7744
  };
6262
7745
  run(source.get());
6263
- source.subscribe(run);
7746
+ const stopSource = source.subscribe(run);
7747
+ return () => {
7748
+ stopSource();
7749
+ for (const s of stops) s();
7750
+ stops = [];
7751
+ };
6264
7752
  }
6265
7753
  const SVG = "http://www.w3.org/2000/svg";
6266
7754
  const TOOL_ICONS = {
@@ -6295,6 +7783,7 @@ function createIcon(icon) {
6295
7783
  }
6296
7784
  return svg;
6297
7785
  }
7786
+ /** @internal A labelled icon button, as the toolbar controls make them. */
6298
7787
  function button(label, attrs, icon) {
6299
7788
  const b = h("button", {
6300
7789
  type: "button",
@@ -6306,6 +7795,31 @@ function button(label, attrs, icon) {
6306
7795
  return b;
6307
7796
  }
6308
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
+ /**
6309
7823
  * A toolbar following the WAI-ARIA toolbar pattern: its buttons share one Tab stop, with
6310
7824
  * arrow keys, Home and End to move between them. Fields and selects are Tab stops of their
6311
7825
  * own and keep their keys. Buttons added later join in.
@@ -6350,15 +7864,16 @@ function historyButton(viewer, action) {
6350
7864
  return b;
6351
7865
  }
6352
7866
  /**
6353
- * Sets the tool; pressed while it is the active one. A host tool's button has no icon (append
6354
- * your own) and is named by `label`, else by the tool id.
7867
+ * Sets the tool; pressed while it is the active one. A host or plugin tool's button takes the
7868
+ * tool's own `label` and `icon`; `opts.label` wins, and without either it is named by the tool id.
6355
7869
  */
6356
7870
  function createToolButton(viewer, tool, opts = {}) {
6357
- const b = button(opts.label ?? TOOL_LABELS[tool] ?? tool, {
7871
+ const own = hostToolsOf(viewer).find((t) => t.id === tool);
7872
+ const b = button(opts.label ?? TOOL_LABELS[tool] ?? own?.label ?? tool, {
6358
7873
  class: "ink-tool-button",
6359
7874
  "data-ink-tool-button": "",
6360
7875
  "data-tool": tool
6361
- }, TOOL_ICONS[tool]);
7876
+ }, TOOL_ICONS[tool] ?? own?.icon);
6362
7877
  const show = (active) => {
6363
7878
  const on = active === tool;
6364
7879
  b.dataset.state = on ? "on" : "off";
@@ -6368,13 +7883,55 @@ function createToolButton(viewer, tool, opts = {}) {
6368
7883
  follow(viewer.tools, b, (tools, watch) => watch(tools.active, show), () => show(null));
6369
7884
  return b;
6370
7885
  }
7886
+ /**
7887
+ * A plugin command as a button with its status text beside it. The button is named by the
7888
+ * command's label and disabled while the command is, except while it runs: then it keeps focus,
7889
+ * with `aria-busy` and `aria-disabled`, and a press does nothing. The status is a live region. A command without an icon shows its label as the button's text. For an id no
7890
+ * plugin registered it logs `InkError('UnknownCommand')` to the console and returns the same
7891
+ * markup disabled, `aria-hidden` and unnamed.
7892
+ */
7893
+ function createCommandButton(viewer, id) {
7894
+ const command = viewer.commands.get(id);
7895
+ const status = h("span", {
7896
+ class: "ink-command-status",
7897
+ role: "status"
7898
+ });
7899
+ if (!command) {
7900
+ console.error(unknownCommand(id, hostCommandIdsOf(viewer)));
7901
+ const pending = h("button", {
7902
+ type: "button",
7903
+ "data-command": id,
7904
+ "aria-hidden": "true"
7905
+ });
7906
+ pending.disabled = true;
7907
+ return h("div", { class: "ink-command" }, pending, status);
7908
+ }
7909
+ const b = button(command.label, { "data-command": id }, command.icon);
7910
+ if (!command.icon) b.textContent = command.label;
7911
+ b.addEventListener("click", () => void command.run());
7912
+ const sync = () => {
7913
+ const busy = command.busy.get();
7914
+ b.disabled = !command.enabled.get() && !busy;
7915
+ b.setAttribute("aria-busy", String(busy));
7916
+ if (busy) b.setAttribute("aria-disabled", "true");
7917
+ else b.removeAttribute("aria-disabled");
7918
+ };
7919
+ command.enabled.subscribe(sync);
7920
+ command.busy.subscribe(sync);
7921
+ sync();
7922
+ command.status.subscribe((text) => {
7923
+ status.textContent = text;
7924
+ });
7925
+ status.textContent = command.status.get();
7926
+ return h("div", { class: "ink-command" }, b, status);
7927
+ }
6371
7928
  function createZoomIn(viewer) {
6372
7929
  const b = button("Zoom in", {
6373
7930
  class: "ink-zoom-in",
6374
7931
  "data-ink-zoom-in": ""
6375
7932
  }, ZoomIn);
6376
7933
  b.addEventListener("click", () => viewer.viewport.get()?.zoomBy(1.25));
6377
- follow(viewer.viewport, b, () => void 0);
7934
+ onViewerDestroy(viewer, follow(viewer.viewport, b, () => void 0));
6378
7935
  return b;
6379
7936
  }
6380
7937
  function createZoomOut(viewer) {
@@ -6383,7 +7940,7 @@ function createZoomOut(viewer) {
6383
7940
  "data-ink-zoom-out": ""
6384
7941
  }, ZoomOut);
6385
7942
  b.addEventListener("click", () => viewer.viewport.get()?.zoomBy(.8));
6386
- follow(viewer.viewport, b, () => void 0);
7943
+ onViewerDestroy(viewer, follow(viewer.viewport, b, () => void 0));
6387
7944
  return b;
6388
7945
  }
6389
7946
  /** Fit width, fit page and the zoom levels; a zoom between levels gets an option of its own. */
@@ -6548,6 +8105,167 @@ function rovingTabindex(bar) {
6548
8105
  };
6549
8106
  }
6550
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
6551
8269
  //#region src/dom/viewer/menu.ts
6552
8270
  const FOCUSABLE = "select:not(:disabled), input:not(:disabled), button:not(:disabled):not([tabindex=\"-1\"])";
6553
8271
  function createMenu(trigger, opts) {
@@ -8110,6 +9828,6 @@ function createSignatureMenu(viewer, opts = {}) {
8110
9828
  return root;
8111
9829
  }
8112
9830
  //#endregion
8113
- 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, createFocusReturn, createHighlightMenu, createIcon, createImageSignature, createLineStyleMenu, createMenu, createNextPage, createOverlay, createPageCount, createPageInput, createPageMount, createPageRenderer, createPageTransform, 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, 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, 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 };
8114
9832
 
8115
9833
  //# sourceMappingURL=dom.mjs.map