@inkbar/core 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/dom.mjs CHANGED
@@ -1,4 +1,4 @@
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";
1
+ import { $t as derived, B as isTextMarkup, Bt as withCalloutBounds, E as withoutReplies, G as resizedRect, Gt as isKnown, H as movedEndpoint, Ht as LINE_ENDINGS, I as hasCallout, J as withBox, Jt as cssVariableDefaults, K as scaled, L as hasCornerHandles, Lt as calloutStroke, O as createToolStyle, Qt as createRecordStore, R as hasRectGeometry, Rt as freeTextBox, S as isThreadRoot, T as threadRoot, U as movedTip, V as keepsAspect, W as resizedBox, Y as openDocument, Z as deriveRect, Zt as mergeLineRects, _ as defaultEnds, b as isAbandonedNote, bn as isInkError, d as recogniseStroke, dn as rectFromPoints, en as readonly, f as DEFAULT_CORNER_RADIUS, g as constrain, h as SHAPE_KINDS, i as createInkTemplate, in as invertMatrix, l as placeSignature, m as DEFAULT_SHAPE_SIZE, mn as rectsIntersect, nn as applyMatrix, o as createMemorySignatureStore, p as DEFAULT_SHAPE_OPTIONS, q as translated, r as createImageTemplate, s as createSignatureLibrary, tn as writable, tt as isProduction, v as isPointerOnlyKind, w as threadOf, y as shapeInput, yn as InkError, z as isBubble, zt as isStandardCallout } from "./signatures-D68fuLlt.mjs";
2
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";
3
3
  import { getStroke } from "perfect-freehand";
4
4
  //#region src/dom/transform.ts
@@ -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,8 +2820,1218 @@ function createOverlay(opts) {
2818
2820
  };
2819
2821
  }
