@inkbar/core 0.11.0 → 0.12.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 { $ as deriveRect, B as isBubble, Bt as freeTextBox, C as isThreadRoot, Cn as isInkError, D as withoutReplies, E as threadRoot, G as resizedBox, H as keepsAspect, Ht as withCalloutBounds, J as translated, K as resizedRect, L as hasCallout, Qt as isKnown, R as hasCornerHandles, Sn as InkError, T as threadOf, U as movedEndpoint, V as isTextMarkup, Vt as isStandardCallout, W as movedTip, Wt as cssVariableDefaults, X as openDocument, Y as withBox, Yt as LINE_ENDINGS, Z as isBlankQuery, _ as constrain, _n as rectsIntersect, a as createInkTemplate, an as applyMatrix, b as shapeInput, c as createSignatureLibrary, en as mergeLineRects, f as recogniseStroke, g as SHAPE_KINDS, h as DEFAULT_SHAPE_SIZE, i as createImageTemplate, in as writable, k as createToolStyle, m as DEFAULT_SHAPE_OPTIONS, mn as rectFromPoints, nn as derived, p as DEFAULT_CORNER_RADIUS, q as scaled, qt as sameValue, rn as readonly, rt as isProduction, s as createMemorySignatureStore, sn as invertMatrix, t as signField, tn as createRecordStore, u as placeSignature, v as defaultEnds, x as isAbandonedNote, y as isPointerOnlyKind, z as hasRectGeometry, zt as calloutStroke } from "./sign-DZVDJhqT.mjs";
2
- import { ALargeSmall, AlignCenter, AlignJustify, AlignLeft, AlignRight, Bold, CaseSensitive, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Circle, CircleDashed, Download, Hexagon, Highlighter, ICON_ATTRIBUTES, Italic, MessageCircle, MessageSquare, MessageSquareText, Minus, MoveUpRight, PenLine, PenTool, Plus, Printer, RectangleHorizontal, Redo2, Search, Shapes, Signature, Slash, SpellCheck2, Square, SquareDashed, SquareDashedMousePointer, Star, Strikethrough, TextCursor, Trash2, Type, Underline, Undo2, X, ZoomIn, ZoomOut } from "@inkbar/icons";
1
+ import { $ as withBox, A as threadRoot, C as isPointerOnlyKind, Cn as rectsIntersect, D as isThreadRoot, G as isTextMarkup, Gt as calloutStroke, H as hasCornerHandles, J as movedTip, Jt as withCalloutBounds, K as keepsAspect, Kt as freeTextBox, N as createToolStyle, On as InkError, Q as translated, S as defaultEnds, T as isAbandonedNote, U as hasRectGeometry, V as hasCallout, W as isBubble, X as resizedRect, Y as resizedBox, Yt as cssVariableDefaults, Z as scaled, _ as DEFAULT_CORNER_RADIUS, a as createInkTemplate, an as mergeLineRects, b as SHAPE_KINDS, bn as rectFromPoints, c as createSignatureLibrary, cn as readonly, ct as sameValue, d as placeSignature, en as LINE_ENDINGS, et as openDocument, fn as invertMatrix, ft as importedId, g as recogniseStroke, hn as quadContainsPoint, i as createImageTemplate, j as withoutReplies, k as threadOf, kn as isInkError, l as createStorageSignatureStore, ln as writable, lt as safeUrl, m as scopeStorage, on as createRecordStore, p as createMemoryStorage, q as movedEndpoint, qt as isStandardCallout, rn as isKnown, rt as deriveRect, sn as derived, st as isProduction, t as signField, tt as isBlankQuery, un as applyMatrix, v as DEFAULT_SHAPE_OPTIONS, w as shapeInput, x as constrain, xn as rectFromQuad, y as DEFAULT_SHAPE_SIZE, yn as rectContainsPoint } from "./sign-BSP7H3b7.mjs";
2
+ import { ALargeSmall, AlignCenter, AlignJustify, AlignLeft, AlignRight, ArrowLeft, ArrowRight, Bold, CaseSensitive, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Circle, CircleDashed, Download, Hexagon, Highlighter, ICON_ATTRIBUTES, Italic, MessageCircle, MessageSquare, MessageSquareText, Minus, MoveUpRight, PenLine, PenTool, Plus, Printer, RectangleHorizontal, Redo2, Search, Shapes, 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. */
@@ -585,6 +585,7 @@ function createViewport(opts) {
585
585
  const rotation = writable(opts.rotation ?? 0);
586
586
  const mode = writable(opts.mode ?? "continuous");
587
587
  const currentPage = writable(0);
588
+ const readingLine = writable(0);
588
589
  const mounted = writable([]);
589
590
  const layout = writable({
590
591
  boxes: [],
@@ -703,6 +704,9 @@ function createViewport(opts) {
703
704
  if (destroyed) return;
704
705
  const l = layout.get();
705
706
  const top = container.scrollTop - origin().y;
707
+ const end = container.scrollHeight - container.clientHeight;
708
+ const atEnd = end > 0 && container.scrollTop >= end - 1;
709
+ readingLine.set(top + (atEnd ? container.clientHeight : container.clientHeight / 3));
706
710
  const wanted = visibleRangeCss(l, top, container.clientHeight, buffer);
707
711
  const wantedSet = new Set(wanted);
708
712
  for (const [page, host] of [...hosts]) {
@@ -725,6 +729,70 @@ function createViewport(opts) {
725
729
  if (!sameList$1(mounted.get(), wanted)) mounted.set(wanted);
726
730
  if (mode.get() === "continuous") currentPage.set(mostVisiblePageCss(l, top, container.clientHeight));
727
731
  }
732
+ /**
733
+ * Absolute container scroll positions that frame `dest`, or `undefined` when its page has no
734
+ * box. No `left` means "leave the horizontal scroll where it is". Shared by
735
+ * `scrollToDestination` and `destinationY` so they cannot disagree.
736
+ */
737
+ function destinationScroll(dest) {
738
+ const box = boxFor(dest.page);
739
+ if (!box) return void 0;
740
+ const t = transformFor(dest.page);
741
+ const at = origin();
742
+ const pageTop = mode.get() === "single" ? 0 : Math.max(0, at.y + box.top - gap);
743
+ const absTop = (css) => Math.max(0, at.y + box.top + css);
744
+ const absLeft = (css) => Math.max(0, at.x + box.left + css);
745
+ const known = (x, y) => {
746
+ const o = t.toCss({
747
+ x: 0,
748
+ y: 0
749
+ });
750
+ const dx = t.toCss({
751
+ x: 1,
752
+ y: 0
753
+ });
754
+ const dy = t.toCss({
755
+ x: 0,
756
+ y: 1
757
+ });
758
+ const p = t.toCss({
759
+ x: x ?? 0,
760
+ y: y ?? 0
761
+ });
762
+ const r = {};
763
+ if ((x !== void 0 || dx.y === o.y) && (y !== void 0 || dy.y === o.y)) r.top = p.y;
764
+ if ((x !== void 0 || dx.x === o.x) && (y !== void 0 || dy.x === o.x)) r.left = p.x;
765
+ return r;
766
+ };
767
+ switch (dest.fit) {
768
+ case "xyz": {
769
+ const c = known(dest.left, dest.top);
770
+ const r = { top: c.top === void 0 ? pageTop : absTop(c.top) };
771
+ if (c.left !== void 0) r.left = absLeft(c.left);
772
+ return r;
773
+ }
774
+ case "fith":
775
+ case "fitbh": {
776
+ const c = known(void 0, dest.top);
777
+ return { top: c.top === void 0 ? pageTop : absTop(c.top) };
778
+ }
779
+ case "fitv":
780
+ case "fitbv": {
781
+ const c = known(dest.left, void 0);
782
+ const r = { top: pageTop };
783
+ if (c.left !== void 0) r.left = absLeft(c.left);
784
+ return r;
785
+ }
786
+ case "fitr": {
787
+ const css = t.toCssRect(dest.rect);
788
+ return {
789
+ top: Math.max(0, at.y + box.top + css.top - (container.clientHeight - css.height) / 2),
790
+ left: Math.max(0, at.x + box.left + css.left - (container.clientWidth - css.width) / 2)
791
+ };
792
+ }
793
+ default: return { top: pageTop };
794
+ }
795
+ }
728
796
  let scrollFrame = 0;
729
797
  const onScroll = () => {
730
798
  if (scrollFrame) return;
@@ -954,6 +1022,27 @@ function createViewport(opts) {
954
1022
  if (to.top !== void 0 || to.left !== void 0) container.scrollTo(to);
955
1023
  sync();
956
1024
  },
1025
+ scrollToDestination(dest, o) {
1026
+ if (!Number.isInteger(dest.page) || dest.page < 0 || dest.page >= doc.pages.length) return;
1027
+ if (mode.get() === "single" && currentPage.get() !== dest.page) {
1028
+ currentPage.set(dest.page);
1029
+ relayout();
1030
+ }
1031
+ const to = destinationScroll(dest);
1032
+ if (!to) return;
1033
+ const scroll = {
1034
+ top: to.top,
1035
+ behavior: o?.behavior ?? "auto"
1036
+ };
1037
+ if (to.left !== void 0) scroll.left = to.left;
1038
+ container.scrollTo(scroll);
1039
+ sync();
1040
+ },
1041
+ destinationY(dest) {
1042
+ const to = destinationScroll(dest);
1043
+ return to ? to.top - origin().y : void 0;
1044
+ },
1045
+ readingLine: readonly(readingLine),
957
1046
  transformFor,
958
1047
  hostFor: (page) => hosts.get(page),
959
1048
  pageAtCss,
@@ -1145,9 +1234,9 @@ function attachHistoryKeys(el, history) {
1145
1234
  return () => el.removeEventListener("keydown", onKeydown);
1146
1235
  }
1147
1236
  /**
1148
- * Keeps focus in the viewer when a focused Undo or Redo button inside `el` turns disabled
1149
- * (it undid or redid the last step), which would otherwise drop focus to `<body>`, out of
1150
- * reach of the undo and redo keys. Focus moves to `scroll()`, the viewer's scroll element,
1237
+ * Keeps focus in the viewer when a focused Undo, Redo, Back or Forward button inside `el` turns
1238
+ * disabled (it undid, redid or went back over the last step), which would otherwise drop focus
1239
+ * to `<body>`, out of reach of the viewer's keys. Focus moves to `scroll()`, the viewer's scroll element,
1151
1240
  * without scrolling. Returns a function that removes the listeners.
1152
1241
  *
1153
1242
  * Chrome and Firefox blur the button as it is disabled: its `focusout` comes while the
@@ -1156,7 +1245,7 @@ function attachHistoryKeys(el, history) {
1156
1245
  * `focusout` (a click on content that takes no focus) is left alone.
1157
1246
  */
1158
1247
  function keepHistoryFocus(el, scroll) {
1159
- const isHistoryButton = (node) => node instanceof HTMLButtonElement && node.disabled && node.matches("[data-ink-undo], [data-ink-redo]");
1248
+ const isHistoryButton = (node) => node instanceof HTMLButtonElement && node.disabled && node.matches("[data-ink-undo], [data-ink-redo], [data-ink-back], [data-ink-forward]");
1160
1249
  const toScroll = () => {
1161
1250
  const target = scroll();
1162
1251
  if (!target?.isConnected) return;
@@ -4171,6 +4260,7 @@ function createPluginHost(opts) {
4171
4260
  if (seen.has(plugin.id)) throw new InkError("DuplicatePlugin", `A plugin with id ${plugin.id} is registered twice`, { id: plugin.id });
4172
4261
  seen.add(plugin.id);
4173
4262
  }
4263
+ const storage = opts.storage ?? createMemoryStorage();
4174
4264
  const registry = createCommandRegistry({ document: opts.document });
4175
4265
  const tools = [];
4176
4266
  const decorators = [];
@@ -4290,6 +4380,7 @@ function createPluginHost(opts) {
4290
4380
  opts.viewport?.()?.scrollToRect(page, rect, scrollOpts);
4291
4381
  },
4292
4382
  search: opts.search ?? inertSearch,
4383
+ storage: scopeStorage(storage, `plugin.${plugin.id}`),
4293
4384
  onPress: listenForPress("press"),
4294
4385
  onLongPress: listenForPress("long-press")
4295
4386
  };
@@ -6157,8 +6248,18 @@ function createToolController$1(opts) {
6157
6248
  let madeEdit;
6158
6249
  /** The press now under way went to `madeEdit`, not the tool. */
6159
6250
  let editingMade = false;
6251
+ /** The root's cursor is the pointer for a link under it. */
6252
+ let overLink = false;
6253
+ /** The link last reported to `onLinkHover`. */
6254
+ let hovered;
6255
+ /** The press now under way had a modifier key down, so it does not follow a link. */
6256
+ let pressModified = false;
6160
6257
  const presses = createPressTracker({
6161
- onPress: (press) => opts.onPress?.("press", press),
6258
+ onPress: (press) => {
6259
+ const link = !pressModified && opts.onLink && opts.linkAt?.(press.page, press.point);
6260
+ if (link) opts.onLink(link);
6261
+ else opts.onPress?.("press", press);
6262
+ },
6162
6263
  onLongPress: (press) => opts.onPress?.("long-press", press)
6163
6264
  });
6164
6265
  function clearMade() {
@@ -6207,6 +6308,8 @@ function createToolController$1(opts) {
6207
6308
  const selected = store.selection.get();
6208
6309
  if (!made.some((id) => selected.includes(id))) set("select-text");
6209
6310
  }
6311
+ /** True under the tools whose still presses are reported and may follow a link. */
6312
+ const selecting = () => current?.id === "select-text" || current?.id === "select";
6210
6313
  /** True for events from a pointer other than the one whose press is live. */
6211
6314
  const foreign = (e) => pressed !== void 0 && pressed !== e.pointerId;
6212
6315
  const readFirst = (e) => {
@@ -6233,7 +6336,7 @@ function createToolController$1(opts) {
6233
6336
  const target = e.target instanceof Element ? e.target : null;
6234
6337
  const annotationId = target?.closest("[data-annotation]")?.getAttribute("data-annotation") ?? void 0;
6235
6338
  const hit = hitFor(ctx, e);
6236
- const reportable = (current?.id === "select-text" || current?.id === "select") && annotationId === void 0 && target?.closest("a[href], [data-handle]") == null && !pressDismisses && !closedEditor(e) && hit !== void 0;
6339
+ const reportable = selecting() && !onAnnotation(target) && !pressDismisses && !closedEditor(e) && hit !== void 0;
6237
6340
  presses.cancel();
6238
6341
  if (current?.kind === "one-shot") {
6239
6342
  if (pressFoundMade && annotationId === void 0 && !onScrollBar(e, ctx.root)) {
@@ -6253,6 +6356,7 @@ function createToolController$1(opts) {
6253
6356
  }
6254
6357
  pressed = e.pointerId;
6255
6358
  if (current?.onPointerDown?.(e, hit, annotationId) === true) capture(e.pointerId);
6359
+ pressModified = e.shiftKey || e.ctrlKey || e.metaKey || e.altKey;
6256
6360
  if (reportable && pressed === e.pointerId) presses.down(e, {
6257
6361
  page: hit.page,
6258
6362
  point: hit.user,
@@ -6272,7 +6376,29 @@ function createToolController$1(opts) {
6272
6376
  return;
6273
6377
  }
6274
6378
  current?.onPointerMove?.(e, hitFor(ctx, e));
6275
- };
6379
+ if (pressed === void 0) hoverLink(e);
6380
+ };
6381
+ /** Shows the pointer cursor over a link a press would follow (see the top of the file). */
6382
+ function hoverLink(e) {
6383
+ const link = selecting() && !onAnnotation(e.target) ? linkUnder(e) : void 0;
6384
+ hover(link);
6385
+ const over = link !== void 0;
6386
+ if (over === overLink || !current) return;
6387
+ overLink = over;
6388
+ ctx.root.style.cursor = over ? "pointer" : current.cursor;
6389
+ }
6390
+ /** Reports `link` as hovered when it is not already. */
6391
+ function hover(link) {
6392
+ if (link?.id === hovered?.id && link?.page === hovered?.page) return;
6393
+ hovered = link;
6394
+ opts.onLinkHover?.(link);
6395
+ }
6396
+ const onLeave = () => hover(void 0);
6397
+ function linkUnder(e) {
6398
+ if (!opts.linkAt || inCustomContent(e.target)) return void 0;
6399
+ const hit = hitFor(ctx, e);
6400
+ return hit && opts.linkAt(hit.page, hit.user);
6401
+ }
6276
6402
  const onUp = (e) => {
6277
6403
  if (!e.isPrimary || e.button !== 0 || foreign(e)) return;
6278
6404
  if (pressed === e.pointerId) pressed = void 0;
@@ -6353,6 +6479,7 @@ function createToolController$1(opts) {
6353
6479
  doc.addEventListener("pointerdown", readAfterEditor, { capture: true });
6354
6480
  ctx.root.addEventListener("pointerdown", onDown, { capture: true });
6355
6481
  ctx.root.addEventListener("pointermove", onMove);
6482
+ ctx.root.addEventListener("pointerleave", onLeave);
6356
6483
  ctx.root.addEventListener("pointerup", onUp);
6357
6484
  ctx.root.addEventListener("pointercancel", onCancel);
6358
6485
  ctx.root.addEventListener("lostpointercapture", onCancel);
@@ -6383,6 +6510,8 @@ function createToolController$1(opts) {
6383
6510
  next.activate(ctx);
6384
6511
  ctx.root.dataset.inkTool = id;
6385
6512
  ctx.root.style.cursor = next.cursor;
6513
+ overLink = false;
6514
+ hover(void 0);
6386
6515
  applyClaim();
6387
6516
  active.set(id);
6388
6517
  }
@@ -6426,6 +6555,8 @@ function createToolController$1(opts) {
6426
6555
  doc.removeEventListener("pointerdown", readAfterEditor, { capture: true });
6427
6556
  ctx.root.removeEventListener("pointerdown", onDown, { capture: true });
6428
6557
  ctx.root.removeEventListener("pointermove", onMove);
6558
+ ctx.root.removeEventListener("pointerleave", onLeave);
6559
+ hover(void 0);
6429
6560
  ctx.root.removeEventListener("pointerup", onUp);
6430
6561
  ctx.root.removeEventListener("pointercancel", onCancel);
6431
6562
  ctx.root.removeEventListener("lostpointercapture", onCancel);
@@ -6452,6 +6583,11 @@ function isViewportEnter(e, root) {
6452
6583
  if (target.matches(".ink-page[data-page]")) return true;
6453
6584
  return target.closest("[data-ink-text-layer], [data-ink-canvas-layer]") !== null;
6454
6585
  }
6586
+ /** True for a target on an annotation, a resize handle or a host's own link. */
6587
+ function onAnnotation(target) {
6588
+ if (!(target instanceof Element)) return false;
6589
+ return target.closest("[data-annotation], a[href], [data-handle]") !== null;
6590
+ }
6455
6591
  function pointerTypeOf(e) {
6456
6592
  return e.pointerType === "touch" || e.pointerType === "pen" ? e.pointerType : "mouse";
6457
6593
  }
@@ -7623,44 +7759,55 @@ function annotsOrder(items, widget) {
7623
7759
  })).sort((a, b) => a.at - b.at || a.i - b.i).map(({ item }) => item);
7624
7760
  }
7625
7761
  /**
7626
- * One page's widget ids in Tab order: `R` reads rows top to bottom and each row left to right,
7627
- * `C` reads columns left to right and each column top to bottom, and `S` follows `structure`
7628
- * (the page's annotation ids in structure-tree order), with widgets the tree leaves out after
7629
- * the rest in `/Annots` order. `W`, `A` or no `/Tabs` keep `/Annots` order. Positions are taken
7630
- * as the page displays, after its `/Rotate`; without `info` the page is read unturned.
7762
+ * One page's stops in Tab order: `R` reads rows top to bottom and each row left to right, `C`
7763
+ * reads columns left to right and each column top to bottom, and `S` follows `structure` (the
7764
+ * page's stop ids in structure-tree order), with stops the tree leaves out after the rest in
7765
+ * `/Annots` order. `W` puts widgets first, each group in `/Annots` order; `A` or no `/Tabs`
7766
+ * keep `/Annots` order.
7631
7767
  */
7632
- function pageTabOrder(widgets, order, info, page = 0, structure = []) {
7633
- const listed = annotsOrder(widgets, (w) => w);
7634
- if (order === "S") {
7768
+ function orderStops(items, tabs, structure = []) {
7769
+ const listed = [...items].sort((a, b) => a.index - b.index);
7770
+ if (tabs === "S") {
7635
7771
  const at = new Map(structure.map((id, i) => [id, i]));
7636
- return listed.map((w, i) => ({
7637
- id: w.id,
7772
+ return listed.map((item, i) => ({
7773
+ id: item.id,
7638
7774
  i,
7639
- at: at.get(w.id) ?? Infinity
7640
- })).sort((a, b) => a.at - b.at || a.i - b.i).map((w) => w.id);
7775
+ at: at.get(item.id) ?? Infinity
7776
+ })).sort((a, b) => a.at - b.at || a.i - b.i).map((item) => item.id);
7641
7777
  }
7642
- if (order !== "R" && order !== "C") return listed.map((w) => w.id);
7778
+ if (tabs === "W") return [...listed.filter((i) => i.widget), ...listed.filter((i) => !i.widget)].map((i) => i.id);
7779
+ if (tabs !== "R" && tabs !== "C") return listed.map((i) => i.id);
7780
+ return (tabs === "R" ? banded(listed, (b) => b.top, (b) => b.height, (b) => b.left) : banded(listed, (b) => b.left, (b) => b.width, (b) => b.top)).map((p) => p.id);
7781
+ }
7782
+ /**
7783
+ * One page's widget and link ids in Tab order (`orderStops`), so fields and links share one
7784
+ * reading order. A link is placed by its first quad, the line it starts on. Positions are taken
7785
+ * as the page displays, after its `/Rotate`; without `info` the page is read unturned.
7786
+ * `structure` holds the page's annotation ids as PDF.js reports them, which link ids wrap.
7787
+ */
7788
+ function pageStopOrder(widgets, links, order, info, page = 0, structure = []) {
7643
7789
  const transform = createPageTransform(info ?? unturned(page), 1);
7644
- const placed = listed.map((w, index) => ({
7790
+ const listed = annotsOrder(widgets, (w) => w);
7791
+ const items = [...listed.map((w, i) => ({
7645
7792
  id: w.id,
7646
- index,
7647
- box: transform.toCssRect(w.rect)
7648
- }));
7649
- return (order === "R" ? banded(placed, (b) => b.top, (b) => b.height, (b) => b.left) : banded(placed, (b) => b.left, (b) => b.width, (b) => b.top)).map((p) => p.id);
7793
+ box: transform.toCssRect(w.rect),
7794
+ index: w.order ?? i,
7795
+ widget: true
7796
+ })), ...links.map((l, i) => ({
7797
+ id: l.id,
7798
+ box: transform.toCssRect(l.quads?.[0] ? rectFromQuad(l.quads[0]) : l.rect),
7799
+ index: l.order ?? listed.length + i
7800
+ }))];
7801
+ const prefix = importedId(page, "");
7802
+ const ids = new Set(links.map((l) => l.id));
7803
+ return orderStops(items, order, structure.map((id) => ids.has(prefix + id) ? prefix + id : id));
7650
7804
  }
7651
7805
  /**
7652
- * Widget ids in Tab order: pages in order, each by its `/Tabs` (`pageTabOrder`), with
7653
- * `structure` the structure order of each `S` page. A page missing from `pages` is read
7654
- * unturned.
7806
+ * One page's widget ids in Tab order (`orderStops`). Positions are taken as the page displays,
7807
+ * after its `/Rotate`; without `info` the page is read unturned.
7655
7808
  */
7656
- function tabOrder(fields, tabs, pages = [], structure = /* @__PURE__ */ new Map()) {
7657
- const byPage = /* @__PURE__ */ new Map();
7658
- for (const field of fields) for (const w of field.widgets) {
7659
- const list = byPage.get(w.page) ?? [];
7660
- list.push(w);
7661
- byPage.set(w.page, list);
7662
- }
7663
- return [...byPage.keys()].sort((a, b) => a - b).flatMap((page) => pageTabOrder(byPage.get(page), tabs.get(page), pages[page], page, structure.get(page)));
7809
+ function pageTabOrder(widgets, order, info, page = 0, structure = []) {
7810
+ return pageStopOrder(widgets, [], order, info, page, structure);
7664
7811
  }
7665
7812
  //#endregion
7666
7813
  //#region src/dom/palette.ts
@@ -8640,13 +8787,195 @@ function createFormLayer(opts) {
8640
8787
  return layer;
8641
8788
  }
8642
8789
  //#endregion
8790
+ //#region src/dom/link-layer.ts
8791
+ const UNSUPPORTED = "This link is not supported";
8792
+ /** The page a Named action goes to from the link's own page, as a reader on it would expect. */
8793
+ function namedPage$1(action, page, count) {
8794
+ const to = action === "NextPage" ? page + 1 : action === "PrevPage" ? page - 1 : action === "FirstPage" ? 0 : count - 1;
8795
+ return Math.min(Math.max(to, 0), count - 1);
8796
+ }
8797
+ /** Draws `page`'s links (`document.links(page)`) over the page in `host`. */
8798
+ function createLinkLayer(opts) {
8799
+ const { host, page, document: doc } = opts;
8800
+ const ownerDoc = host.ownerDocument;
8801
+ let transform = opts.transform;
8802
+ let destroyed = false;
8803
+ const element = ownerDoc.createElement("div");
8804
+ element.className = "ink-link-layer";
8805
+ element.dataset.inkLinkLayer = "";
8806
+ element.style.cssText = "position:absolute;inset:0;pointer-events:none;";
8807
+ host.append(element);
8808
+ const name = (e) => {
8809
+ if (e.text) return e.text;
8810
+ const target = e.link.target;
8811
+ if (target.kind === "url") return target.url;
8812
+ if (target.kind === "unsupported" || e.dead) return UNSUPPORTED;
8813
+ return e.to === void 0 ? "Go to link" : `Go to page ${e.to + 1}`;
8814
+ };
8815
+ const place = (el, rect) => {
8816
+ const box = transform.toCssRect(rect);
8817
+ el.style.left = `${box.left}px`;
8818
+ el.style.top = `${box.top}px`;
8819
+ el.style.width = `${box.width}px`;
8820
+ el.style.height = `${box.height}px`;
8821
+ };
8822
+ const follow = (e) => {
8823
+ if (!destroyed && !e.dead && e.link.target.kind !== "unsupported") opts.follow(e.link.target, { from: e.link.page });
8824
+ };
8825
+ /** Marks every piece of a wrapped link while its stop has a visible focus ring. */
8826
+ const markFocus = (e, on) => {
8827
+ for (const { el } of e.pieces) el.toggleAttribute("data-ink-focused", on);
8828
+ };
8829
+ /** Makes a link's stop inert: not followed, not a Tab stop, and titled with why. */
8830
+ const disable = (el) => {
8831
+ el.title = UNSUPPORTED;
8832
+ el.setAttribute("aria-disabled", "true");
8833
+ el.tabIndex = -1;
8834
+ };
8835
+ const readText = (e) => e.pieces.map((p) => opts.textUnder(p.rect).trim()).filter(Boolean).join(" ");
8836
+ const entries = doc.links(page).map((link) => {
8837
+ const target = link.target;
8838
+ const rects = link.quads?.length ? link.quads.map(rectFromQuad) : [link.rect];
8839
+ const e = {
8840
+ link,
8841
+ pieces: [],
8842
+ text: "",
8843
+ dead: false,
8844
+ to: target.kind === "resolved" ? target.dest.page : target.kind === "named" ? namedPage$1(target.action, page, doc.pages.length) : void 0
8845
+ };
8846
+ rects.forEach((rect, i) => {
8847
+ const stop = i === 0;
8848
+ let el;
8849
+ if (stop && target.kind === "url") {
8850
+ const a = ownerDoc.createElement("a");
8851
+ a.href = target.url;
8852
+ el = a;
8853
+ } else {
8854
+ el = ownerDoc.createElement("span");
8855
+ if (!stop) el.setAttribute("aria-hidden", "true");
8856
+ else {
8857
+ el.setAttribute("role", "link");
8858
+ if (target.kind === "unsupported") disable(el);
8859
+ else el.tabIndex = 0;
8860
+ }
8861
+ }
8862
+ el.className = "ink-link";
8863
+ el.dataset.inkLink = link.id;
8864
+ el.style.cssText = "position:absolute;pointer-events:none;";
8865
+ place(el, rect);
8866
+ element.append(el);
8867
+ e.pieces.push({
8868
+ el,
8869
+ rect
8870
+ });
8871
+ });
8872
+ const stop = e.pieces[0].el;
8873
+ stop.setAttribute("aria-label", name(e));
8874
+ stop.addEventListener("click", (ev) => {
8875
+ ev.preventDefault();
8876
+ follow(e);
8877
+ });
8878
+ if (stop.tagName !== "A") stop.addEventListener("keydown", (ev) => {
8879
+ if (ev.key !== "Enter" || ev.defaultPrevented) return;
8880
+ ev.preventDefault();
8881
+ follow(e);
8882
+ });
8883
+ stop.addEventListener("focus", () => markFocus(e, stop.matches(":focus-visible")));
8884
+ stop.addEventListener("blur", () => markFocus(e, false));
8885
+ return e;
8886
+ });
8887
+ const relabel = () => {
8888
+ if (destroyed) return;
8889
+ for (const e of entries) {
8890
+ e.text = readText(e);
8891
+ e.pieces[0].el.setAttribute("aria-label", name(e));
8892
+ }
8893
+ };
8894
+ relabel();
8895
+ for (const e of entries) {
8896
+ const target = e.link.target;
8897
+ if (target.kind !== "dest") continue;
8898
+ const stop = e.pieces[0].el;
8899
+ const settle = (page) => {
8900
+ if (destroyed) return;
8901
+ if (page === void 0) {
8902
+ e.dead = true;
8903
+ disable(stop);
8904
+ } else e.to = page;
8905
+ stop.setAttribute("aria-label", name(e));
8906
+ };
8907
+ doc.resolveDestination(target.raw).then((dest) => settle(dest?.page), () => settle(void 0));
8908
+ }
8909
+ return {
8910
+ element,
8911
+ update(next) {
8912
+ if (destroyed) return;
8913
+ transform = next;
8914
+ for (const e of entries) for (const p of e.pieces) place(p.el, p.rect);
8915
+ },
8916
+ tabStops() {
8917
+ if (destroyed) return [];
8918
+ return entries.flatMap((e) => {
8919
+ const stop = e.pieces[0].el;
8920
+ return stop.tabIndex >= 0 ? [stop] : [];
8921
+ });
8922
+ },
8923
+ relabel,
8924
+ destroy() {
8925
+ if (destroyed) return;
8926
+ destroyed = true;
8927
+ element.remove();
8928
+ }
8929
+ };
8930
+ }
8931
+ /** The text runs of `layer` (a text layer), measured once for any number of `textInBox` calls. */
8932
+ function textRuns(layer) {
8933
+ const walker = layer.ownerDocument.createTreeWalker(layer, NodeFilter.SHOW_TEXT);
8934
+ const runs = [];
8935
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
8936
+ const rect = node.parentElement?.getBoundingClientRect();
8937
+ if (rect) runs.push({
8938
+ node,
8939
+ rect
8940
+ });
8941
+ }
8942
+ return runs;
8943
+ }
8944
+ /**
8945
+ * The text of `runs` (from `textRuns`) whose characters sit in `box`, a client-coordinate box.
8946
+ * Runs that lie wholly inside count whole; runs that cross the edge count the characters whose
8947
+ * centre is inside, so a link over one word of a line is named by that word.
8948
+ */
8949
+ function textInBox(runs, box) {
8950
+ const inside = (x, y) => x >= box.left && x <= box.right && y >= box.top && y <= box.bottom;
8951
+ const parts = [];
8952
+ let range;
8953
+ for (const { node: text, rect: run } of runs) {
8954
+ if (run.right < box.left || run.left > box.right || run.bottom < box.top || run.top > box.bottom) continue;
8955
+ let out = "";
8956
+ if (run.left >= box.left && run.right <= box.right && run.top >= box.top && run.bottom <= box.bottom) out = text.data;
8957
+ else {
8958
+ range ??= text.ownerDocument.createRange();
8959
+ for (let i = 0; i < text.data.length; i++) {
8960
+ range.setStart(text, i);
8961
+ range.setEnd(text, i + 1);
8962
+ const c = range.getBoundingClientRect();
8963
+ if (inside(c.left + c.width / 2, c.top + c.height / 2)) out += text.data[i];
8964
+ }
8965
+ }
8966
+ if (out.trim()) parts.push(out.trim());
8967
+ }
8968
+ return parts.join(" ").replace(/\s+/g, " ");
8969
+ }
8970
+ //#endregion
8643
8971
  //#region src/dom/page-mount.ts
8644
8972
  /**
8645
- * Canvas, text layer, form layer, annotation overlay and any plugin layers for one page, stacked
8646
- * in that order.
8973
+ * Canvas, text layer, links, search highlights, form layer, annotation overlay and any plugin
8974
+ * layers for one page, stacked in that order.
8647
8975
  */
8648
8976
  function createPageMount(opts) {
8649
- const { document, page, host, transform } = opts;
8977
+ const { document, page, host } = opts;
8978
+ let transform = opts.transform;
8650
8979
  const callbacks = (layer) => ({
8651
8980
  ...opts.onError ? { onError: (error) => opts.onError?.(error, layer) } : {},
8652
8981
  ...opts.onRendered ? { onRendered: () => opts.onRendered?.(layer) } : {}
@@ -8659,13 +8988,48 @@ function createPageMount(opts) {
8659
8988
  ...opts.devicePixelRatio === void 0 ? {} : { devicePixelRatio: opts.devicePixelRatio },
8660
8989
  ...callbacks("canvas")
8661
8990
  });
8991
+ let named = false;
8992
+ const text = callbacks("text");
8662
8993
  const textLayer = createTextLayer({
8663
8994
  document,
8664
8995
  page,
8665
8996
  host,
8666
8997
  transform,
8667
- ...callbacks("text")
8998
+ ...text,
8999
+ onRendered: () => {
9000
+ if (!named) {
9001
+ named = true;
9002
+ links?.relabel();
9003
+ }
9004
+ text.onRendered?.();
9005
+ }
8668
9006
  });
9007
+ let measured;
9008
+ const links = document.links(page).length > 0 ? createLinkLayer({
9009
+ host,
9010
+ page,
9011
+ transform,
9012
+ document,
9013
+ follow: (target, o) => opts.followLink?.(target, o),
9014
+ textUnder: (rect) => {
9015
+ if (!measured) {
9016
+ measured = {
9017
+ runs: textRuns(textLayer.element),
9018
+ origin: host.getBoundingClientRect()
9019
+ };
9020
+ queueMicrotask(() => measured = void 0);
9021
+ }
9022
+ const box = transform.toCssRect(rect);
9023
+ const left = measured.origin.left + box.left;
9024
+ const top = measured.origin.top + box.top;
9025
+ return textInBox(measured.runs, {
9026
+ left,
9027
+ top,
9028
+ right: left + box.width,
9029
+ bottom: top + box.height
9030
+ });
9031
+ }
9032
+ }) : void 0;
8669
9033
  const marks = opts.highlights?.mount(page, host, transform);
8670
9034
  const form = document.form.fields.get().length > 0 ? createFormLayer({
8671
9035
  host,
@@ -8690,14 +9054,34 @@ function createPageMount(opts) {
8690
9054
  ...opts.decorate ? { decorate: opts.decorate } : {}
8691
9055
  });
8692
9056
  const layers = opts.layers?.mount(page, host, transform);
9057
+ /**
9058
+ * Each stop's place in the page's one order of fields and links, worked out on first use: the
9059
+ * two sit in separate layers, so the order comes from the page, not the DOM.
9060
+ */
9061
+ let ranks;
9062
+ const rankOf = (stop) => {
9063
+ ranks ??= new Map(pageStopOrder(document.form.fields.get().flatMap((f) => f.widgets.filter((w) => w.page === page)), document.links(page), document.form._tabs.get(page), document.pages[page], page, document.form._structure.get(page)).map((id, i) => [id, i]));
9064
+ const id = stop.dataset.inkLink ?? stop.closest("[data-ink-widget]")?.dataset.inkWidget;
9065
+ return (id === void 0 ? void 0 : ranks.get(id)) ?? Infinity;
9066
+ };
8693
9067
  return {
8694
9068
  renderer,
8695
9069
  textLayer,
8696
9070
  overlay,
8697
9071
  form,
9072
+ links,
9073
+ tabStops() {
9074
+ const fields = form?.tabStops() ?? [];
9075
+ const linked = links?.tabStops() ?? [];
9076
+ if (fields.length === 0) return linked;
9077
+ if (linked.length === 0) return fields;
9078
+ return [...fields, ...linked].sort((a, b) => rankOf(a) - rankOf(b));
9079
+ },
8698
9080
  update(next) {
9081
+ transform = next;
8699
9082
  renderer.update(next);
8700
9083
  textLayer.update(next);
9084
+ links?.update(next);
8701
9085
  overlay.update(next);
8702
9086
  form?.update(next);
8703
9087
  layers?.update(next);
@@ -8708,6 +9092,7 @@ function createPageMount(opts) {
8708
9092
  form?.destroy();
8709
9093
  overlay.destroy();
8710
9094
  marks?.destroy();
9095
+ links?.destroy();
8711
9096
  textLayer.destroy();
8712
9097
  renderer.destroy();
8713
9098
  }
@@ -9851,6 +10236,122 @@ function trapTab(e, container) {
9851
10236
  }
9852
10237
  }
9853
10238
  //#endregion
10239
+ //#region src/dom/navigation.ts
10240
+ /** The positions kept on each side; past this, the oldest goes. */
10241
+ const LIMIT = 50;
10242
+ /**
10243
+ * Looks `raw` up in `doc` through its cached resolver.
10244
+ * `undefined` for anything unreadable, or when the document is not ready.
10245
+ */
10246
+ async function resolve(doc, raw) {
10247
+ try {
10248
+ return await doc.resolveDestination(raw);
10249
+ } catch {
10250
+ return;
10251
+ }
10252
+ }
10253
+ /** The page a Named action goes to from `current`, within `count` pages. */
10254
+ function namedPage(action, current, count) {
10255
+ const to = action === "NextPage" ? current + 1 : action === "PrevPage" ? current - 1 : action === "FirstPage" ? 0 : count - 1;
10256
+ return Math.min(Math.max(to, 0), count - 1);
10257
+ }
10258
+ /** Within a point: closer than that, two positions show the same view. */
10259
+ const near = (a, b) => a === void 0 || b === void 0 ? a === b : Math.abs(a - b) <= 1;
10260
+ /** Whether `b` is where `a` was, give or take a point of rounding. */
10261
+ function samePlace(a, b) {
10262
+ if (!b || a.page !== b.page || a.fit !== b.fit) return false;
10263
+ const p = a;
10264
+ const q = b;
10265
+ return near(p.left, q.left) && near(p.top, q.top);
10266
+ }
10267
+ function createNavigation(deps) {
10268
+ const backStack = [];
10269
+ const forwardStack = [];
10270
+ const canGoBack = writable(false);
10271
+ const canGoForward = writable(false);
10272
+ const sync = () => {
10273
+ canGoBack.set(backStack.length > 0);
10274
+ canGoForward.set(forwardStack.length > 0);
10275
+ };
10276
+ const push = (stack, dest) => {
10277
+ stack.push(dest);
10278
+ if (stack.length > LIMIT) stack.shift();
10279
+ };
10280
+ const inRange = (page) => {
10281
+ const count = deps.document()?.pages.length ?? 0;
10282
+ return Number.isInteger(page) && page >= 0 && page < count;
10283
+ };
10284
+ /** Pops `from` and goes there, keeping the current position on `to`. Records no jump. */
10285
+ const step = (from, to) => {
10286
+ const vp = deps.viewport();
10287
+ const dest = from.at(-1);
10288
+ if (!vp || !dest) return;
10289
+ from.pop();
10290
+ const here = deps.here();
10291
+ if (here) push(to, here);
10292
+ vp.scrollToDestination(dest);
10293
+ sync();
10294
+ };
10295
+ const goTo = (dest) => {
10296
+ const vp = deps.viewport();
10297
+ if (!vp || !inRange(dest.page)) return;
10298
+ const here = deps.here();
10299
+ vp.scrollToDestination(dest);
10300
+ if (!here || samePlace(here, deps.here())) return;
10301
+ push(backStack, here);
10302
+ forwardStack.length = 0;
10303
+ sync();
10304
+ };
10305
+ return {
10306
+ canGoBack: readonly(canGoBack),
10307
+ canGoForward: readonly(canGoForward),
10308
+ back: () => step(backStack, forwardStack),
10309
+ forward: () => step(forwardStack, backStack),
10310
+ goTo,
10311
+ async follow(target, opts) {
10312
+ switch (target.kind) {
10313
+ case "resolved":
10314
+ goTo(target.dest);
10315
+ return;
10316
+ case "dest": {
10317
+ const doc = deps.document();
10318
+ if (!doc) return;
10319
+ const dest = await resolve(doc, target.raw);
10320
+ if (dest && deps.document() === doc) goTo(dest);
10321
+ return;
10322
+ }
10323
+ case "named": {
10324
+ const vp = deps.viewport();
10325
+ const count = deps.document()?.pages.length ?? 0;
10326
+ if (!vp || count === 0) return;
10327
+ const current = opts?.from ?? vp.currentPage.get();
10328
+ const page = namedPage(target.action, current, count);
10329
+ if (page !== current) goTo({
10330
+ page,
10331
+ fit: "fit"
10332
+ });
10333
+ return;
10334
+ }
10335
+ case "url": {
10336
+ const url = safeUrl(target.url);
10337
+ if (url) deps.report({
10338
+ kind: "open-url",
10339
+ url,
10340
+ newWindow: target.newWindow
10341
+ });
10342
+ return;
10343
+ }
10344
+ case "unsupported": return;
10345
+ }
10346
+ },
10347
+ clear() {
10348
+ backStack.length = 0;
10349
+ forwardStack.length = 0;
10350
+ sync();
10351
+ }
10352
+ };
10353
+ }
10354
+ //#endregion
9854
10355
  //#region src/dom/search/highlights.ts
9855
10356
  const NO_HITS = [];
9856
10357
  /** One box over the quad: its CSS bounding box, clipped to the quad when it is not upright. */
@@ -10028,8 +10529,9 @@ function mountViewer(opts) {
10028
10529
  tool: opts.tool
10029
10530
  });
10030
10531
  const toolStyle = opts.toolStyle ?? tracker.toolStyle;
10532
+ const storage = opts.storage ?? createMemoryStorage();
10031
10533
  const host = opts.signatureLibrary;
10032
- const own = host ? void 0 : createSignatureLibrary(opts.signatures ?? createMemorySignatureStore());
10534
+ const own = host ? void 0 : createSignatureLibrary(opts.signatures ?? createStorageSignatureStore(storage));
10033
10535
  /** The library now: a host getter is read each time, so a swapped library takes effect. */
10034
10536
  const signatures = () => typeof host === "function" ? host() : host ?? own;
10035
10537
  signatures().refresh();
@@ -10050,6 +10552,7 @@ function mountViewer(opts) {
10050
10552
  formHighlight: highlight,
10051
10553
  formActive: formsActive,
10052
10554
  onSignField: (req) => opts.onSignField?.(req),
10555
+ followLink: (target, o) => void navigation.follow(target, o),
10053
10556
  ...layers ? { layers } : {},
10054
10557
  ...highlights ? { highlights } : {},
10055
10558
  onError: (cause, layer) => {
@@ -10077,26 +10580,25 @@ function mountViewer(opts) {
10077
10580
  return id !== void 0 && FORM_TOOLS.has(id);
10078
10581
  };
10079
10582
  /**
10080
- * The first widget (`last`: the last) in Tab order on each page with a control to stop at,
10081
- * by page index.
10583
+ * The first stop (`last`: the last) in Tab order on `page`, by its user-space rect, among the
10584
+ * fields with a control to stop at (while fields take input) and the links Inkbar follows.
10585
+ * `widgets` holds those fields' widgets by page.
10082
10586
  */
10083
- function tabEdges(last) {
10084
- const fields = doc.form.fields.get();
10085
- const byId = new Map(fields.flatMap((f) => hasControl(f) ? f.widgets.map((w) => [w.id, w]) : []));
10587
+ function tabEdge(page, last, widgets) {
10086
10588
  const state = doc.form.widgetState.get();
10087
- const edges = /* @__PURE__ */ new Map();
10088
- for (const id of tabOrder(fields, doc.form._tabs, doc.pages, doc.form._structure)) {
10089
- const w = byId.get(id);
10090
- if (!w || (state[id]?.hidden ?? (w.hidden || w.printOnly))) continue;
10091
- if (last || !edges.has(w.page)) edges.set(w.page, w);
10092
- }
10093
- return edges;
10589
+ const shown = (widgets.get(page) ?? []).filter((w) => !(state[w.id]?.hidden ?? (w.hidden || w.printOnly)));
10590
+ const links = doc.links(page).filter((l) => l.target.kind !== "unsupported");
10591
+ if (shown.length === 0 && links.length === 0) return void 0;
10592
+ const rects = new Map([...shown.map((w) => [w.id, w.rect]), ...links.map((l) => [l.id, l.quads?.[0] ? rectFromQuad(l.quads[0]) : l.rect])]);
10593
+ const order = pageStopOrder(shown, links, doc.form._tabs.get(page), doc.pages[page], page, doc.form._structure.get(page));
10594
+ const id = last ? order.at(-1) : order[0];
10595
+ return id === void 0 ? void 0 : rects.get(id);
10094
10596
  }
10095
10597
  /** The page a Tab waits for: its stops were not mounted when the view scrolled to it. */
10096
10598
  let tabWait;
10097
- /** The first stop of `page` (`back`: its last), once its form layer is mounted. */
10599
+ /** The first stop of `page` (`back`: its last), once the page is mounted. */
10098
10600
  const edgeStop = (page, back) => {
10099
- const stops = pages.get(page)?.form?.tabStops() ?? [];
10601
+ const stops = pages.get(page)?.tabStops() ?? [];
10100
10602
  return back ? stops.at(-1) : stops[0];
10101
10603
  };
10102
10604
  /**
@@ -10131,45 +10633,117 @@ function mountViewer(opts) {
10131
10633
  tabWait = wait;
10132
10634
  }
10133
10635
  /**
10134
- * Tab on a page's last field stop (Shift+Tab on its first) goes to the first (last) stop of
10135
- * the next (previous) page that has fields, mounting it and scrolling it into view. A page
10136
- * that has not mounted once the view scrolls to it gets one more try, when it mounts or on the
10137
- * next frame; after that, focus moves on as the browser's Tab would. Past the last page's
10138
- * fields, Tab leaves the viewer as usual.
10636
+ * Tab among a page's fields and links, and across pages. On a page with both, Tab follows the
10637
+ * page's one order of them: they sit in two layers, so the browser's own Tab would visit one
10638
+ * layer and then the other. Tab on a page's last stop (Shift+Tab on its first) goes to the
10639
+ * first (last) stop of the next (previous) page that has any, mounting it and scrolling it
10640
+ * into view. A page that has not mounted once the view scrolls to it gets one more try, when
10641
+ * it mounts or on the next frame; after that, focus moves on as the browser's Tab would. Past
10642
+ * the last page's stops, Tab leaves the viewer as usual.
10139
10643
  */
10140
- function onFormTab(e) {
10644
+ function onPageTab(e) {
10141
10645
  if (e.key !== "Tab" || e.altKey || e.ctrlKey || e.metaKey || e.defaultPrevented) return;
10142
10646
  const target = e.target;
10143
- if (!(target instanceof Element) || !target.closest("[data-ink-form-layer]")) return;
10144
- if (!formsActive()) return;
10647
+ if (!(target instanceof HTMLElement) || !target.closest("[data-ink-form-layer], [data-ink-link-layer]")) return;
10145
10648
  const back = e.shiftKey;
10146
- const from = [...pages].find(([, m]) => m.form?.element.contains(target));
10649
+ const from = [...pages].find(([, m]) => m.form?.element.contains(target) || m.links?.element.contains(target));
10147
10650
  if (!from) return;
10148
10651
  const [page, mount] = from;
10149
- if (mount.form?.tabStops().at(back ? 0 : -1) !== target) return;
10150
- const edges = [...tabEdges(back)].filter(([p]) => back ? p < page : p > page).sort(([a], [b]) => back ? b - a : a - b);
10151
- for (const [next, widget] of edges) {
10152
- viewport.scrollToRect(next, widget.rect);
10652
+ const stops = mount.tabStops();
10653
+ const at = stops.indexOf(target);
10654
+ if (at < 0) return;
10655
+ const within = stops[back ? at - 1 : at + 1];
10656
+ if (within) {
10657
+ if (mount.form && mount.links) {
10658
+ e.preventDefault();
10659
+ within.focus();
10660
+ }
10661
+ return;
10662
+ }
10663
+ const fields = formsActive() ? doc.form.fields.get() : [];
10664
+ const widgets = /* @__PURE__ */ new Map();
10665
+ for (const f of fields) if (hasControl(f)) for (const w of f.widgets) {
10666
+ const list = widgets.get(w.page) ?? [];
10667
+ list.push(w);
10668
+ widgets.set(w.page, list);
10669
+ }
10670
+ for (let next = page + (back ? -1 : 1); next >= 0 && next < doc.pages.length; next += back ? -1 : 1) {
10671
+ const rect = tabEdge(next, back, widgets);
10672
+ if (!rect) continue;
10673
+ viewport.scrollToRect(next, rect);
10153
10674
  const to = edgeStop(next, back);
10154
10675
  if (to) {
10155
10676
  e.preventDefault();
10156
10677
  to.focus();
10157
10678
  return;
10158
10679
  }
10159
- if (!pages.get(next)?.form) {
10680
+ if (!pages.has(next)) {
10160
10681
  e.preventDefault();
10161
10682
  awaitTabStop(next, back, target);
10162
10683
  return;
10163
10684
  }
10164
10685
  }
10165
10686
  }
10166
- el.addEventListener("keydown", onFormTab);
10687
+ el.addEventListener("keydown", onPageTab);
10688
+ /**
10689
+ * The Tab key pressed now, and whether with Shift: set on its keydown, wherever focus is, and
10690
+ * cleared once the browser has moved focus for it, so a pointer press never counts.
10691
+ */
10692
+ let tabbing;
10693
+ const onTabKey = (e) => {
10694
+ if (e.key !== "Tab") return;
10695
+ const press = { back: e.shiftKey };
10696
+ tabbing = press;
10697
+ setTimeout(() => {
10698
+ if (tabbing === press) tabbing = void 0;
10699
+ });
10700
+ };
10701
+ el.ownerDocument.addEventListener("keydown", onTabKey, true);
10702
+ /**
10703
+ * Tab from outside a page onto its fields and links starts at the page's first stop (Shift+Tab
10704
+ * at its last). The browser enters by DOM order, which on a page with both is the first link or
10705
+ * the last field, so stops before it in the page's order would be skipped.
10706
+ */
10707
+ function onStopFocus(e) {
10708
+ const target = e.target;
10709
+ if (!tabbing || !(target instanceof HTMLElement)) return;
10710
+ const on = [...pages.values()].find((m) => m.form?.element.contains(target) || m.links?.element.contains(target));
10711
+ if (!on?.form || !on.links) return;
10712
+ const from = e.relatedTarget;
10713
+ if (from instanceof Node && (on.form.element.contains(from) || on.links.element.contains(from))) return;
10714
+ const stops = on.tabStops();
10715
+ if (!stops.includes(target)) return;
10716
+ const edge = tabbing.back ? stops.at(-1) : stops[0];
10717
+ if (edge && edge !== target) edge.focus();
10718
+ }
10719
+ el.addEventListener("focusin", onStopFocus);
10720
+ const reportAction = (a) => (opts.onAction ?? opts.onFormAction)?.(a);
10721
+ /**
10722
+ * History of the jumps made in this document. "Here" is the point the rebuild anchor keeps:
10723
+ * the top-left of the visible area as an `xyz` destination, so Back puts the view where it
10724
+ * was. When that corner is in a gap, `top` is left out and the page lands at its top.
10725
+ */
10726
+ const navigation = createNavigation({
10727
+ viewport: () => destroyed ? void 0 : viewport,
10728
+ document: () => destroyed ? void 0 : doc,
10729
+ here: () => {
10730
+ const a = scrollAnchor(viewport);
10731
+ return a && {
10732
+ page: a.page,
10733
+ fit: "xyz",
10734
+ left: a.user.x,
10735
+ top: a.vertical ? a.user.y : void 0
10736
+ };
10737
+ },
10738
+ report: reportAction
10739
+ });
10167
10740
  const stopForm = [
10168
- doc.form.on("action", (a) => opts.onFormAction?.(a)),
10741
+ doc.form.on("action", reportAction),
10169
10742
  doc.form.on("alert", (message) => opts.onFormAlert?.(message)),
10170
- doc.form.on("goto", (page) => {
10171
- if (!destroyed && page >= 0 && page < doc.pages.length) viewport.scrollToPage(page);
10172
- })
10743
+ doc.form.on("goto", (page) => navigation.goTo({
10744
+ page,
10745
+ fit: "fit"
10746
+ }))
10173
10747
  ];
10174
10748
  /**
10175
10749
  * The page whose open scripts ran last, and has not closed since. Page open and close events
@@ -10244,6 +10818,28 @@ function mountViewer(opts) {
10244
10818
  el.scrollLeft += o.x + box.left + css.x - corner.x;
10245
10819
  if (anchor.vertical) el.scrollTop += o.y + box.top + css.y - corner.y;
10246
10820
  }
10821
+ /**
10822
+ * The link a press at user-space `point` on `page` follows: of the live links whose quads (or
10823
+ * rect, without quads) hold the point, the topmost, which is the last in the file. Unsupported
10824
+ * links are not live, and nor are destinations the page's link layer found go nowhere (it
10825
+ * marks their stop `aria-disabled`).
10826
+ */
10827
+ function linkAt(vp, page, point) {
10828
+ const host = vp.hostFor(page);
10829
+ const dead = (l) => host?.querySelector(`[data-ink-link-layer] [data-ink-link="${CSS.escape(l.id)}"]`)?.getAttribute("aria-disabled") === "true";
10830
+ const links = doc.links(page);
10831
+ for (let i = links.length - 1; i >= 0; i--) {
10832
+ const l = links[i];
10833
+ if ((l.quads?.length ? l.quads.some((q) => quadContainsPoint(q, point)) : rectContainsPoint(l.rect, point)) && l.target.kind !== "unsupported" && !dead(l)) return l;
10834
+ }
10835
+ }
10836
+ /** The pieces of the hovered link, which carry `data-ink-hover` for the theme's tint. */
10837
+ let hoveredPieces = [];
10838
+ function markHover(vp, link) {
10839
+ for (const piece of hoveredPieces) piece.removeAttribute("data-ink-hover");
10840
+ hoveredPieces = link ? [...vp.hostFor(link.page)?.querySelectorAll(`[data-ink-link-layer] [data-ink-link="${CSS.escape(link.id)}"]`) ?? []] : [];
10841
+ for (const piece of hoveredPieces) piece.setAttribute("data-ink-hover", "");
10842
+ }
10247
10843
  function build(state) {
10248
10844
  const vp = createViewport({
10249
10845
  container: el,
@@ -10264,6 +10860,9 @@ function mountViewer(opts) {
10264
10860
  tools: list,
10265
10861
  options: toolOptions,
10266
10862
  ...plugins && { onPress: (kind, press) => plugins.emitPress(kind, press) },
10863
+ linkAt: (page, point) => linkAt(vp, page, point),
10864
+ onLink: (link) => void navigation.follow(link.target, { from: link.page }),
10865
+ onLinkHover: (link) => markHover(vp, link),
10267
10866
  ctx: {
10268
10867
  toolStyle,
10269
10868
  store: doc.annotations,
@@ -10334,8 +10933,10 @@ function mountViewer(opts) {
10334
10933
  get signatures() {
10335
10934
  return signatures();
10336
10935
  },
10936
+ storage,
10337
10937
  signatureFonts: opts.signatureFonts ? [...opts.signatureFonts] : SIGNATURE_FONTS,
10338
10938
  pages,
10939
+ navigation,
10339
10940
  rebuild(next) {
10340
10941
  if (destroyed) return;
10341
10942
  const state = {
@@ -10372,9 +10973,12 @@ function mountViewer(opts) {
10372
10973
  layers?.destroy();
10373
10974
  highlights?.destroy();
10374
10975
  stopRedecorate?.();
10375
- el.removeEventListener("keydown", onFormTab);
10976
+ el.removeEventListener("keydown", onPageTab);
10977
+ el.ownerDocument.removeEventListener("keydown", onTabKey, true);
10978
+ el.removeEventListener("focusin", onStopFocus);
10376
10979
  tabWait?.cancel();
10377
10980
  for (const stop of stopForm) stop();
10981
+ navigation.clear();
10378
10982
  if (!highlight) delete el.dataset.inkFormHighlight;
10379
10983
  }
10380
10984
  };
@@ -11674,9 +12278,9 @@ const passThrough = {
11674
12278
  react: () => noop
11675
12279
  };
11676
12280
  function createViewer(el, opts) {
11677
- const { source: initial, assets, password, defaults, history, fields, annotations, plugins, threads: withThreads = true, onReady, onError, onActivate, onBuild, onPageMount, onPageUnmount, onPageError, formHighlight: initialHighlight, toolStyle: _toolStyle, toolOptions: _toolOptions, signatureLibrary: _signatureLibrary, onSignField: _onSignField, onFormAlert: _onFormAlert, signatures: signatureStore, search: _search, ...view } = opts;
12281
+ const { source: initial, assets, password, defaults, history, fields, annotations, plugins, threads: withThreads = true, onReady, onError, onActivate, onBuild, onPageMount, onPageUnmount, onPageError, formHighlight: initialHighlight, toolStyle: _toolStyle, toolOptions: _toolOptions, signatureLibrary: _signatureLibrary, onSignField: _onSignField, onFormAlert: _onFormAlert, signatures: signatureStore, storage = createMemoryStorage(), search: _search, ...view } = opts;
11678
12282
  /** One library for the viewer's life, so templates and the menu outlive each document. */
11679
- const signatures = createSignatureLibrary(signatureStore ?? createMemorySignatureStore());
12283
+ const signatures = createSignatureLibrary(signatureStore ?? createStorageSignatureStore(storage));
11680
12284
  /** Tool options persist for the viewer's life; the tool style is per document. */
11681
12285
  const toolOptions = createToolOptions();
11682
12286
  const status = writable({ state: "idle" });
@@ -11685,6 +12289,10 @@ function createViewer(el, opts) {
11685
12289
  const engine = writable(null);
11686
12290
  const viewport = writable(null);
11687
12291
  const tools = writable(null);
12292
+ /** The open document's history, and whether it can move, following each engine. */
12293
+ const navigation = writable(null);
12294
+ const canGoBack = writable(false);
12295
+ const canGoForward = writable(false);
11688
12296
  /** The activated annotation: the note thread opens from it. */
11689
12297
  const activated = writable(null);
11690
12298
  let formHighlight = initialHighlight ?? true;
@@ -11727,6 +12335,7 @@ function createViewer(el, opts) {
11727
12335
  */
11728
12336
  const host = createPluginHost({
11729
12337
  plugins: plugins ?? [],
12338
+ storage,
11730
12339
  document: readonly(current),
11731
12340
  history: () => current.get()?.history ?? passThrough,
11732
12341
  activated: readonly(activated),
@@ -11807,6 +12416,7 @@ function createViewer(el, opts) {
11807
12416
  formHighlight,
11808
12417
  toolOptions,
11809
12418
  signatureLibrary: signatures,
12419
+ storage,
11810
12420
  onFormAlert: (message) => formAlert.show(message),
11811
12421
  onSignField: (req) => {
11812
12422
  signFieldMenu ??= createSignFieldMenu(viewer, {
@@ -11849,7 +12459,16 @@ function createViewer(el, opts) {
11849
12459
  }
11850
12460
  });
11851
12461
  engine.set(mounted);
12462
+ const nav = mounted.navigation;
12463
+ const stopNav = [nav.canGoBack.subscribe(canGoBack.set), nav.canGoForward.subscribe(canGoForward.set)];
12464
+ canGoBack.set(nav.canGoBack.get());
12465
+ canGoForward.set(nav.canGoForward.get());
12466
+ navigation.set(nav);
11852
12467
  return () => {
12468
+ for (const s of stopNav) s();
12469
+ navigation.set(null);
12470
+ canGoBack.set(false);
12471
+ canGoForward.set(false);
11853
12472
  stopForm();
11854
12473
  releaseFormErrors = () => void 0;
11855
12474
  signFieldMenu?.close();
@@ -11944,6 +12563,11 @@ function createViewer(el, opts) {
11944
12563
  formHighlight = on;
11945
12564
  engine.get()?.setFormHighlight(on);
11946
12565
  },
12566
+ canGoBack: readonly(canGoBack),
12567
+ canGoForward: readonly(canGoForward),
12568
+ back: () => navigation.get()?.back(),
12569
+ forward: () => navigation.get()?.forward(),
12570
+ follow: async (target) => navigation.get()?.follow(target),
11947
12571
  commands: { get: (id) => host.command(id) },
11948
12572
  search: viewerSearch,
11949
12573
  records: (key) => host.records(key),
@@ -11968,6 +12592,7 @@ function createViewer(el, opts) {
11968
12592
  internals.set(viewer, {
11969
12593
  search,
11970
12594
  host,
12595
+ navigation: readonly(navigation),
11971
12596
  destroyers,
11972
12597
  destroyed: () => destroyed
11973
12598
  });
@@ -12000,6 +12625,15 @@ function pluginHostOf(viewer) {
12000
12625
  return found.host;
12001
12626
  }
12002
12627
  /**
12628
+ * @internal The open document's navigation, `null` while none is ready, for controls that jump
12629
+ * (the outline) and so must record history.
12630
+ */
12631
+ function navigationOf(viewer) {
12632
+ const found = internals.get(viewer);
12633
+ if (!found) throw new Error("Not a viewer made by createViewer");
12634
+ return found.navigation;
12635
+ }
12636
+ /**
12003
12637
  * @internal Runs `fn` when the viewer is destroyed, for what a control set up on it; at once
12004
12638
  * when it already is.
12005
12639
  */
@@ -12171,6 +12805,33 @@ function historyButton(viewer, action) {
12171
12805
  return b;
12172
12806
  }
12173
12807
  /**
12808
+ * Returns to where the view was before the last jump (`viewer.back()`): a link, an outline
12809
+ * item or a form button that went to a page. Disabled while there is nowhere to go back to.
12810
+ */
12811
+ function createBack(viewer) {
12812
+ return navigationButton(viewer, "back");
12813
+ }
12814
+ /** Goes forward again after `createBack` (`viewer.forward()`); disabled until Back is used. */
12815
+ function createForward(viewer) {
12816
+ return navigationButton(viewer, "forward");
12817
+ }
12818
+ function navigationButton(viewer, action) {
12819
+ const back = action === "back";
12820
+ const b = button(back ? "Back" : "Forward", {
12821
+ class: `ink-${action}`,
12822
+ [`data-ink-${action}`]: ""
12823
+ }, back ? ArrowLeft : ArrowRight);
12824
+ b.addEventListener("click", () => viewer[action]());
12825
+ keepHistoryFocus(b, () => viewer.element.querySelector("[data-ink-scroll]"));
12826
+ const can = back ? viewer.canGoBack : viewer.canGoForward;
12827
+ const show = (on) => {
12828
+ b.disabled = !on;
12829
+ };
12830
+ show(can.get());
12831
+ onViewerDestroy(viewer, can.subscribe(show));
12832
+ return b;
12833
+ }
12834
+ /**
12174
12835
  * True for `select-text` and `select`, which every viewer registers, and for any other tool id
12175
12836
  * the viewer was given.
12176
12837
  */
@@ -13229,6 +13890,29 @@ function sidebarBadge(badge) {
13229
13890
  const text = String(badge).trim();
13230
13891
  return text === "" || text === "0" ? null : text;
13231
13892
  }
13893
+ /** The sidebar's widths in CSS px, unless the host gives its own. */
13894
+ const SIDEBAR_WIDTH = {
13895
+ default: 320,
13896
+ min: 240,
13897
+ max: 480
13898
+ };
13899
+ /** The widest the sidebar shows: `max`, and half the container once measured, but never below `min`. */
13900
+ const widthLimit = ({ min, max, container }) => container === void 0 ? max : Math.min(max, Math.max(min, Math.floor(container / 2)));
13901
+ /** The width the sidebar shows for the wanted `want`. */
13902
+ function shownSidebarWidth(want, limits) {
13903
+ return Math.min(Math.max(want, limits.min), widthLimit(limits));
13904
+ }
13905
+ /** How far an arrow key moves the handle. */
13906
+ const KEY_STEP = 16;
13907
+ const isThenable = (value) => typeof value?.then === "function";
13908
+ /** Calls `fn` on storage so that neither a throw nor a rejection escapes. */
13909
+ function quietly(fn, then) {
13910
+ try {
13911
+ const result = fn();
13912
+ if (isThenable(result)) result.then((value) => then?.(value), () => {});
13913
+ else then?.(result);
13914
+ } catch {}
13915
+ }
13232
13916
  const TRIGGER = "[data-ink-sidebar-trigger]";
13233
13917
  let count = 0;
13234
13918
  function sameIds(a, b) {
@@ -13256,6 +13940,20 @@ function createSidebarController(opts) {
13256
13940
  let visible = opts.visible ?? true;
13257
13941
  /** The options that last gave `onOpenChange`, called as a method of them. */
13258
13942
  let handler = opts;
13943
+ /** Likewise for `onWidthChange`. */
13944
+ let widthHandler = opts;
13945
+ const storage = opts.storage;
13946
+ const name = opts.name;
13947
+ let defaultWidth = opts.defaultWidth ?? SIDEBAR_WIDTH.default;
13948
+ let minWidth = opts.minWidth ?? SIDEBAR_WIDTH.min;
13949
+ let maxWidth = opts.maxWidth ?? SIDEBAR_WIDTH.max;
13950
+ let widthControlled = opts.width !== void 0;
13951
+ let width = opts.width ?? defaultWidth;
13952
+ let resizing = false;
13953
+ /** The width of the root's parent, once `bindHandle` has measured it. */
13954
+ let container;
13955
+ /** Set by a drag, key or reset: a late answer from storage no longer applies. */
13956
+ let touched = false;
13259
13957
  /** The trigger that holds the Tab stop in rail and tabs, once one has had focus. */
13260
13958
  let current = null;
13261
13959
  let destroyed = false;
@@ -13288,15 +13986,49 @@ function createSidebarController(opts) {
13288
13986
  side,
13289
13987
  panels,
13290
13988
  open: resolve(opts.open !== void 0 ? opts.open : panels[0]?.id ?? null),
13291
- visible
13989
+ visible,
13990
+ width,
13991
+ resizing
13292
13992
  });
13293
- const publish = (open) => store.set({
13993
+ const publish = (open = store.get().open) => store.set({
13294
13994
  layout,
13295
13995
  side,
13296
13996
  panels,
13297
13997
  open,
13298
- visible
13998
+ visible,
13999
+ width,
14000
+ resizing
14001
+ });
14002
+ const widthKey = () => `sidebar.${name ?? side}.width`;
14003
+ const limits = () => ({
14004
+ min: minWidth,
14005
+ max: maxWidth,
14006
+ container
13299
14007
  });
14008
+ const shown = () => shownSidebarWidth(width, limits());
14009
+ const clampWidth = (w) => Math.min(Math.max(w, minWidth), maxWidth);
14010
+ if (!widthControlled && storage) quietly(() => storage.get(widthKey()), (saved) => {
14011
+ if (destroyed || touched || widthControlled || typeof saved !== "number" || !Number.isFinite(saved) || saved <= 0 || saved === width) return;
14012
+ width = saved;
14013
+ publish();
14014
+ });
14015
+ /** A width the user chose: kept and reported when uncontrolled, only reported when controlled. */
14016
+ function chooseWidth(next, save) {
14017
+ touched = true;
14018
+ if (!widthControlled) {
14019
+ if (next !== width) {
14020
+ width = next;
14021
+ publish();
14022
+ }
14023
+ if (save && storage) quietly(() => storage.set(widthKey(), next));
14024
+ }
14025
+ widthHandler.onWidthChange?.(next);
14026
+ }
14027
+ function resetWidth() {
14028
+ if (destroyed) return;
14029
+ chooseWidth(defaultWidth, false);
14030
+ if (!widthControlled && storage) quietly(() => storage.remove(widthKey()));
14031
+ }
13300
14032
  const isOpen = (id) => store.get().open.includes(id);
13301
14033
  function update(next) {
13302
14034
  if (destroyed) return;
@@ -13309,11 +14041,24 @@ function createSidebarController(opts) {
13309
14041
  if (next.panels !== void 0 && !samePanels(next.panels, panels)) panels = next.panels;
13310
14042
  if ("onOpenChange" in next) handler = next;
13311
14043
  if (next.open !== void 0) controlled = true;
14044
+ if ("onWidthChange" in next) widthHandler = next;
14045
+ const shownBefore = shown();
14046
+ const widthBefore = width;
14047
+ const minBefore = minWidth;
14048
+ const limitBefore = widthLimit(limits());
14049
+ if (next.defaultWidth !== void 0) defaultWidth = next.defaultWidth;
14050
+ if (next.minWidth !== void 0) minWidth = next.minWidth;
14051
+ if (next.maxWidth !== void 0) maxWidth = next.maxWidth;
14052
+ if (next.width !== void 0) {
14053
+ widthControlled = true;
14054
+ width = next.width;
14055
+ }
14056
+ const widthChanged = width !== widthBefore || shown() !== shownBefore || minWidth !== minBefore || widthLimit(limits()) !== limitBefore;
13312
14057
  const arrived = !controlled && before.panels.length === 0 && before.open.length === 0 ? panels[0]?.id : void 0;
13313
14058
  const asked = next.open !== void 0 ? idsOf(next.open) : arrived !== void 0 ? [arrived] : before.open;
13314
14059
  let open = resolve(asked);
13315
14060
  if (!controlled && layout === "tabs" && open.length === 0 && panels[0] !== void 0) open = [panels[0].id];
13316
- if (pending !== wasPending || layout !== before.layout || side !== before.side || visible !== before.visible || panels !== before.panels || !sameIds(open, before.open)) publish(open);
14061
+ if (pending !== wasPending || layout !== before.layout || side !== before.side || visible !== before.visible || panels !== before.panels || widthChanged || !sameIds(open, before.open)) publish(open);
13317
14062
  if (!(open.length === new Set(asked).size && open.every((id) => asked.includes(id)))) handler.onOpenChange?.(toValue(open));
13318
14063
  }
13319
14064
  function toggle(id) {
@@ -13444,6 +14189,123 @@ function createSidebarController(opts) {
13444
14189
  bindings.add(unbind);
13445
14190
  return unbind;
13446
14191
  }
14192
+ const panelsId = `${prefix}-panels`;
14193
+ function handleAttrs() {
14194
+ const attrs = {
14195
+ "data-ink-sidebar-handle": "",
14196
+ role: "separator",
14197
+ "aria-orientation": "vertical",
14198
+ "aria-label": "Resize sidebar",
14199
+ "aria-valuemin": String(Math.round(minWidth)),
14200
+ "aria-valuemax": String(Math.round(widthLimit(limits()))),
14201
+ "aria-valuenow": String(Math.round(shown())),
14202
+ tabindex: "0"
14203
+ };
14204
+ if (layout !== "stack") attrs["aria-controls"] = panelsId;
14205
+ if (!visible || store.get().open.length === 0) attrs.hidden = "";
14206
+ return attrs;
14207
+ }
14208
+ function bindHandle(el) {
14209
+ /**
14210
+ * The drag under way: its pointer, where it started, the width then, the last one chosen and
14211
+ * whether it has chosen any.
14212
+ */
14213
+ let drag = null;
14214
+ const wider = () => side === "left" ? 1 : -1;
14215
+ const setResizing = (on) => {
14216
+ if (resizing === on) return;
14217
+ resizing = on;
14218
+ publish();
14219
+ };
14220
+ const onPointerDown = (e) => {
14221
+ if (destroyed || drag) return;
14222
+ if (e.pointerType === "mouse" && e.button !== 0) return;
14223
+ e.preventDefault();
14224
+ try {
14225
+ el.setPointerCapture(e.pointerId);
14226
+ } catch {}
14227
+ drag = {
14228
+ pointer: e.pointerId,
14229
+ x: e.clientX,
14230
+ width: shown(),
14231
+ last: shown(),
14232
+ moved: false
14233
+ };
14234
+ setResizing(true);
14235
+ };
14236
+ /** Ends the drag; saves only when it moved, so a press or a double-click writes nothing. */
14237
+ const endDrag = () => {
14238
+ if (!drag) return;
14239
+ const { moved } = drag;
14240
+ drag = null;
14241
+ setResizing(false);
14242
+ if (moved && !widthControlled && storage) quietly(() => storage.set(widthKey(), width));
14243
+ };
14244
+ const onPointerMove = (e) => {
14245
+ if (!drag || e.pointerId !== drag.pointer) return;
14246
+ if (e.pointerType === "mouse" && e.buttons === 0) return endDrag();
14247
+ const next = clampWidth(drag.width + (e.clientX - drag.x) * wider());
14248
+ if (next === drag.last) return;
14249
+ drag.last = next;
14250
+ drag.moved = true;
14251
+ chooseWidth(next, false);
14252
+ };
14253
+ const onPointerEnd = (e) => {
14254
+ if (drag && e.pointerId === drag.pointer) endDrag();
14255
+ };
14256
+ const onKeyDown = (e) => {
14257
+ if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return;
14258
+ const now = shown();
14259
+ let next;
14260
+ if (e.key === "ArrowRight") next = clampWidth(now + KEY_STEP * wider());
14261
+ else if (e.key === "ArrowLeft") next = clampWidth(now - KEY_STEP * wider());
14262
+ else if (e.key === "Home") next = minWidth;
14263
+ else if (e.key === "End") next = maxWidth;
14264
+ else if (e.key === "Enter") next = null;
14265
+ else return;
14266
+ e.preventDefault();
14267
+ if (next === null) resetWidth();
14268
+ else chooseWidth(next, true);
14269
+ };
14270
+ const onDoubleClick = () => resetWidth();
14271
+ const parent = (el.closest("[data-ink-sidebar]") ?? el.parentElement)?.parentElement;
14272
+ let observer;
14273
+ if (parent && typeof ResizeObserver !== "undefined") {
14274
+ observer = new ResizeObserver((entries) => {
14275
+ const measured = entries[entries.length - 1]?.contentRect.width;
14276
+ if (measured === void 0 || measured === container || destroyed) return;
14277
+ const before = shown();
14278
+ const limitBefore = widthLimit(limits());
14279
+ container = measured;
14280
+ if (shown() !== before || widthLimit(limits()) !== limitBefore) publish();
14281
+ });
14282
+ observer.observe(parent);
14283
+ }
14284
+ el.addEventListener("pointerdown", onPointerDown);
14285
+ el.addEventListener("pointermove", onPointerMove);
14286
+ el.addEventListener("pointerup", onPointerEnd);
14287
+ el.addEventListener("pointercancel", onPointerEnd);
14288
+ el.addEventListener("lostpointercapture", onPointerEnd);
14289
+ el.addEventListener("keydown", onKeyDown);
14290
+ el.addEventListener("dblclick", onDoubleClick);
14291
+ const unbind = () => {
14292
+ if (!bindings.delete(unbind)) return;
14293
+ observer?.disconnect();
14294
+ el.removeEventListener("pointerdown", onPointerDown);
14295
+ el.removeEventListener("pointermove", onPointerMove);
14296
+ el.removeEventListener("pointerup", onPointerEnd);
14297
+ el.removeEventListener("pointercancel", onPointerEnd);
14298
+ el.removeEventListener("lostpointercapture", onPointerEnd);
14299
+ el.removeEventListener("keydown", onKeyDown);
14300
+ el.removeEventListener("dblclick", onDoubleClick);
14301
+ if (drag) {
14302
+ drag = null;
14303
+ if (!destroyed) setResizing(false);
14304
+ }
14305
+ };
14306
+ bindings.add(unbind);
14307
+ return unbind;
14308
+ }
13447
14309
  const controller = {
13448
14310
  state: readonly(store),
13449
14311
  update,
@@ -13454,8 +14316,14 @@ function createSidebarController(opts) {
13454
14316
  "data-layout": layout,
13455
14317
  "data-side": side,
13456
14318
  "data-state": store.get().open.length > 0 || pending && !controlled ? "open" : "closed",
14319
+ ...resizing && { "data-resizing": "" },
13457
14320
  ...!visible && { hidden: "" }
13458
14321
  }),
14322
+ rootStyle: () => ({ "--ink-sidebar-width": `${shown()}px` }),
14323
+ panelsAttrs: () => ({ id: panelsId }),
14324
+ handleAttrs,
14325
+ bindHandle,
14326
+ resetWidth,
13459
14327
  tablistAttrs,
13460
14328
  triggerAttrs,
13461
14329
  panelAttrs,
@@ -13489,6 +14357,8 @@ function createSidebar(opts) {
13489
14357
  const { content, ...options } = opts;
13490
14358
  const controller = createSidebarController(options);
13491
14359
  const root = h("div", { class: "ink-sidebar" });
14360
+ const handle = h("div", { class: "ink-sidebar-handle" });
14361
+ let panelsEl = null;
13492
14362
  let shape = "";
13493
14363
  const triggers = /* @__PURE__ */ new Map();
13494
14364
  const panels = /* @__PURE__ */ new Map();
@@ -13524,6 +14394,7 @@ function createSidebar(opts) {
13524
14394
  for (const [p, panel] of built) panels.set(p.id, panel);
13525
14395
  if (s.layout === "stack") {
13526
14396
  list = null;
14397
+ panelsEl = null;
13527
14398
  root.replaceChildren(...built.map(([p, panel]) => {
13528
14399
  const header = makeTrigger(p, "ink-sidebar-header");
13529
14400
  triggers.set(p.id, header);
@@ -13536,15 +14407,20 @@ function createSidebar(opts) {
13536
14407
  triggers.set(p.id, t);
13537
14408
  list.append(t);
13538
14409
  }
13539
- root.replaceChildren(list, h("div", { class: "ink-sidebar-panels" }, ...built.map(([, panel]) => panel)));
14410
+ panelsEl = h("div", { class: "ink-sidebar-panels" }, ...built.map(([, panel]) => panel));
14411
+ root.replaceChildren(list, panelsEl);
13540
14412
  }
14413
+ root.append(handle);
13541
14414
  paint(s);
13542
14415
  if (focused !== void 0) triggers.get(focused)?.focus();
13543
14416
  }
13544
14417
  /** Attributes, labels and badges, in place: the focused trigger keeps focus. */
13545
14418
  function paint(s) {
13546
14419
  setAttrs(root, controller.rootAttrs());
14420
+ for (const [name, value] of Object.entries(controller.rootStyle())) if (root.style.getPropertyValue(name) !== value) root.style.setProperty(name, value);
13547
14421
  if (list) setAttrs(list, controller.tablistAttrs());
14422
+ if (panelsEl) setAttrs(panelsEl, controller.panelsAttrs());
14423
+ setAttrs(handle, controller.handleAttrs());
13548
14424
  for (const p of s.panels) {
13549
14425
  const t = triggers.get(p.id);
13550
14426
  const panel = panels.get(p.id);
@@ -13578,9 +14454,11 @@ function createSidebar(opts) {
13578
14454
  show(controller.state.get());
13579
14455
  const stopState = controller.state.subscribe(show);
13580
14456
  const unbind = controller.bindTriggers(root);
14457
+ const unbindHandle = controller.bindHandle(handle);
13581
14458
  onSidebarDestroy(controller, () => {
13582
14459
  stopState();
13583
14460
  unbind();
14461
+ unbindHandle();
13584
14462
  });
13585
14463
  return {
13586
14464
  element: root,
@@ -14424,6 +15302,581 @@ function createThumbnails(viewer, opts = {}) {
14424
15302
  return root;
14425
15303
  }
14426
15304
  //#endregion
15305
+ //#region src/dom/outline/controller.ts
15306
+ /** How far, in CSS px, the view may drift after a jump before the jumped-to item lets go. */
15307
+ const PIN_SLACK = 2;
15308
+ /** How long typed characters keep adding to one type-ahead search, in ms. */
15309
+ const TYPE_AHEAD_MS = 500;
15310
+ let instances = 0;
15311
+ const hasChildren = (node) => node.item.children.length > 0;
15312
+ /**
15313
+ * `role="tree"` and its label, for the element holding an outline's items. The same as a
15314
+ * controller's `treeAttrs()`, without one, so markup rendered on the server has them too.
15315
+ */
15316
+ function outlineTreeAttrs() {
15317
+ return {
15318
+ role: "tree",
15319
+ "aria-label": "Outline"
15320
+ };
15321
+ }
15322
+ function createOutlineController(deps) {
15323
+ const prefix = deps.idPrefix ?? `ink-outline-${++instances}`;
15324
+ const domId = (id) => `${prefix}-${id.replaceAll(".", "-")}`;
15325
+ const items = writable([]);
15326
+ const loading = writable(false);
15327
+ const expanded = writable(/* @__PURE__ */ new Set());
15328
+ const current = writable(null);
15329
+ const focused = writable(null);
15330
+ /** Every item, in document order. */
15331
+ let nodes = [];
15332
+ const byId = /* @__PURE__ */ new Map();
15333
+ const byDomId = /* @__PURE__ */ new Map();
15334
+ /** The items with a destination on a laid-out page, in document order, with its y. */
15335
+ let placed = [];
15336
+ /** `suffixMin[i]`: the least y from `placed[i]` on, which never decreases with `i`. */
15337
+ let suffixMin = [];
15338
+ /** The item the reading line has reached, before collapsed ancestors are applied. */
15339
+ let reached = null;
15340
+ let destroyed = false;
15341
+ /** Bumped per document, so an outline read for an earlier one is dropped. */
15342
+ let generation = 0;
15343
+ /** The document the tree's elements live in, once a key or `bindScroll` has shown it. */
15344
+ let ownerDoc = null;
15345
+ /**
15346
+ * The item last jumped to from the outline. It stays current until the reader scrolls away
15347
+ * from where the jump landed (`line`, recorded once the jump settles): a destination centred
15348
+ * in the view, or one sharing a page with later items, may otherwise not be the one the rule
15349
+ * picks there.
15350
+ */
15351
+ let pin = null;
15352
+ /** Cancels the wait for the jump to settle. */
15353
+ let cancelSettle = null;
15354
+ let typed = "";
15355
+ let typedTimer;
15356
+ function index(roots) {
15357
+ nodes = [];
15358
+ byId.clear();
15359
+ byDomId.clear();
15360
+ const walk = (list, parent) => list.forEach((item, i) => {
15361
+ const node = {
15362
+ item,
15363
+ parent,
15364
+ level: parent ? parent.level + 1 : 1,
15365
+ posinset: i + 1,
15366
+ setsize: list.length
15367
+ };
15368
+ nodes.push(node);
15369
+ byId.set(item.id, node);
15370
+ byDomId.set(domId(item.id), node);
15371
+ walk(item.children, node);
15372
+ });
15373
+ walk(roots, null);
15374
+ }
15375
+ const isExpanded = (node) => expanded.get().has(node.item.id);
15376
+ /** Whether every ancestor of `node` is expanded. */
15377
+ function shown(node) {
15378
+ for (let p = node.parent; p; p = p.parent) if (!isExpanded(p)) return false;
15379
+ return true;
15380
+ }
15381
+ /** The items a reader sees, in order: those whose ancestors are all expanded. */
15382
+ function visible() {
15383
+ const out = [];
15384
+ const walk = (list) => {
15385
+ for (const item of list) {
15386
+ const node = byId.get(item.id);
15387
+ out.push(node);
15388
+ if (isExpanded(node)) walk(item.children);
15389
+ }
15390
+ };
15391
+ walk(items.get());
15392
+ return out;
15393
+ }
15394
+ /** `node`, or its outermost collapsed ancestor when one hides it. */
15395
+ function showing(node) {
15396
+ let out = node;
15397
+ for (let p = node.parent; p; p = p.parent) if (!isExpanded(p)) out = p;
15398
+ return out;
15399
+ }
15400
+ function mark() {
15401
+ current.set(reached ? showing(reached).item.id : null);
15402
+ }
15403
+ /** Places every destination in the current layout. */
15404
+ function place() {
15405
+ const vp = deps.viewport.get();
15406
+ placed = [];
15407
+ if (vp) {
15408
+ const shown = vp.currentPage.get();
15409
+ for (const node of nodes) {
15410
+ const target = node.item.target;
15411
+ if (target.kind !== "resolved") continue;
15412
+ const y = vp.destinationY(target.dest);
15413
+ if (y !== void 0 && Number.isFinite(y)) placed.push({
15414
+ node,
15415
+ y
15416
+ });
15417
+ else if (y === void 0 && target.dest.page !== shown) placed.push({
15418
+ node,
15419
+ y: target.dest.page < shown ? -Infinity : Infinity
15420
+ });
15421
+ }
15422
+ }
15423
+ suffixMin = new Array(placed.length);
15424
+ let least = Infinity;
15425
+ for (let i = placed.length - 1; i >= 0; i--) {
15426
+ least = Math.min(least, placed[i].y);
15427
+ suffixMin[i] = least;
15428
+ }
15429
+ }
15430
+ /**
15431
+ * Finds the last placed item at or above the reading line. The last `i` with
15432
+ * `suffixMin[i] <= line` is that item: nothing after it is at or above the line.
15433
+ */
15434
+ function reach() {
15435
+ const vp = deps.viewport.get();
15436
+ if (!vp || placed.length === 0) reached = null;
15437
+ else {
15438
+ const line = vp.readingLine.get();
15439
+ let lo = 0;
15440
+ let hi = placed.length;
15441
+ while (lo < hi) {
15442
+ const mid = lo + hi >> 1;
15443
+ if (suffixMin[mid] <= line) lo = mid + 1;
15444
+ else hi = mid;
15445
+ }
15446
+ reached = lo > 0 ? placed[lo - 1].node : null;
15447
+ if (pin && pin.line !== void 0 && Math.abs(line - pin.line) > PIN_SLACK) unpin();
15448
+ if (pin) reached = pin.node;
15449
+ }
15450
+ mark();
15451
+ }
15452
+ /** The item holding the Tab stop: the focused one, else the current one, else the first. */
15453
+ function tabStop() {
15454
+ const f = focused.get();
15455
+ if (f !== null && byId.has(f)) return f;
15456
+ return current.get() ?? nodes[0]?.item.id ?? null;
15457
+ }
15458
+ const elementFor = (id) => ownerDoc?.getElementById(domId(id)) ?? null;
15459
+ /** The item's own row: the item element holds its children too, which can run far below. */
15460
+ const rowFor = (id) => ownerDoc?.getElementById(`${domId(id)}-row`) ?? null;
15461
+ const focusInTree = () => {
15462
+ const active = ownerDoc?.activeElement;
15463
+ return !!active && byDomId.has(active.id);
15464
+ };
15465
+ /** Moves the Tab stop, and focus, to `id`. */
15466
+ function moveTo(id) {
15467
+ focused.set(id);
15468
+ elementFor(id)?.focus();
15469
+ }
15470
+ function setExpanded(next) {
15471
+ const hadFocus = focusInTree();
15472
+ expanded.set(next);
15473
+ const f = focused.get();
15474
+ const node = f === null ? void 0 : byId.get(f);
15475
+ if (node && !shown(node)) {
15476
+ const to = showing(node).item.id;
15477
+ focused.set(to);
15478
+ if (hadFocus) elementFor(to)?.focus();
15479
+ }
15480
+ mark();
15481
+ }
15482
+ function unpin() {
15483
+ pin = null;
15484
+ cancelSettle?.();
15485
+ cancelSettle = null;
15486
+ }
15487
+ /** Runs `fn` once the scroll a jump started has landed: on the next frame. */
15488
+ function afterFrame(fn) {
15489
+ const view = ownerDoc?.defaultView ?? globalThis;
15490
+ if (typeof view.requestAnimationFrame === "function") {
15491
+ const handle = view.requestAnimationFrame(fn);
15492
+ return () => view.cancelAnimationFrame(handle);
15493
+ }
15494
+ const timer = setTimeout(fn, 0);
15495
+ return () => clearTimeout(timer);
15496
+ }
15497
+ function load(doc) {
15498
+ const mine = ++generation;
15499
+ unpin();
15500
+ index([]);
15501
+ items.set([]);
15502
+ expanded.set(/* @__PURE__ */ new Set());
15503
+ focused.set(null);
15504
+ reached = null;
15505
+ place();
15506
+ mark();
15507
+ typed = "";
15508
+ if (!doc) {
15509
+ loading.set(false);
15510
+ return;
15511
+ }
15512
+ loading.set(true);
15513
+ const done = (list) => {
15514
+ if (destroyed || mine !== generation) return;
15515
+ index(list);
15516
+ expanded.set(new Set(nodes.filter((n) => n.item.open && hasChildren(n)).map((n) => n.item.id)));
15517
+ items.set(list);
15518
+ place();
15519
+ reach();
15520
+ loading.set(false);
15521
+ };
15522
+ doc.outline().then(done, () => done([]));
15523
+ }
15524
+ let stopLine = null;
15525
+ function watchViewport(vp) {
15526
+ stopLine?.();
15527
+ stopLine = null;
15528
+ if (vp) {
15529
+ const offLine = vp.readingLine.subscribe(reach);
15530
+ const replace = () => {
15531
+ place();
15532
+ reach();
15533
+ };
15534
+ const offLayout = vp.layout.subscribe(() => {
15535
+ if (pin?.line !== void 0) unpin();
15536
+ replace();
15537
+ });
15538
+ const offPage = vp.currentPage.subscribe(replace);
15539
+ stopLine = () => {
15540
+ offLine();
15541
+ offLayout();
15542
+ offPage();
15543
+ };
15544
+ }
15545
+ place();
15546
+ reach();
15547
+ }
15548
+ const stopDocument = deps.document.subscribe(load);
15549
+ const stopViewport = deps.viewport.subscribe(watchViewport);
15550
+ load(deps.document.get());
15551
+ watchViewport(deps.viewport.get());
15552
+ function toggle(id) {
15553
+ const node = byId.get(id);
15554
+ if (destroyed || !node || !hasChildren(node)) return;
15555
+ const next = new Set(expanded.get());
15556
+ if (!next.delete(id)) next.add(id);
15557
+ setExpanded(next);
15558
+ }
15559
+ function activate(id) {
15560
+ const node = byId.get(id);
15561
+ if (destroyed || !node) return;
15562
+ focused.set(id);
15563
+ const target = node.item.target;
15564
+ const navigation = deps.navigation.get();
15565
+ if (target.kind === "unsupported" || !navigation) return;
15566
+ const follow = navigation.follow(target);
15567
+ if (target.kind !== "resolved") return;
15568
+ unpin();
15569
+ const mine = {
15570
+ node,
15571
+ line: void 0
15572
+ };
15573
+ pin = mine;
15574
+ reach();
15575
+ follow.then(() => {
15576
+ if (pin !== mine) return;
15577
+ cancelSettle = afterFrame(() => {
15578
+ cancelSettle = null;
15579
+ const vp = deps.viewport.get();
15580
+ if (pin !== mine || !vp) return;
15581
+ mine.line = vp.readingLine.get();
15582
+ reach();
15583
+ });
15584
+ });
15585
+ }
15586
+ /** The visible item whose title starts with what was typed, searching on from `from`. */
15587
+ function typeAhead(char, from) {
15588
+ clearTimeout(typedTimer);
15589
+ typed += char.toLowerCase();
15590
+ typedTimer = setTimeout(() => {
15591
+ typed = "";
15592
+ }, TYPE_AHEAD_MS);
15593
+ const list = visible();
15594
+ const at = list.indexOf(from);
15595
+ const start = typed.length === 1 ? at + 1 : at;
15596
+ for (let k = 0; k < list.length; k++) {
15597
+ const node = list[(start + k) % list.length];
15598
+ if (node.item.title.toLowerCase().startsWith(typed)) return node;
15599
+ }
15600
+ }
15601
+ function onKeydown(e) {
15602
+ if (destroyed || e.defaultPrevented || e.isComposing) return;
15603
+ if (e.altKey || e.ctrlKey || e.metaKey) return;
15604
+ const target = e.target;
15605
+ if (target?.ownerDocument) ownerDoc = target.ownerDocument;
15606
+ const el = target && typeof target.closest === "function" ? target.closest("[role=\"treeitem\"]") : null;
15607
+ const stop = tabStop();
15608
+ const node = el && byDomId.get(el.id) || (stop === null ? void 0 : byId.get(stop));
15609
+ if (!node) return;
15610
+ const list = visible();
15611
+ const at = list.indexOf(node);
15612
+ let to;
15613
+ switch (e.key) {
15614
+ case "ArrowDown":
15615
+ to = list[at + 1] ?? node;
15616
+ break;
15617
+ case "ArrowUp":
15618
+ to = list[at - 1] ?? node;
15619
+ break;
15620
+ case "Home":
15621
+ to = list[0];
15622
+ break;
15623
+ case "End":
15624
+ to = list.at(-1);
15625
+ break;
15626
+ case "ArrowRight":
15627
+ to = node;
15628
+ if (hasChildren(node)) {
15629
+ if (!isExpanded(node)) toggle(node.item.id);
15630
+ else to = byId.get(node.item.children[0].id);
15631
+ }
15632
+ break;
15633
+ case "ArrowLeft":
15634
+ to = node;
15635
+ if (hasChildren(node) && isExpanded(node)) toggle(node.item.id);
15636
+ else if (node.parent) to = node.parent;
15637
+ break;
15638
+ case "Enter":
15639
+ e.preventDefault();
15640
+ activate(node.item.id);
15641
+ return;
15642
+ case "*": {
15643
+ const siblings = node.parent ? node.parent.item.children : items.get();
15644
+ const next = new Set(expanded.get());
15645
+ for (const s of siblings) if (s.children.length > 0) next.add(s.id);
15646
+ setExpanded(next);
15647
+ to = node;
15648
+ break;
15649
+ }
15650
+ default:
15651
+ if (e.key.length !== 1 || e.key === " " && typed === "") return;
15652
+ to = typeAhead(e.key, node) ?? node;
15653
+ }
15654
+ e.preventDefault();
15655
+ if (to) moveTo(to.item.id);
15656
+ }
15657
+ /** Scrolls `row` into view inside `scroller` by the least amount; never any ancestor. */
15658
+ function scrollWithin(scroller, row) {
15659
+ const box = scroller.getBoundingClientRect();
15660
+ const r = row.getBoundingClientRect();
15661
+ if (r.top < box.top) scroller.scrollTop -= box.top - r.top;
15662
+ else if (r.bottom > box.bottom) scroller.scrollTop += Math.min(r.bottom - box.bottom, r.top - box.top);
15663
+ }
15664
+ /** The live `bindScroll` bindings, undone by `destroy`. */
15665
+ const bindings = /* @__PURE__ */ new Set();
15666
+ function bindScroll(scroller) {
15667
+ if (destroyed) return () => void 0;
15668
+ ownerDoc = scroller.ownerDocument;
15669
+ const view = ownerDoc.defaultView;
15670
+ /** Set while the current item waits for a hidden list to show. */
15671
+ let pending = false;
15672
+ let frame = 0;
15673
+ function reveal() {
15674
+ const id = current.get();
15675
+ if (id === null) return;
15676
+ const active = ownerDoc?.activeElement;
15677
+ if (active && scroller.contains(active)) return;
15678
+ if (scroller.clientHeight === 0) {
15679
+ pending = true;
15680
+ return;
15681
+ }
15682
+ pending = false;
15683
+ const row = rowFor(id);
15684
+ if (row && scroller.contains(row)) return scrollWithin(scroller, row);
15685
+ if (view) {
15686
+ view.cancelAnimationFrame(frame);
15687
+ frame = view.requestAnimationFrame(() => {
15688
+ const later = current.get() === id ? rowFor(id) : null;
15689
+ if (later && scroller.contains(later)) scrollWithin(scroller, later);
15690
+ });
15691
+ }
15692
+ }
15693
+ const stopCurrent = current.subscribe(reveal);
15694
+ const stopItems = items.subscribe(reveal);
15695
+ const resize = view && "ResizeObserver" in view ? new view.ResizeObserver(() => {
15696
+ if (pending) reveal();
15697
+ }) : null;
15698
+ resize?.observe(scroller);
15699
+ reveal();
15700
+ const unbind = () => {
15701
+ if (!bindings.delete(unbind)) return;
15702
+ stopCurrent();
15703
+ stopItems();
15704
+ resize?.disconnect();
15705
+ if (view) view.cancelAnimationFrame(frame);
15706
+ };
15707
+ bindings.add(unbind);
15708
+ return unbind;
15709
+ }
15710
+ return {
15711
+ items: readonly(items),
15712
+ loading: readonly(loading),
15713
+ expanded: readonly(expanded),
15714
+ current: readonly(current),
15715
+ focused: readonly(focused),
15716
+ toggle,
15717
+ expandAll() {
15718
+ if (destroyed) return;
15719
+ setExpanded(new Set(nodes.filter(hasChildren).map((n) => n.item.id)));
15720
+ },
15721
+ collapseAll() {
15722
+ if (destroyed) return;
15723
+ setExpanded(/* @__PURE__ */ new Set());
15724
+ },
15725
+ activate,
15726
+ treeAttrs: outlineTreeAttrs,
15727
+ titleAttrs: (id) => ({ id: `${domId(id)}-title` }),
15728
+ rowAttrs: (id) => ({ id: `${domId(id)}-row` }),
15729
+ itemAttrs(id) {
15730
+ const node = byId.get(id);
15731
+ return {
15732
+ role: "treeitem",
15733
+ id: domId(id),
15734
+ "aria-level": node ? String(node.level) : void 0,
15735
+ "aria-setsize": node ? String(node.setsize) : void 0,
15736
+ "aria-posinset": node ? String(node.posinset) : void 0,
15737
+ "aria-expanded": node && hasChildren(node) ? String(isExpanded(node)) : void 0,
15738
+ "aria-current": current.get() === id ? "location" : void 0,
15739
+ "aria-disabled": node?.item.target.kind === "unsupported" ? "true" : void 0,
15740
+ "aria-labelledby": `${domId(id)}-title`,
15741
+ tabindex: tabStop() === id ? "0" : "-1"
15742
+ };
15743
+ },
15744
+ onKeydown,
15745
+ bindScroll,
15746
+ destroy() {
15747
+ if (destroyed) return;
15748
+ for (const unbind of [...bindings]) unbind();
15749
+ destroyed = true;
15750
+ unpin();
15751
+ generation++;
15752
+ clearTimeout(typedTimer);
15753
+ stopDocument();
15754
+ stopViewport();
15755
+ stopLine?.();
15756
+ stopLine = null;
15757
+ }
15758
+ };
15759
+ }
15760
+ //#endregion
15761
+ //#region src/dom/outline/outline.ts
15762
+ /**
15763
+ * The open document's outline as a tree. Give it a height (it fills its parent's) and place it
15764
+ * anywhere, a sidebar panel for instance. Clicking an item, or Enter on it, follows it and can be
15765
+ * gone back from; its chevron expands and collapses it. The item the reader has reached is
15766
+ * marked and kept in view. It stops when the viewer is destroyed.
15767
+ */
15768
+ function createOutline(viewer, opts = {}) {
15769
+ const controller = createOutlineController({
15770
+ document: viewer.document,
15771
+ viewport: viewer.viewport,
15772
+ navigation: navigationOf(viewer)
15773
+ });
15774
+ const tree = h("ul", {
15775
+ class: "ink-outline-tree",
15776
+ ...controller.treeAttrs()
15777
+ });
15778
+ const empty = h("p", { class: "ink-outline-empty" }, opts.emptyText ?? "This document has no outline");
15779
+ const root = h("div", {
15780
+ class: "ink-outline",
15781
+ "data-ink-outline": ""
15782
+ }, tree, empty);
15783
+ /** Each item's element and, for items with children, its group. */
15784
+ const rows = /* @__PURE__ */ new Map();
15785
+ function build(list, into) {
15786
+ for (const item of list) {
15787
+ const parent = item.children.length > 0;
15788
+ const row = h("li", {
15789
+ class: "ink-outline-item",
15790
+ "data-ink-outline-item": item.id
15791
+ }, h("div", {
15792
+ class: "ink-outline-row",
15793
+ ...controller.rowAttrs(item.id)
15794
+ }, parent ? h("span", {
15795
+ class: "ink-outline-toggle",
15796
+ "aria-hidden": "true"
15797
+ }, createIcon(ChevronRight)) : h("span", {
15798
+ class: "ink-outline-spacer",
15799
+ "aria-hidden": "true"
15800
+ }), h("span", {
15801
+ class: "ink-outline-title",
15802
+ ...controller.titleAttrs(item.id)
15803
+ }, item.title)));
15804
+ if (item.bold) row.setAttribute("data-bold", "");
15805
+ if (item.italic) row.setAttribute("data-italic", "");
15806
+ row.style.setProperty("--ink-outline-level", String(item.id.split(".").length - 1));
15807
+ let group = null;
15808
+ if (parent) {
15809
+ group = h("ul", {
15810
+ class: "ink-outline-group",
15811
+ role: "group"
15812
+ });
15813
+ build(item.children, group);
15814
+ row.append(group);
15815
+ }
15816
+ rows.set(item.id, {
15817
+ row,
15818
+ group
15819
+ });
15820
+ into.append(row);
15821
+ }
15822
+ }
15823
+ /** Brings every item's attributes and its group's visibility up to date. */
15824
+ function sync() {
15825
+ const open = controller.expanded.get();
15826
+ for (const [id, { row, group }] of rows) {
15827
+ for (const [name, value] of Object.entries(controller.itemAttrs(id))) if (value === void 0) row.removeAttribute(name);
15828
+ else if (row.getAttribute(name) !== value) row.setAttribute(name, value);
15829
+ if (group) group.hidden = !open.has(id);
15830
+ }
15831
+ }
15832
+ /** The empty text shows once a document is read and has no outline. */
15833
+ function status() {
15834
+ const loading = controller.loading.get();
15835
+ const none = controller.items.get().length === 0;
15836
+ tree.hidden = none;
15837
+ empty.hidden = loading || !none;
15838
+ if (loading) root.setAttribute("aria-busy", "true");
15839
+ else root.removeAttribute("aria-busy");
15840
+ }
15841
+ function render() {
15842
+ rows.clear();
15843
+ tree.replaceChildren();
15844
+ build(controller.items.get(), tree);
15845
+ sync();
15846
+ status();
15847
+ }
15848
+ /**
15849
+ * The item whose row holds `target`. Only rows take clicks, as in the bindings: a click on a
15850
+ * group's padding or the item around its row belongs to no item, not to the parent.
15851
+ */
15852
+ const itemOf = (target) => {
15853
+ const row = target instanceof Element ? target.closest(".ink-outline-row")?.closest("[data-ink-outline-item]") : null;
15854
+ return row && root.contains(row) ? row.dataset.inkOutlineItem ?? null : null;
15855
+ };
15856
+ tree.addEventListener("click", (e) => {
15857
+ const id = itemOf(e.target);
15858
+ if (id === null) return;
15859
+ if (e.target.closest(".ink-outline-toggle")) controller.toggle(id);
15860
+ else controller.activate(id);
15861
+ });
15862
+ tree.addEventListener("keydown", (e) => controller.onKeydown(e));
15863
+ render();
15864
+ const stops = [
15865
+ controller.items.subscribe(render),
15866
+ controller.loading.subscribe(status),
15867
+ controller.expanded.subscribe(sync),
15868
+ controller.current.subscribe(sync),
15869
+ controller.focused.subscribe(sync)
15870
+ ];
15871
+ const unbind = controller.bindScroll(root);
15872
+ onViewerDestroy(viewer, () => {
15873
+ unbind();
15874
+ stops.forEach((stop) => stop());
15875
+ controller.destroy();
15876
+ });
15877
+ return root;
15878
+ }
15879
+ //#endregion
14427
15880
  //#region src/dom/viewer/style-menu.ts
14428
15881
  /** A button that opens the line style menu: weight, solid or dashed, and arrowheads. */
14429
15882
  function createLineStyleMenu(viewer) {
@@ -15120,6 +16573,6 @@ function createHighlightMenu(viewer) {
15120
16573
  return root;
15121
16574
  }
15122
16575
  //#endregion
15123
- 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, FORM_LABELS, 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, THUMBNAIL_WIDTH, TOOL_LABELS, ZOOM_LEVELS, allTools, annotationElementFor, arrowheadChoice, attachFileKeys, attachHistoryKeys, attachTouchPinch, attachTrackpadPinch, attachViewportKeys, clampFontSize, colorMenuModel, computeCssLayout, createColorMenu, createCommandButton, createDownloadButton, createFindBar, createFocusReturn, createFormAlert, createHighlightMenu, createIcon, createImageSignature, createLineStyleMenu, createMenu, createNextPage, createOverlay, createPageCount, createPageInput, createPageMount, createPageRenderer, createPageTransform, createPluginHost, createPluginLayers, createPopover, createPrevPage, createPrintButton, createRecordList, createRecordListController, createRedo, createSearchButton, createSearchController, createSearchResults, createShapeMenu, createSidebar, createSidebarController, createSignFieldMenu, createSignatureDialog, createSignatureDraft, createSignatureMenu, createSignaturePad, createSignaturePreview, createTextLayer, createTextStyleMenu, createThumbnail, createThumbnailPainter, createThumbnails, createThumbnailsController, createToolButton, createToolController, createToolOptions, createToolbar, createToolbarSeparator, createTypedSignature, createUndo, createViewer, createViewport, createZoomIn, createZoomOut, createZoomPreview, createZoomSelect, cssRectsToUserRects, cssVariableDefaults, definePlugin, downloadDocument, drawTool, editFreeText, findAnnouncer, findCountText, fitZoomCss, focusInitialMenuItem, fontFamilyValue, freetextTool, getSelectionQuads, handleFindKey, handleMenuKeys, handleRadioKeys, handleResultsFieldKey, handleTabKeys, highlightMenuModel, highlightTool, hitFor, isBlankQuery, keepHistoryFocus, lineStyleMenuModel, menuItems, mostVisiblePageCss, mountViewer, normalizeRotation, noteTool, observeAnnotationElement, pageSelectionText, pinchFrame, placePanel, printDocument, recordsSource, redactAreaTool, renderAnnotationContent, renderTypedSignature, searchSource, selectTextTool, selectTool, shapeMenuModel, shapeTool, shouldKeepOpenOnAnnotation, sidebarBadge, signField, signatureMenuModel, signaturePreview, signatureTool, sketchTool, textStyleMenuModel, threadDeleteLabel, threadPopoverLabel, thumbnailSize, trackToolStyle, trapTab, unknownCommand, unknownTool, viewerSearchOf, visibleRangeCss, watchDetached, wheelFactor, withHostTools };
16576
+ 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, FORM_LABELS, LINE_WEIGHTS, MARKUP_ICONS, MARKUP_KINDS, MARKUP_LABELS, PALETTE, SHAPE_COLUMNS, SHAPE_ICONS, SHAPE_KINDS, SHAPE_LABELS, SIDEBAR_WIDTH, SIGNATURE_FONTS, SIGNATURE_IMAGE_MAX_LENGTH, SIGNATURE_IMAGE_MAX_SIDE, SIGNATURE_IMAGE_TYPES, SIGNATURE_LABELS, SIGNATURE_TABS, THUMBNAIL_WIDTH, TOOL_LABELS, ZOOM_LEVELS, allTools, annotationElementFor, arrowheadChoice, attachFileKeys, attachHistoryKeys, attachTouchPinch, attachTrackpadPinch, attachViewportKeys, clampFontSize, colorMenuModel, computeCssLayout, createBack, createColorMenu, createCommandButton, createDownloadButton, createFindBar, createFocusReturn, createFormAlert, createForward, createHighlightMenu, createIcon, createImageSignature, createLineStyleMenu, createMenu, createNextPage, createOutline, createOutlineController, createOverlay, createPageCount, createPageInput, createPageMount, createPageRenderer, createPageTransform, createPluginHost, createPluginLayers, createPopover, createPrevPage, createPrintButton, createRecordList, createRecordListController, createRedo, createSearchButton, createSearchController, createSearchResults, createShapeMenu, createSidebar, createSidebarController, createSignFieldMenu, createSignatureDialog, createSignatureDraft, createSignatureMenu, createSignaturePad, createSignaturePreview, createTextLayer, createTextStyleMenu, createThumbnail, createThumbnailPainter, createThumbnails, createThumbnailsController, createToolButton, createToolController, createToolOptions, createToolbar, createToolbarSeparator, createTypedSignature, createUndo, createViewer, createViewport, createZoomIn, createZoomOut, createZoomPreview, createZoomSelect, cssRectsToUserRects, cssVariableDefaults, definePlugin, downloadDocument, drawTool, editFreeText, findAnnouncer, findCountText, fitZoomCss, focusInitialMenuItem, fontFamilyValue, freetextTool, getSelectionQuads, handleFindKey, handleMenuKeys, handleRadioKeys, handleResultsFieldKey, handleTabKeys, highlightMenuModel, highlightTool, hitFor, isBlankQuery, keepHistoryFocus, lineStyleMenuModel, menuItems, mostVisiblePageCss, mountViewer, normalizeRotation, noteTool, observeAnnotationElement, outlineTreeAttrs, pageSelectionText, pinchFrame, placePanel, printDocument, recordsSource, redactAreaTool, renderAnnotationContent, renderTypedSignature, searchSource, selectTextTool, selectTool, shapeMenuModel, shapeTool, shouldKeepOpenOnAnnotation, sidebarBadge, signField, signatureMenuModel, signaturePreview, signatureTool, sketchTool, textStyleMenuModel, threadDeleteLabel, threadPopoverLabel, thumbnailSize, trackToolStyle, trapTab, unknownCommand, unknownTool, viewerSearchOf, visibleRangeCss, watchDetached, wheelFactor, withHostTools };
15124
16577
 
15125
16578
  //# sourceMappingURL=dom.mjs.map