2820
2822
  //#endregion
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
+ });
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
+ }
2863
+ });
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
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;
2910
+ return {
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
+ }
2921
+ },
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 });
2940
+ }
2941
+ };
2942
+ }
2943
+ //#endregion
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;
2956
+ }
2957
+ //#endregion
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);
2992
+ };
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
+ /**
3807
+ * `value` seen with `extra` laid over it, read-only. Everything else is read from and written to
3808
+ * `value` itself, and its methods are bound to it, so a method always runs on the original
3809
+ * (a class instance keeps its `#private` fields, and `this.id` is the plugin's own id).
3810
+ */
3811
+ function withProps(value, extra) {
3812
+ const bound = /* @__PURE__ */ new Map();
3813
+ const own = (key) => Object.hasOwn(extra, key);
3814
+ return new Proxy(Object.create(null), {
3815
+ get(_, key) {
3816
+ if (own(key)) return extra[key];
3817
+ const v = Reflect.get(value, key, value);
3818
+ if (typeof v !== "function") return v;
3819
+ let entry = bound.get(key);
3820
+ if (entry?.fn !== v) {
3821
+ entry = {
3822
+ fn: v,
3823
+ bound: v.bind(value)
3824
+ };
3825
+ bound.set(key, entry);
3826
+ }
3827
+ return entry.bound;
3828
+ },
3829
+ set: (_, key, v) => !own(key) && Reflect.set(value, key, v, value),
3830
+ defineProperty(target, key, d) {
3831
+ if (own(key) || !Reflect.defineProperty(value, key, d)) return false;
3832
+ const defined = Reflect.getOwnPropertyDescriptor(value, key);
3833
+ if (defined && !defined.configurable) Reflect.defineProperty(target, key, defined);
3834
+ return true;
3835
+ },
3836
+ deleteProperty: (_, key) => !own(key) && Reflect.deleteProperty(value, key),
3837
+ has: (_, key) => own(key) || Reflect.has(value, key),
3838
+ ownKeys: () => [.../* @__PURE__ */ new Set([...Reflect.ownKeys(value), ...Reflect.ownKeys(extra)])],
3839
+ getOwnPropertyDescriptor(target, key) {
3840
+ const d = Reflect.getOwnPropertyDescriptor(own(key) ? extra : value, key);
3841
+ const fixed = Reflect.getOwnPropertyDescriptor(target, key)?.configurable === false;
3842
+ return d && {
3843
+ ...d,
3844
+ configurable: !fixed
3845
+ };
3846
+ },
3847
+ getPrototypeOf: () => Reflect.getPrototypeOf(value)
3848
+ });
3849
+ }
3850
+ /** Runs a plugin callback; a throw is logged so one plugin cannot break the others. */
3851
+ function guard(fn) {
3852
+ try {
3853
+ return fn();
3854
+ } catch (error) {
3855
+ console.error(error);
3856
+ return;
3857
+ }
3858
+ }
3859
+ /**
3860
+ * @internal For bindings and `createViewer`, which place a viewer's plugins; plugin authors use
3861
+ * `definePlugin` and never call it.
3862
+ */
3863
+ function createPluginHost(opts) {
3864
+ const seen = /* @__PURE__ */ new Set();
3865
+ for (const plugin of opts.plugins) {
3866
+ if (seen.has(plugin.id)) throw new InkError("DuplicatePlugin", `A plugin with id ${plugin.id} is registered twice`, { id: plugin.id });
3867
+ seen.add(plugin.id);
3868
+ }
3869
+ const registry = createCommandRegistry({ document: opts.document });
3870
+ const tools = [];
3871
+ const decorators = [];
3872
+ const layers = createLayerRegistry();
3873
+ const requests = [];
3874
+ const documentFns = [];
3875
+ const editFns = [];
3876
+ const setupCleanups = [];
3877
+ const redecorators = /* @__PURE__ */ new Set();
3878
+ let opened = null;
3879
+ let destroyed = false;
3880
+ /** Creates a requested store for the open document and has its history track it. */
3881
+ const openStore = (request, state, history) => {
3882
+ const store = createRecordStore(request.key, {
3883
+ initial: request.opts.initial?.(state.doc) ?? [],
3884
+ ...request.opts.onChange && { onChange: request.opts.onChange }
3885
+ });
3886
+ state.stores.push(store);
3887
+ state.detach.push(history.track(store));
3888
+ request.current.set(store);
3889
+ };
3890
+ const runOnDocument = (fn, state) => {
3891
+ const cleanup = guard(() => fn(state.doc));
3892
+ if (typeof cleanup === "function") state.cleanups.push(cleanup);
3893
+ };
3894
+ const open = (doc) => {
3895
+ const history = opts.history();
3896
+ const state = {
3897
+ doc,
3898
+ stores: [],
3899
+ detach: [],
3900
+ cleanups: []
3901
+ };
3902
+ opened = state;
3903
+ for (const request of requests) openStore(request, state, history);
3904
+ for (const fn of editFns) state.detach.push(history.react(fn));
3905
+ for (const fn of documentFns) runOnDocument(fn, state);
3906
+ };
3907
+ const close = () => {
3908
+ const state = opened;
3909
+ if (!state) return;
3910
+ opened = null;
3911
+ for (const detach of state.detach) detach();
3912
+ for (const store of state.stores) store.destroy();
3913
+ for (const request of requests) request.current.set(null);
3914
+ for (const cleanup of state.cleanups.reverse()) guard(cleanup);
3915
+ };
3916
+ const contextFor = (plugin) => {
3917
+ const storeNames = /* @__PURE__ */ new Set();
3918
+ return {
3919
+ id: plugin.id,
3920
+ document: opts.document,
3921
+ history: { batch: (fn) => opts.history().batch(fn) },
3922
+ activated: opts.activated,
3923
+ setActivated: (id) => opts.setActivated(id),
3924
+ root: opts.root,
3925
+ commands: { add: (spec) => registry.add(plugin.id, spec) },
3926
+ tools: { add(tool) {
3927
+ const id = `${plugin.id}.${tool.id}`;
3928
+ if (tools.some((t) => t.id === id)) throw new InkError("DuplicateId", `A tool with id ${id} already exists`, { id });
3929
+ tools.push(withProps(tool, { id }));
3930
+ } },
3931
+ decorate(fn) {
3932
+ decorators.push(fn);
3933
+ },
3934
+ redecorate() {
3935
+ for (const listener of [...redecorators]) listener();
3936
+ },
3937
+ store(name, storeOpts = {}) {
3938
+ const key = `${plugin.id}.${name}`;
3939
+ if (storeNames.has(name)) throw new InkError("DuplicateId", `A store with key ${key} already exists`, { key });
3940
+ storeNames.add(name);
3941
+ const request = {
3942
+ key,
3943
+ opts: storeOpts,
3944
+ current: writable(null)
3945
+ };
3946
+ requests.push(request);
3947
+ if (opened) openStore(request, opened, opts.history());
3948
+ return readonly(request.current);
3949
+ },
3950
+ onDocument(fn) {
3951
+ documentFns.push(fn);
3952
+ if (opened) runOnDocument(fn, opened);
3953
+ },
3954
+ onAnnotationEdit(fn) {
3955
+ editFns.push(fn);
3956
+ if (opened) opened.detach.push(opts.history().react(fn));
3957
+ },
3958
+ layer(spec) {
3959
+ return layers.add(withProps(spec, { plugin: plugin.id }));
3960
+ },
3961
+ scrollTo(page, rect, scrollOpts) {
3962
+ opts.viewport?.()?.scrollToRect(page, rect, scrollOpts);
3963
+ }
3964
+ };
3965
+ };
3966
+ for (const plugin of opts.plugins) {
3967
+ let cleanup;
3968
+ try {
3969
+ cleanup = plugin.setup(contextFor(plugin));
3970
+ } catch (error) {
3971
+ for (const done of setupCleanups.reverse()) guard(done);
3972
+ throw error;
3973
+ }
3974
+ if (typeof cleanup === "function") setupCleanups.push(cleanup);
3975
+ }
3976
+ const current = opts.document.get();
3977
+ if (current) open(current);
3978
+ const stopDocument = opts.document.subscribe((doc) => {
3979
+ close();
3980
+ if (doc) open(doc);
3981
+ });
3982
+ return {
3983
+ commands: registry.commands,
3984
+ command: (id) => registry.commands.get(id),
3985
+ tools,
3986
+ decorate(annotation) {
3987
+ const all = [opts.decorate?.(), ...decorators];
3988
+ let merged;
3989
+ for (const fn of all) {
3990
+ const d = fn && guard(() => fn(annotation));
3991
+ if (!d) continue;
3992
+ merged ??= {};
3993
+ if (d.data) merged = {
3994
+ ...merged,
3995
+ data: {
3996
+ ...d.data,
3997
+ ...merged.data
3998
+ }
3999
+ };
4000
+ if (d.classes) merged = {
4001
+ ...merged,
4002
+ classes: [...merged.classes ?? [], ...d.classes]
4003
+ };
4004
+ if (merged.description === void 0 && d.description !== void 0) merged = {
4005
+ ...merged,
4006
+ description: d.description
4007
+ };
4008
+ }
4009
+ return merged;
4010
+ },
4011
+ layers: layers.layers,
4012
+ watchLayers: (listener) => layers.watchLayers(listener),
4013
+ watchRedecorate(listener) {
4014
+ const run = () => listener();
4015
+ redecorators.add(run);
4016
+ return () => {
4017
+ redecorators.delete(run);
4018
+ };
4019
+ },
4020
+ destroy() {
4021
+ if (destroyed) return;
4022
+ destroyed = true;
4023
+ stopDocument();
4024
+ close();
4025
+ for (const cleanup of setupCleanups.reverse()) guard(cleanup);
4026
+ }
4027
+ };
4028
+ }
4029
+ //#endregion
2821
4030
  //#region src/dom/page-mount.ts
2822
- /** Canvas, text layer and annotation overlay for one page, stacked in that order. */
4031
+ /**
4032
+ * Canvas, text layer, annotation overlay and any plugin layers for one page, stacked in that
4033
+ * order.
4034
+ */
2823
4035
  function createPageMount(opts) {
2824
4036
  const { document, page, host, transform } = opts;
2825
4037
  const callbacks = (layer) => ({
@@ -2852,6 +4064,7 @@ function createPageMount(opts) {
2852
4064
  ...opts.isCustom ? { isCustom: (a) => opts.isCustom?.(a) === true } : {},
2853
4065
  ...opts.decorate ? { decorate: opts.decorate } : {}
2854
4066
  });
4067
+ const layers = opts.layers?.mount(page, host, transform);
2855
4068
  return {
2856
4069
  renderer,
2857
4070
  textLayer,
@@ -2860,8 +4073,10 @@ function createPageMount(opts) {
2860
4073
  renderer.update(next);
2861
4074
  textLayer.update(next);
2862
4075
  overlay.update(next);
4076
+ layers?.update(next);
2863
4077
  },
2864
4078
  destroy() {
4079
+ layers?.destroy();
2865
4080
  overlay.destroy();
2866
4081
  textLayer.destroy();
2867
4082
  renderer.destroy();
@@ -3611,6 +4826,8 @@ function onScrollBar(e, root) {
3611
4826
  function inCustomContent(target) {
3612
4827
  if (!(target instanceof Element)) return false;
3613
4828
  if (target.closest("[data-ink-editor]")) return true;
4829
+ const layer = target.closest(".ink-plugin-layer");
4830
+ if (layer && layer !== target) return true;
3614
4831
  const el = target.closest("[data-annotation]");
3615
4832
  return el !== null && el !== target && el.hasAttribute("data-custom") && target.closest("[data-handle]") === null;
3616
4833
  }
@@ -5381,7 +6598,7 @@ function handleTabKeys(e, tablist) {
5381
6598
  tabs[next].focus({ preventScroll: true });
5382
6599
  tabs[next].click();
5383
6600
  }
5384
- const TABBABLE$1 = [
6601
+ const TABBABLE = [
5385
6602
  "a[href]",
5386
6603
  "button:not(:disabled)",
5387
6604
  "input:not(:disabled):not([type=\"hidden\"])",
@@ -5395,7 +6612,7 @@ const TABBABLE$1 = [
5395
6612
  */
5396
6613
  function trapTab(e, container) {
5397
6614
  if (e.key !== "Tab" || e.defaultPrevented) return;
5398
- const stops = [...container.querySelectorAll(TABBABLE$1)].filter((el) => !el.matches(":disabled") && el.getClientRects().length > 0);
6615
+ const stops = [...container.querySelectorAll(TABBABLE)].filter((el) => !el.matches(":disabled") && el.getClientRects().length > 0);
5399
6616
  if (stops.length === 0) {
5400
6617
  e.preventDefault();
5401
6618
  return;
@@ -5412,239 +6629,6 @@ function trapTab(e, container) {
5412
6629
  }
5413
6630
  }
5414
6631
  //#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;
5587
- 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;
5603
- e.preventDefault();
5604
- (e.shiftKey ? last : first).focus();
5605
- };
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
- 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
- });
5634
- },
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
- }
5645
- };
5646
- }
5647
- //#endregion
5648
6632
  //#region src/dom/viewer-engine.ts
5649
6633
  function mountViewer(opts) {
5650
6634
  const { container: el, document: doc } = opts;
@@ -5657,7 +6641,15 @@ function mountViewer(opts) {
5657
6641
  let stopKeys = () => void 0;
5658
6642
  let stopActive = () => void 0;
5659
6643
  let destroyed = false;
5660
- const hostTools = opts.tools ?? [];
6644
+ const plugins = opts.plugins;
6645
+ const hostTools = [...opts.tools ?? [], ...plugins?.tools ?? []];
6646
+ /** Plugin layers follow the host for the engine's life, across rebuilds. */
6647
+ const layers = plugins && createPluginLayers(plugins);
6648
+ const decorate = plugins ? (a) => plugins.decorate(a) : (a) => opts.decorateAnnotation?.(a);
6649
+ /** A plugin's `ctx.redecorate()` reaches every page this engine has mounted. */
6650
+ const stopRedecorate = plugins?.watchRedecorate(() => {
6651
+ for (const mount of pages.values()) mount.overlay.redecorate();
6652
+ });
5661
6653
  /** Option values outlive rebuilds: every controller shares this registry. */
5662
6654
  const toolOptions = opts.toolOptions ?? createToolOptions();
5663
6655
  /** Without a host style, the engine's own follows the active tool across rebuilds. */
@@ -5686,7 +6678,8 @@ function mountViewer(opts) {
5686
6678
  store: doc.annotations,
5687
6679
  onActivate: (id) => opts.onActivate?.(id),
5688
6680
  isCustom: () => isCustom,
5689
- decorate: (a) => opts.decorateAnnotation?.(a),
6681
+ decorate,
6682
+ ...layers ? { layers } : {},
5690
6683
  onError: (cause, layer) => {
5691
6684
  failed.add(layer);
5692
6685
  if (error) return;
@@ -5871,6 +6864,8 @@ function mountViewer(opts) {
5871
6864
  if (destroyed) return;
5872
6865
  destroyed = true;
5873
6866
  teardown();
6867
+ layers?.destroy();
6868
+ stopRedecorate?.();
5874
6869
  }
5875
6870
  };
5876
6871
  }
@@ -6043,8 +7038,14 @@ function createThreadForm(n, actions) {
6043
7038
  }
6044
7039
  //#endregion
6045
7040
  //#region src/dom/viewer/create-viewer.ts
7041
+ const noop = () => void 0;
7042
+ const passThrough = {
7043
+ batch: (fn) => fn(),
7044
+ track: () => noop,
7045
+ react: () => noop
7046
+ };
6046
7047
  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;
7048
+ const { source: initial, assets, password, defaults, history, plugins, threads: withThreads = true, onReady, onError, onActivate, onBuild, onPageMount, onPageUnmount, onPageError, toolStyle: _toolStyle, toolOptions: _toolOptions, signatureLibrary: _signatureLibrary, signatures: signatureStore, ...view } = opts;
6048
7049
  /** One library for the viewer's life, so templates and the menu outlive each document. */
6049
7050
  const signatures = createSignatureLibrary(signatureStore ?? createMemorySignatureStore());
6050
7051
  /** Tool options persist for the viewer's life; the tool style is per document. */
@@ -6056,6 +7057,7 @@ function createViewer(el, opts) {
6056
7057
  const tools = writable(null);
6057
7058
  /** The activated annotation: the note thread opens from it. */
6058
7059
  const activated = writable(null);
7060
+ const rootEl = writable(null);
6059
7061
  const root = h("div", {
6060
7062
  "data-ink-root": "",
6061
7063
  "data-state": "idle"
@@ -6074,6 +7076,24 @@ function createViewer(el, opts) {
6074
7076
  }, scroll);
6075
7077
  root.append(viewportRoot);
6076
7078
  el.append(root);
7079
+ rootEl.set(scroll);
7080
+ /**
7081
+ * One host for the viewer's life. It reads the history when a document opens, and `current`
7082
+ * is set before it notifies, so that is the new document's. With no document open, a
7083
+ * command's `batch` just runs its function.
7084
+ */
7085
+ const host = createPluginHost({
7086
+ plugins: plugins ?? [],
7087
+ document: readonly(current),
7088
+ history: () => current.get()?.history ?? passThrough,
7089
+ activated: readonly(activated),
7090
+ setActivated: (id) => {
7091
+ if (id === null || current.get()?.annotations.get(id)) activated.set(id);
7092
+ },
7093
+ root: readonly(rootEl),
7094
+ decorate: () => view.decorateAnnotation,
7095
+ viewport: () => viewport.get()
7096
+ });
6077
7097
  const focusReturn = createFocusReturn({
6078
7098
  root: () => root,
6079
7099
  activated: () => activated.get()
@@ -6123,6 +7143,7 @@ function createViewer(el, opts) {
6123
7143
  };
6124
7144
  const mounted = mountViewer({
6125
7145
  ...view,
7146
+ plugins: host,
6126
7147
  container: scroll,
6127
7148
  document: doc,
6128
7149
  toolOptions,
@@ -6219,7 +7240,7 @@ function createViewer(el, opts) {
6219
7240
  });
6220
7241
  }
6221
7242
  open(initial);
6222
- return {
7243
+ const viewer = {
6223
7244
  element: root,
6224
7245
  status: readonly(status),
6225
7246
  document: readonly(current),
@@ -6227,18 +7248,33 @@ function createViewer(el, opts) {
6227
7248
  viewport: readonly(viewport),
6228
7249
  tools: readonly(tools),
6229
7250
  activated: readonly(activated),
6230
- setActivated(id) {
7251
+ setActivated: (id) => {
6231
7252
  if (id === null || current.get()?.annotations.get(id)) activated.set(id);
6232
7253
  },
7254
+ commands: { get: (id) => host.command(id) },
6233
7255
  open,
6234
7256
  destroy() {
6235
7257
  if (destroyed) return;
6236
7258
  destroyed = true;
6237
7259
  loads++;
6238
7260
  close();
7261
+ host.destroy();
6239
7262
  root.remove();
6240
7263
  }
6241
7264
  };
7265
+ toolsByViewer.set(viewer, [...view.tools ?? [], ...host.tools]);
7266
+ commandIdsByViewer.set(viewer, () => [...host.commands.keys()]);
7267
+ return viewer;
7268
+ }
7269
+ const toolsByViewer = /* @__PURE__ */ new WeakMap();
7270
+ /** The host tools a viewer was given (its own and its plugins'), for the controls' labels and icons. */
7271
+ function hostToolsOf(viewer) {
7272
+ return toolsByViewer.get(viewer) ?? [];
7273
+ }
7274
+ const commandIdsByViewer = /* @__PURE__ */ new WeakMap();
7275
+ /** The ids of the commands a viewer's plugins registered, for the error on an unknown one. */
7276
+ function hostCommandIdsOf(viewer) {
7277
+ return commandIdsByViewer.get(viewer)?.() ?? [];
6242
7278
  }
6243
7279
  //#endregion
6244
7280
  //#region src/dom/viewer/controls.ts
@@ -6350,15 +7386,16 @@ function historyButton(viewer, action) {
6350
7386
  return b;
6351
7387
  }
6352
7388
  /**
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.
7389
+ * Sets the tool; pressed while it is the active one. A host or plugin tool's button takes the
7390
+ * tool's own `label` and `icon`; `opts.label` wins, and without either it is named by the tool id.
6355
7391
  */
6356
7392
  function createToolButton(viewer, tool, opts = {}) {
6357
- const b = button(opts.label ?? TOOL_LABELS[tool] ?? tool, {
7393
+ const own = hostToolsOf(viewer).find((t) => t.id === tool);
7394
+ const b = button(opts.label ?? TOOL_LABELS[tool] ?? own?.label ?? tool, {
6358
7395
  class: "ink-tool-button",
6359
7396
  "data-ink-tool-button": "",
6360
7397
  "data-tool": tool
6361
- }, TOOL_ICONS[tool]);
7398
+ }, TOOL_ICONS[tool] ?? own?.icon);
6362
7399
  const show = (active) => {
6363
7400
  const on = active === tool;
6364
7401
  b.dataset.state = on ? "on" : "off";
@@ -6368,6 +7405,48 @@ function createToolButton(viewer, tool, opts = {}) {
6368
7405
  follow(viewer.tools, b, (tools, watch) => watch(tools.active, show), () => show(null));
6369
7406
  return b;
6370
7407
  }
7408
+ /**
7409
+ * A plugin command as a button with its status text beside it. The button is named by the
7410
+ * command's label and disabled while the command is, except while it runs: then it keeps focus,
7411
+ * 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
7412
+ * plugin registered it logs `InkError('UnknownCommand')` to the console and returns the same
7413
+ * markup disabled, `aria-hidden` and unnamed.
7414
+ */
7415
+ function createCommandButton(viewer, id) {
7416
+ const command = viewer.commands.get(id);
7417
+ const status = h("span", {
7418
+ class: "ink-command-status",
7419
+ role: "status"
7420
+ });
7421
+ if (!command) {
7422
+ console.error(unknownCommand(id, hostCommandIdsOf(viewer)));
7423
+ const pending = h("button", {
7424
+ type: "button",
7425
+ "data-command": id,
7426
+ "aria-hidden": "true"
7427
+ });
7428
+ pending.disabled = true;
7429
+ return h("div", { class: "ink-command" }, pending, status);
7430
+ }
7431
+ const b = button(command.label, { "data-command": id }, command.icon);
7432
+ if (!command.icon) b.textContent = command.label;
7433
+ b.addEventListener("click", () => void command.run());
7434
+ const sync = () => {
7435
+ const busy = command.busy.get();
7436
+ b.disabled = !command.enabled.get() && !busy;
7437
+ b.setAttribute("aria-busy", String(busy));
7438
+ if (busy) b.setAttribute("aria-disabled", "true");
7439
+ else b.removeAttribute("aria-disabled");
7440
+ };
7441
+ command.enabled.subscribe(sync);
7442
+ command.busy.subscribe(sync);
7443
+ sync();
7444
+ command.status.subscribe((text) => {
7445
+ status.textContent = text;
7446
+ });
7447
+ status.textContent = command.status.get();
7448
+ return h("div", { class: "ink-command" }, b, status);
7449
+ }
6371
7450
  function createZoomIn(viewer) {
6372
7451
  const b = button("Zoom in", {
6373
7452
  class: "ink-zoom-in",
@@ -8110,6 +9189,6 @@ function createSignatureMenu(viewer, opts = {}) {
8110
9189
  return root;
8111
9190
  }
8112
9191
  //#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 };
9192
+ export { ARROWHEAD_CHOICES, ARROWHEAD_ENDING, COLOR_COLUMNS, COLOR_LABELS, DASH_PATTERN, DEFAULT_CORNER_RADIUS, DEFAULT_FREETEXT_WIDTH, DEFAULT_HIGHLIGHT_OPTIONS, DEFAULT_SHAPE_SIZE, FONT_FAMILIES, LINE_WEIGHTS, MARKUP_ICONS, MARKUP_KINDS, MARKUP_LABELS, PALETTE, SHAPE_COLUMNS, SHAPE_ICONS, SHAPE_KINDS, SHAPE_LABELS, SIGNATURE_FONTS, SIGNATURE_IMAGE_MAX_LENGTH, SIGNATURE_IMAGE_MAX_SIDE, SIGNATURE_IMAGE_TYPES, SIGNATURE_LABELS, SIGNATURE_TABS, TOOL_LABELS, ZOOM_LEVELS, annotationElementFor, arrowheadChoice, attachViewportKeys, builtinTools, clampFontSize, colorMenuModel, computeCssLayout, createColorMenu, createCommandButton, createFocusReturn, createHighlightMenu, createIcon, createImageSignature, createLineStyleMenu, createMenu, createNextPage, createOverlay, createPageCount, createPageInput, createPageMount, createPageRenderer, createPageTransform, createPluginHost, createPluginLayers, createPopover, createPrevPage, createRedo, createShapeMenu, createSignatureDialog, createSignatureDraft, createSignatureMenu, createSignaturePad, createSignaturePreview, createTextLayer, createTextStyleMenu, createToolButton, createToolController, createToolOptions, createToolbar, createToolbarSeparator, createTypedSignature, createUndo, createViewer, createViewport, createZoomIn, createZoomOut, createZoomSelect, cssRectsToUserRects, cssVariableDefaults, definePlugin, editFreeText, fitZoomCss, focusInitialMenuItem, fontFamilyValue, freetextTool, getSelectionQuads, handleMenuKeys, handleRadioKeys, handleTabKeys, highlightMenuModel, highlightTool, hitFor, lineStyleMenuModel, menuItems, mostVisiblePageCss, mountViewer, normalizeRotation, noteTool, observeAnnotationElement, placePanel, redactAreaTool, renderAnnotationContent, renderTypedSignature, selectTextTool, selectTool, shapeMenuModel, shapeTool, shouldKeepOpenOnAnnotation, signatureMenuModel, signaturePreview, signatureTool, textStyleMenuModel, threadDeleteLabel, threadPopoverLabel, trackToolStyle, trapTab, unknownCommand, visibleRangeCss, withHostTools };
8114
9193
 
8115
9194
  //# sourceMappingURL=dom.mjs.map