@inkbar/core 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/dom.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { B as isBubble, Bt as isStandardCallout, D as acceptSuggestion, E as withoutReplies, G as resizedBox, H as keepsAspect, It as confidenceBucket, J as translated, K as resizedRect, Kt as isKnown, L as hasCallout, Q as deriveRect, Qt as mergeLineRects, R as hasCornerHandles, Rt as calloutStroke, S as isThreadRoot, T as threadRoot, U as movedEndpoint, Ut as LINE_ENDINGS, V as isTextMarkup, Vt as withCalloutBounds, W as movedTip, X as openDocument, Y as withBox, Yt as cssVariableDefaults, _ as defaultEnds, _n as InkError, b as isAbandonedNote, d as recogniseStroke, en as readonly, f as DEFAULT_CORNER_RADIUS, fn as rectsIntersect, g as constrain, h as SHAPE_KINDS, i as createInkTemplate, in as invertMatrix, k as createToolStyle, l as placeSignature, m as DEFAULT_SHAPE_SIZE, nn as applyMatrix, o as createMemorySignatureStore, p as DEFAULT_SHAPE_OPTIONS, q as scaled, r as createImageTemplate, s as createSignatureLibrary, tn as writable, un as rectFromPoints, v as isPointerOnlyKind, vn as isInkError, w as threadOf, y as shapeInput, z as hasRectGeometry, zt as freeTextBox } from "./signatures-DF2VmNVa.mjs";
1
+ import { $t as derived, B as isTextMarkup, Bt as withCalloutBounds, E as withoutReplies, G as resizedRect, Gt as isKnown, H as movedEndpoint, Ht as LINE_ENDINGS, I as hasCallout, J as withBox, Jt as cssVariableDefaults, K as scaled, L as hasCornerHandles, Lt as calloutStroke, O as createToolStyle, Qt as createRecordStore, R as hasRectGeometry, Rt as freeTextBox, S as isThreadRoot, T as threadRoot, U as movedTip, V as keepsAspect, W as resizedBox, Y as openDocument, Z as deriveRect, Zt as mergeLineRects, _ as defaultEnds, b as isAbandonedNote, bn as isInkError, d as recogniseStroke, dn as rectFromPoints, en as readonly, f as DEFAULT_CORNER_RADIUS, g as constrain, h as SHAPE_KINDS, i as createInkTemplate, in as invertMatrix, l as placeSignature, m as DEFAULT_SHAPE_SIZE, mn as rectsIntersect, nn as applyMatrix, o as createMemorySignatureStore, p as DEFAULT_SHAPE_OPTIONS, q as translated, r as createImageTemplate, s as createSignatureLibrary, tn as writable, tt as isProduction, v as isPointerOnlyKind, w as threadOf, y as shapeInput, yn as InkError, z as isBubble, zt as isStandardCallout } from "./signatures-D68fuLlt.mjs";
2
2
  import { ALargeSmall, AlignCenter, AlignJustify, AlignLeft, AlignRight, Bold, Check, ChevronDown, ChevronLeft, ChevronRight, Circle, CircleDashed, Hexagon, Highlighter, ICON_ATTRIBUTES, Italic, MessageCircle, MessageSquare, MessageSquareText, Minus, MoveUpRight, PenLine, PenTool, Plus, RectangleHorizontal, Redo2, Search, Signature, Slash, SpellCheck2, Square, SquareDashed, SquareDashedMousePointer, Star, Strikethrough, TextCursor, Trash2, Type, Underline, Undo2, ZoomIn, ZoomOut } from "@inkbar/icons";
3
3
  import { getStroke } from "perfect-freehand";
4
4
  //#region src/dom/transform.ts
@@ -500,7 +500,7 @@ function createViewport(opts) {
500
500
  });
501
501
  sync();
502
502
  },
503
- scrollToRect(page, rect) {
503
+ scrollToRect(page, rect, o) {
504
504
  if (mode.get() === "single" && currentPage.get() !== page) {
505
505
  currentPage.set(page);
506
506
  relayout();
@@ -508,11 +508,13 @@ function createViewport(opts) {
508
508
  const box = boxFor(page);
509
509
  if (!box) return;
510
510
  const css = transformFor(page).toCssRect(rect);
511
- const o = origin();
512
- const top = o.y + box.top + css.top;
513
- if (top < container.scrollTop || top + css.height > container.scrollTop + container.clientHeight) container.scrollTop = Math.max(0, top - (container.clientHeight - css.height) / 2);
514
- const left = o.x + box.left + css.left;
515
- if (left < container.scrollLeft || left + css.width > container.scrollLeft + container.clientWidth) container.scrollLeft = Math.max(0, left - (container.clientWidth - css.width) / 2);
511
+ const at = origin();
512
+ const to = { behavior: o?.behavior ?? "auto" };
513
+ const top = at.y + box.top + css.top;
514
+ if (top < container.scrollTop || top + css.height > container.scrollTop + container.clientHeight) to.top = Math.max(0, top - (container.clientHeight - css.height) / 2);
515
+ const left = at.x + box.left + css.left;
516
+ if (left < container.scrollLeft || left + css.width > container.scrollLeft + container.clientWidth) to.left = Math.max(0, left - (container.clientWidth - css.width) / 2);
517
+ if (to.top !== void 0 || to.left !== void 0) container.scrollTo(to);
516
518
  sync();
517
519
  },
518
520
  transformFor,
@@ -629,7 +631,7 @@ function watchDevicePixelRatio(view, current, onChange) {
629
631
  }
630
632
  //#endregion
631
633
  //#region src/dom/page-renderer.ts
632
- const isCancellation$1 = (error) => error?.name === "RenderingCancelledException";
634
+ const isCancellation$2 = (error) => error?.name === "RenderingCancelledException";
633
635
  function createPageRenderer(opts) {
634
636
  const { document: doc, page, host } = opts;
635
637
  const view = host.ownerDocument.defaultView;
@@ -715,7 +717,7 @@ function createPageRenderer(opts) {
715
717
  opts.onRendered?.();
716
718
  } catch (error) {
717
719
  if (canvas && canvas !== current) release(canvas);
718
- if (!((destroyed || gen !== generation) && isCancellation$1(error)) && !destroyed) opts.onError?.(error);
720
+ if (!((destroyed || gen !== generation) && isCancellation$2(error)) && !destroyed) opts.onError?.(error);
719
721
  } finally {
720
722
  if (gen === generation) settle();
721
723
  }
@@ -966,6 +968,8 @@ const CORNERS$1 = [
966
968
  ];
967
969
  /** Width in CSS px of the band along a mask's edge that takes presses. */
968
970
  const MASK_EDGE = 6;
971
+ /** Width in CSS px of the band straddling a text box's edge, which drags the box. */
972
+ const TEXT_EDGE = 6;
969
973
  /**
970
974
  * Syncs an element's edit handles (`.ink-handle[data-handle]`) with `spec`. Each is an 8px
971
975
  * `position:absolute; pointer-events:auto` box centred on its point; its look is the theme's.
@@ -999,15 +1003,62 @@ function syncHandles(node, spec) {
999
1003
  h.style.top = `${at.y - HANDLE_PX / 2}px`;
1000
1004
  }
1001
1005
  }
1006
+ /** Width in CSS px a thin stroke's invisible hit twin is widened to. */
1007
+ const HIT_STROKE = 8;
1008
+ /**
1009
+ * True when `a` is drawn as a stroke alone (a line, polyline, ink, or an unfilled square,
1010
+ * circle or polygon with a border), so a press beside the stroke would miss it. These get an
1011
+ * invisible, wider hit twin of their geometry.
1012
+ */
1013
+ function hasHitTwin(a) {
1014
+ switch (a.type) {
1015
+ case "line":
1016
+ case "polyline":
1017
+ case "ink": return true;
1018
+ case "polygon":
1019
+ case "square":
1020
+ case "circle": return a.interiorColor === void 0 && a.width > 0;
1021
+ default: return false;
1022
+ }
1023
+ }
1024
+ /** The width in CSS px of `a`'s hit twin: 8px, or the drawn stroke if that is wider. */
1025
+ function hitWidth(a, t) {
1026
+ return Math.max(HIT_STROKE, "width" in a ? t.toCssLength(a.width) : 0);
1027
+ }
1002
1028
  /**
1003
1029
  * The CSS box an annotation's default visual occupies. Usually `toCssRect(a.rect)`. Stroked
1004
1030
  * paths (lines, polylines, polygons, ink) use the bounds of their own points instead of
1005
1031
  * `rect` (which may already carry stroke padding, as a derived ink rect does), padded once by
1006
1032
  * half their stroke width on every side, at least 1px in all, so a horizontal or vertical
1007
- * line still has a box to draw and hit-test in. Custom elements do not use this: they sit on
1008
- * `toCssRect(a.rect)`.
1033
+ * line still has a box to draw and hit-test in. A shape with a hit twin (see `hasHitTwin`)
1034
+ * is padded by at least 4px more, so its wider hit area is not clipped. Custom elements do
1035
+ * not use this: they sit on `toCssRect(a.rect)`.
1009
1036
  */
1010
1037
  function annotationBox(a, t) {
1038
+ const drawn = handleBox(a, t);
1039
+ if (!hasHitTwin(a)) return drawn;
1040
+ const pad = hitPad(a, t);
1041
+ return {
1042
+ left: drawn.left - pad,
1043
+ top: drawn.top - pad,
1044
+ width: drawn.width + pad * 2,
1045
+ height: drawn.height + pad * 2
1046
+ };
1047
+ }
1048
+ /**
1049
+ * How far past `handleBox` a shape with a hit twin extends its element: the twin's overhang
1050
+ * beyond the drawn stroke, and at least 4px.
1051
+ */
1052
+ function hitPad(a, t) {
1053
+ const overhang = Math.max(0, hitWidth(a, t) - t.toCssLength("width" in a ? a.width : 0));
1054
+ return Math.max(HIT_STROKE / 2, overhang / 2);
1055
+ }
1056
+ /**
1057
+ * The CSS box an annotation's edit handles and resizing work on: `annotationBox` without the
1058
+ * padding its hit twin needs. `toCssRect(a.rect)` for a rect-shaped annotation; the padded
1059
+ * bounds of its points for a stroked path.
1060
+ */
1061
+ function handleBox(a, t) {
1011
1062
  const points = a.type === "line" ? [a.start, a.end] : a.type === "polyline" || a.type === "polygon" ? a.vertices : a.type === "ink" ? a.inklist.flat() : void 0;
1012
1063
  if (!points || points.length === 0 || !("width" in a)) return t.toCssRect(a.rect);
1013
1064
  const box = t.toCssRect(pointBounds(points));
@@ -1200,7 +1251,7 @@ function renderAnnotationContent(el, a, t) {
1200
1251
  text.style.height = `${inner.height}px`;
1201
1252
  }
1202
1253
  text.style.pointerEvents = "auto";
1203
- el.append(text);
1254
+ el.append(text, edgeBand(doc, box, freeTextLocalBox(a, t)));
1204
1255
  appendSelectionFrame(el, a, t);
1205
1256
  return;
1206
1257
  }
@@ -1210,6 +1261,8 @@ function renderAnnotationContent(el, a, t) {
1210
1261
  }
1211
1262
  const svg = boxSvg(doc, box.width, box.height);
1212
1263
  svg.classList.add("ink-annotation-shape");
1264
+ /** Invisible wider copies of stroke-only geometry, added once the drawn shapes are. */
1265
+ const twins = [];
1213
1266
  switch (a.type) {
1214
1267
  case "highlight":
1215
1268
  for (const q of a.quads) svg.append(node("polygon", {
@@ -1252,23 +1305,34 @@ function renderAnnotationContent(el, a, t) {
1252
1305
  case "square":
1253
1306
  case "circle": {
1254
1307
  const sw = t.toCssLength(a.width);
1255
- const shape = a.type === "square" ? node("rect", {
1256
- x: sw / 2,
1257
- y: sw / 2,
1258
- width: Math.max(0, box.width - sw),
1259
- height: Math.max(0, box.height - sw),
1260
- ...a.cornerRadius ? { rx: t.toCssLength(a.cornerRadius) } : {}
1261
- }) : node("ellipse", {
1262
- cx: box.width / 2,
1263
- cy: box.height / 2,
1264
- rx: Math.max(0, box.width / 2 - sw / 2),
1265
- ry: Math.max(0, box.height / 2 - sw / 2)
1266
- });
1308
+ const pad = hasHitTwin(a) ? hitPad(a, t) : 0;
1309
+ const w = box.width - pad * 2;
1310
+ const h = box.height - pad * 2;
1311
+ const geometry = a.type === "square" ? {
1312
+ name: "rect",
1313
+ attrs: {
1314
+ x: pad + sw / 2,
1315
+ y: pad + sw / 2,
1316
+ width: Math.max(0, w - sw),
1317
+ height: Math.max(0, h - sw),
1318
+ ...a.cornerRadius ? { rx: t.toCssLength(a.cornerRadius) } : {}
1319
+ }
1320
+ } : {
1321
+ name: "ellipse",
1322
+ attrs: {
1323
+ cx: box.width / 2,
1324
+ cy: box.height / 2,
1325
+ rx: Math.max(0, w / 2 - sw / 2),
1326
+ ry: Math.max(0, h / 2 - sw / 2)
1327
+ }
1328
+ };
1329
+ const shape = node(geometry.name, geometry.attrs);
1267
1330
  shape.setAttribute("fill", a.interiorColor ?? "none");
1268
1331
  shape.setAttribute("stroke", a.color);
1269
1332
  shape.setAttribute("stroke-width", String(sw));
1270
1333
  dash(shape, a.dashes, t);
1271
1334
  svg.append(shape);
1335
+ if (hasHitTwin(a)) twins.push(hitTwin(node, geometry.name, geometry.attrs, hitWidth(a, t)));
1272
1336
  svg.setAttribute("opacity", String(a.opacity));
1273
1337
  break;
1274
1338
  }
@@ -1320,6 +1384,10 @@ function renderAnnotationContent(el, a, t) {
1320
1384
  });
1321
1385
  dash(stroked, a.dashes, t);
1322
1386
  svg.append(stroked);
1387
+ if (hasHitTwin(a)) twins.push(hitTwin(node, stroked.tagName, {
1388
+ points: stroked.getAttribute("points"),
1389
+ "stroke-linejoin": "round"
1390
+ }, hitWidth(a, t)));
1323
1391
  const ends = endingKinds(a);
1324
1392
  if (ends && pts.length >= 2) for (const [name, kind, tip, other] of [[
1325
1393
  "head",
@@ -1344,6 +1412,11 @@ function renderAnnotationContent(el, a, t) {
1344
1412
  });
1345
1413
  ending.setAttribute("data-ending", name);
1346
1414
  svg.append(ending);
1415
+ twins.push(hitTwin(node, "path", {
1416
+ d,
1417
+ "stroke-linejoin": "round",
1418
+ "stroke-linecap": "round"
1419
+ }, hitWidth(a, t)));
1347
1420
  }
1348
1421
  svg.setAttribute("opacity", String(a.opacity));
1349
1422
  break;
@@ -1356,14 +1429,54 @@ function renderAnnotationContent(el, a, t) {
1356
1429
  d: outlinePath(outline),
1357
1430
  fill: a.color
1358
1431
  }));
1432
+ twins.push(hitTwin(node, "polyline", {
1433
+ points: path.map(local).join(" "),
1434
+ "stroke-linejoin": "round",
1435
+ "stroke-linecap": "round"
1436
+ }, hitWidth(a, t)));
1359
1437
  });
1360
1438
  svg.setAttribute("opacity", String(a.opacity));
1361
1439
  }
1362
1440
  for (const shape of svg.children) shape.setAttribute("pointer-events", "visiblePainted");
1441
+ svg.append(...twins);
1363
1442
  el.append(svg);
1364
1443
  appendCommentMarker(el, a, t);
1365
1444
  }
1366
1445
  /**
1446
+ * An invisible copy of a stroke-only shape's geometry, `width` CSS px wide, that takes
1447
+ * presses on the stroke alone (`[data-ink-hit-stroke]`), so a thin line is easy to hit.
1448
+ */
1449
+ function hitTwin(node, name, attrs, width) {
1450
+ const twin = node(name, attrs);
1451
+ twin.setAttribute("data-ink-hit-stroke", "");
1452
+ twin.setAttribute("fill", "none");
1453
+ twin.setAttribute("stroke", "transparent");
1454
+ twin.setAttribute("stroke-width", String(width));
1455
+ twin.setAttribute("pointer-events", "stroke");
1456
+ return twin;
1457
+ }
1458
+ /**
1459
+ * The band a press on a free text's edge lands on: an invisible `rect[data-ink-edge]` stroke
1460
+ * `TEXT_EDGE` px wide along the text box's edge, half inside it and half out, in its own SVG
1461
+ * over the element. A press inside the box edits it; one on the band selects and drags it.
1462
+ * It is there whether or not the box draws a border. Geometry only.
1463
+ */
1464
+ function edgeBand(doc, el, box) {
1465
+ const svg = boxSvg(doc, el.width, el.height);
1466
+ const band = doc.createElementNS(SVG$2, "rect");
1467
+ band.setAttribute("x", String(box.left));
1468
+ band.setAttribute("y", String(box.top));
1469
+ band.setAttribute("width", String(box.width));
1470
+ band.setAttribute("height", String(box.height));
1471
+ band.setAttribute("fill", "none");
1472
+ band.setAttribute("stroke", "transparent");
1473
+ band.setAttribute("stroke-width", String(TEXT_EDGE));
1474
+ band.setAttribute("pointer-events", "stroke");
1475
+ band.setAttribute("data-ink-edge", "");
1476
+ svg.append(band);
1477
+ return svg;
1478
+ }
1479
+ /**
1367
1480
  * A stamp's `img.ink-stamp`, filling the element's box. The image is upright in user space
1368
1481
  * turned by the stamp's `rotation` (anticlockwise), and the page turns it again on screen
1369
1482
  * (clockwise), so it is drawn turned by the difference: a signature placed against the page's
@@ -1660,7 +1773,8 @@ const SAME_PX = .5;
1660
1773
  const MIN_WIDTH_EM = 2;
1661
1774
  /**
1662
1775
  * Opens an editor on `el`, the overlay element showing `a` (or a stand-in for a box not in the
1663
- * store yet). `Escape` commits; a press outside the editor, or focus leaving it, commits too.
1776
+ * store yet), with the caret at `opts.caret` or after the text. `Escape` commits; a press
1777
+ * outside the editor, or focus leaving it, commits too.
1664
1778
  * Closing while the editor has focus hands focus to `el`. Keys typed in the
1665
1779
  * editor stop there, so no viewport or tool shortcut sees them. Opening a second editor on the
1666
1780
  * same element commits the first.
@@ -1716,7 +1830,7 @@ function editFreeText(el, a, opts) {
1716
1830
  return Math.abs(grown) < SAME_PX ? 0 : grown;
1717
1831
  };
1718
1832
  let closed = false;
1719
- const finish = (commit) => {
1833
+ const finish = (commit, how) => {
1720
1834
  if (closed) return;
1721
1835
  closed = true;
1722
1836
  const contents = read();
@@ -1730,27 +1844,27 @@ function editFreeText(el, a, opts) {
1730
1844
  box?.style.removeProperty("visibility");
1731
1845
  el.style.height = `${base.height}px`;
1732
1846
  refitBubble(el, a, opts.transform, base);
1733
- if (commit) opts.commit(contents, next, grown);
1847
+ if (commit) opts.commit(contents, next, grown, how);
1734
1848
  else opts.cancel();
1735
1849
  };
1736
1850
  const onOutside = (e) => {
1737
1851
  if (e.target instanceof Node && field.contains(e.target)) return;
1738
1852
  closingPresses.add(e);
1739
- finish(true);
1853
+ finish(true, "press");
1740
1854
  };
1741
1855
  view?.addEventListener("pointerdown", onOutside, true);
1742
1856
  field.addEventListener("keydown", (e) => {
1743
1857
  e.stopPropagation();
1744
1858
  if (e.key === "Escape" && !e.isComposing) {
1745
1859
  e.preventDefault();
1746
- finish(true);
1860
+ finish(true, "escape");
1747
1861
  }
1748
1862
  });
1749
1863
  field.addEventListener("focusout", (e) => {
1750
1864
  if (!doc.hasFocus()) return;
1751
1865
  const to = e.relatedTarget;
1752
1866
  if (to instanceof Node && field.contains(to)) return;
1753
- finish(true);
1867
+ finish(true, "focus");
1754
1868
  });
1755
1869
  field.addEventListener("paste", (e) => {
1756
1870
  e.preventDefault();
@@ -1762,14 +1876,17 @@ function editFreeText(el, a, opts) {
1762
1876
  field.focus({ preventScroll: true });
1763
1877
  const selection = doc.getSelection();
1764
1878
  if (selection) {
1765
- const range = doc.createRange();
1766
- range.selectNodeContents(field);
1767
- range.collapse(false);
1879
+ let range = opts.caret && caretIn(field, opts.caret);
1880
+ if (!range) {
1881
+ range = doc.createRange();
1882
+ range.selectNodeContents(field);
1883
+ range.collapse(false);
1884
+ }
1768
1885
  selection.removeAllRanges();
1769
1886
  selection.addRange(range);
1770
1887
  }
1771
1888
  grow();
1772
- const editor = { close: (commit) => finish(commit) };
1889
+ const editor = { close: (commit) => finish(commit, "api") };
1773
1890
  editors.set(el, editor);
1774
1891
  return editor;
1775
1892
  }
@@ -1785,37 +1902,44 @@ function canEditFreeText(a, el) {
1785
1902
  * Opens an editor on the overlay element `el` for the free-text box `id`, wired to the store: a
1786
1903
  * commit is one `update` with the text and the grown rect (none when neither changed), and an
1787
1904
  * empty box is removed, as an abandoned note is. A speech bubble is the exception: its outline
1788
- * shows without text, so it is kept. Returns undefined when the box cannot be
1789
- * edited (`canEditFreeText`).
1905
+ * shows without text, so it is kept. `onClose` runs after the store change, with what closed
1906
+ * the editor; `caret` is a client point to put the caret at (`editFreeText`). Returns
1907
+ * undefined when the box cannot be edited (`canEditFreeText`).
1790
1908
  */
1791
- function editFreeTextIn(store, el, id, transform) {
1909
+ function editFreeTextIn(store, el, id, transform, opts = {}) {
1792
1910
  const a = store._getProposed(id) ?? store.get(id);
1793
1911
  if (!canEditFreeText(a, el)) return void 0;
1794
1912
  const current = () => {
1795
1913
  const now = store._getProposed(id) ?? store.get(id);
1796
1914
  return now?.type === "freetext" ? now : void 0;
1797
1915
  };
1916
+ const write = (contents, grown) => {
1917
+ const now = current();
1918
+ if (!now) return;
1919
+ if (contents.trim() === "" && !isBubble(now)) {
1920
+ store.remove(id);
1921
+ return;
1922
+ }
1923
+ if (grown === 0) {
1924
+ if (contents !== now.contents) store.update(id, { contents });
1925
+ return;
1926
+ }
1927
+ store.update(id, {
1928
+ contents,
1929
+ ...grownFreeText(now, grown, transform)
1930
+ });
1931
+ };
1798
1932
  return editFreeText(el, a, {
1799
1933
  transform,
1800
- commit(contents, _rect, grown) {
1801
- const now = current();
1802
- if (!now) return;
1803
- if (contents.trim() === "" && !isBubble(now)) {
1804
- store.remove(id);
1805
- return;
1806
- }
1807
- if (grown === 0) {
1808
- if (contents !== now.contents) store.update(id, { contents });
1809
- return;
1810
- }
1811
- store.update(id, {
1812
- contents,
1813
- ...grownFreeText(now, grown, transform)
1814
- });
1934
+ caret: opts.caret,
1935
+ commit(contents, _rect, grown, how) {
1936
+ write(contents, grown);
1937
+ opts.onClose?.(how);
1815
1938
  },
1816
1939
  cancel() {
1817
1940
  const now = current();
1818
1941
  if (now && now.contents.trim() === "" && !isBubble(now)) store.remove(id);
1942
+ opts.onClose?.("api");
1819
1943
  }
1820
1944
  });
1821
1945
  }
@@ -1844,6 +1968,28 @@ function grownFreeText(a, grown, t) {
1844
1968
  };
1845
1969
  return withBox(a, box, resized.callout.map((p, i) => i === last ? p : down(p)));
1846
1970
  }
1971
+ /**
1972
+ * A collapsed range at the text position under a client point, if that position is in `field`.
1973
+ * The editor lies over the box with the same look, so a point on the box lands on the same
1974
+ * character in the editor.
1975
+ */
1976
+ function caretIn(field, at) {
1977
+ const doc = field.ownerDocument;
1978
+ let node;
1979
+ let offset = 0;
1980
+ if (typeof doc.caretPositionFromPoint === "function") {
1981
+ const p = doc.caretPositionFromPoint(at.clientX, at.clientY);
1982
+ if (p) ({offsetNode: node, offset} = p);
1983
+ } else if (typeof doc.caretRangeFromPoint === "function") {
1984
+ const r = doc.caretRangeFromPoint(at.clientX, at.clientY);
1985
+ if (r) ({startContainer: node, startOffset: offset} = r);
1986
+ }
1987
+ if (!node || !field.contains(node)) return void 0;
1988
+ const range = doc.createRange();
1989
+ range.setStart(node, offset);
1990
+ range.collapse(true);
1991
+ return range;
1992
+ }
1847
1993
  /** `plaintext-only` where the browser has it, else `true` (paste and formatting are then guarded). */
1848
1994
  function makeEditable(field) {
1849
1995
  try {
@@ -1874,7 +2020,7 @@ function insertText(field, text) {
1874
2020
  }
1875
2021
  //#endregion
1876
2022
  //#region src/pdfjs/render-region.ts
1877
- const isCancellation = (error) => error?.name === "RenderingCancelledException";
2023
+ const isCancellation$1 = (error) => error?.name === "RenderingCancelledException";
1878
2024
  /**
1879
2025
  * Draws `userRect` of `page` (0-based) into `canvas` at `scale` device pixels per user-space
1880
2026
  * unit (times `/UserUnit`, as PDF.js applies it), turned by `rotation`: the page's /Rotate plus
@@ -1917,7 +2063,7 @@ async function renderRegion(doc, page, userRect, scale, canvas, signal, rotation
1917
2063
  try {
1918
2064
  await task.promise;
1919
2065
  } catch (error) {
1920
- if (signal?.aborted && isCancellation(error)) throw signal.reason;
2066
+ if (signal?.aborted && isCancellation$1(error)) throw signal.reason;
1921
2067
  throw error;
1922
2068
  } finally {
1923
2069
  signal?.removeEventListener("abort", cancel);
@@ -2160,27 +2306,69 @@ const LABELS = {
2160
2306
  mask: "Mask",
2161
2307
  redact: "Redaction"
2162
2308
  };
2163
- /** The `data-confidence` bucket for a suggested redaction; undefined for everything else. */
2164
- function confidenceAttr(a) {
2165
- return a.type === "redact" && a.suggestion ? confidenceBucket(a.suggestion.confidence) : void 0;
2166
- }
2167
2309
  function ariaLabel(a) {
2168
- const base = a.type === "redact" && a.origin === "suggested" ? "Suggested redaction" : a.subject === "Signature" && (a.type === "ink" || a.type === "stamp") ? "Signature" : LABELS[a.type];
2310
+ const base = a.subject === "Signature" && (a.type === "ink" || a.type === "stamp") ? "Signature" : LABELS[a.type];
2169
2311
  const named = hasComment(a) ? `${base} with comment` : base;
2170
2312
  return a.author ? `${named} by ${a.author}` : named;
2171
2313
  }
2172
2314
  /** Reading order in user space: top edge first (y grows upwards), then left edge. */
2173
- function readingOrder(a, b) {
2315
+ function readingOrder$1(a, b) {
2174
2316
  return b.rect.y + b.rect.height - (a.rect.y + a.rect.height) || a.rect.x - b.rect.x;
2175
2317
  }
2318
+ /** A decoration's data key: lower-case kebab, as written after `data-`. */
2319
+ const DECORATION_KEY = /^[a-z][a-z0-9-]*$/;
2320
+ /**
2321
+ * `data-*` keys core writes on an annotation element or inside it; a decoration cannot set
2322
+ * them. `data-ink-*` is core's too.
2323
+ */
2324
+ const RESERVED_DATA = /* @__PURE__ */ new Set([
2325
+ "annotation",
2326
+ "type",
2327
+ "source",
2328
+ "locked",
2329
+ "state",
2330
+ "custom",
2331
+ "handle",
2332
+ "framed",
2333
+ "align",
2334
+ "bubble",
2335
+ "border",
2336
+ "icon",
2337
+ "ending"
2338
+ ]);
2339
+ /** Skipped decoration keys and classes already warned about. */
2340
+ const warnedDecorations = /* @__PURE__ */ new Set();
2341
+ function warnDecoration(what, why) {
2342
+ if (isProduction() || warnedDecorations.has(what)) return;
2343
+ warnedDecorations.add(what);
2344
+ console.warn(`[inkbar] annotation decoration: skipped ${what}: ${why}`);
2345
+ }
2346
+ function usableData(key) {
2347
+ if (!DECORATION_KEY.test(key)) {
2348
+ warnDecoration(`data key "${key}"`, "keys are lower-case kebab");
2349
+ return false;
2350
+ }
2351
+ if (RESERVED_DATA.has(key) || key === "ink" || key.startsWith("ink-")) {
2352
+ warnDecoration(`data key "${key}"`, "core sets it");
2353
+ return false;
2354
+ }
2355
+ return true;
2356
+ }
2357
+ function usableClass(name) {
2358
+ if (name.startsWith("ink-")) {
2359
+ warnDecoration(`class "${name}"`, "`ink-` classes are core's");
2360
+ return false;
2361
+ }
2362
+ if (name === "" || /\s/.test(name)) {
2363
+ warnDecoration(`class "${name}"`, "not a single class name");
2364
+ return false;
2365
+ }
2366
+ return true;
2367
+ }
2176
2368
  function setFlag(node, key, on) {
2177
2369
  if (on) node.dataset[key] = "";
2178
2370
  else delete node.dataset[key];
2179
2371
  }
2180
- function setData(node, key, value) {
2181
- if (value === void 0) delete node.dataset[key];
2182
- else node.dataset[key] = value;
2183
- }
2184
2372
  function createOverlay(opts) {
2185
2373
  const { store, page } = opts;
2186
2374
  const doc = opts.host.ownerDocument;
@@ -2199,6 +2387,8 @@ function createOverlay(opts) {
2199
2387
  /** What each element's content was last drawn from; absent when it has none of ours. */
2200
2388
  const drawn = /* @__PURE__ */ new Map();
2201
2389
  const hovered = /* @__PURE__ */ new Set();
2390
+ /** The decoration each element last got, so a later one can drop what it no longer has. */
2391
+ const decorated = /* @__PURE__ */ new WeakMap();
2202
2392
  let order = [];
2203
2393
  let destroyed = false;
2204
2394
  function stateOf(id, selected) {
@@ -2213,7 +2403,17 @@ function createOverlay(opts) {
2213
2403
  node.setAttribute("aria-pressed", String(state === "selected"));
2214
2404
  node.tabIndex = !anyHere || state === "selected" ? 0 : -1;
2215
2405
  const a = store.get(id);
2216
- syncHandles(node, a && !a.locked && state === "selected" && selectToolActive() ? handleSpec(node, a) : { kind: "none" });
2406
+ syncHandles(node, a && !a.locked && state === "selected" && editsSelection() ? handleSpec(node, a) : { kind: "none" });
2407
+ }
2408
+ /** `handleBox` relative to an element at `left`,`top`. */
2409
+ function handleLocalBox(a, left, top) {
2410
+ const box = handleBox(a, transform);
2411
+ return {
2412
+ left: box.left - left,
2413
+ top: box.top - top,
2414
+ width: box.width,
2415
+ height: box.height
2416
+ };
2217
2417
  }
2218
2418
  /**
2219
2419
  * Line ends for a line, corners for what a corner drag resizes (a free text's on its text
@@ -2242,8 +2442,7 @@ function createOverlay(opts) {
2242
2442
  ...freeTextLocalBox(a, transform)
2243
2443
  } : {
2244
2444
  kind: "corners",
2245
- width: parseFloat(node.style.width) || 0,
2246
- height: parseFloat(node.style.height) || 0
2445
+ ...handleLocalBox(a, left, top)
2247
2446
  };
2248
2447
  if (!hasCallout(a)) return corners;
2249
2448
  const tip = transform.toCss(a.callout[0]);
@@ -2257,11 +2456,13 @@ function createOverlay(opts) {
2257
2456
  }
2258
2457
  /**
2259
2458
  * True while the tool controller marks the tool root (an ancestor) with a select tool,
2260
- * `data-ink-tool="select"` or `"select-text"`.
2459
+ * `data-ink-tool="select"` or `"select-text"`, or with `data-ink-made`: what a creation tool
2460
+ * just made can be moved and resized too.
2261
2461
  */
2262
- function selectToolActive() {
2263
- const tool = opts.host.closest("[data-ink-tool]")?.getAttribute("data-ink-tool");
2264
- return tool === "select" || tool === "select-text";
2462
+ function editsSelection() {
2463
+ const root = opts.host.closest("[data-ink-tool]");
2464
+ const tool = root?.getAttribute("data-ink-tool");
2465
+ return tool === "select" || tool === "select-text" || root?.hasAttribute("data-ink-made") === true;
2265
2466
  }
2266
2467
  const toolRoot = opts.host.closest("[data-ink-tool]") ?? opts.host.closest("[data-ink-viewport]");
2267
2468
  const Observer = doc.defaultView?.MutationObserver;
@@ -2273,16 +2474,48 @@ function createOverlay(opts) {
2273
2474
  });
2274
2475
  toolObserver.observe(toolRoot, {
2275
2476
  attributes: true,
2276
- attributeFilter: ["data-ink-tool"]
2477
+ attributeFilter: ["data-ink-tool", "data-ink-made"]
2277
2478
  });
2278
2479
  }
2279
2480
  function applyAttributes(node, a) {
2280
2481
  node.dataset.type = a.type;
2281
2482
  node.dataset.source = a.source;
2282
2483
  setFlag(node, "locked", a.locked === true);
2283
- setData(node, "origin", a.type === "redact" ? a.origin : void 0);
2284
- setData(node, "confidence", confidenceAttr(a));
2285
2484
  node.setAttribute("aria-label", ariaLabel(a));
2485
+ applyDecoration(node, a);
2486
+ }
2487
+ /** The app's data attributes, classes and description, from `decorate`; a throw adds none. */
2488
+ function applyDecoration(node, a) {
2489
+ let decoration;
2490
+ try {
2491
+ decoration = opts.decorate?.(a);
2492
+ } catch (error) {
2493
+ console.error(error);
2494
+ }
2495
+ const prev = decorated.get(node);
2496
+ if (!decoration && !prev) return;
2497
+ const next = {
2498
+ data: /* @__PURE__ */ new Set(),
2499
+ classes: /* @__PURE__ */ new Set(),
2500
+ description: false
2501
+ };
2502
+ for (const [key, value] of Object.entries(decoration?.data ?? {})) {
2503
+ if (!usableData(key)) continue;
2504
+ node.setAttribute(`data-${key}`, value);
2505
+ next.data.add(key);
2506
+ }
2507
+ for (const name of decoration?.classes ?? []) {
2508
+ if (!usableClass(name)) continue;
2509
+ node.classList.add(name);
2510
+ next.classes.add(name);
2511
+ }
2512
+ if (decoration?.description) {
2513
+ node.setAttribute("aria-description", decoration.description);
2514
+ next.description = true;
2515
+ } else if (prev?.description) node.removeAttribute("aria-description");
2516
+ for (const key of prev?.data ?? []) if (!next.data.has(key)) node.removeAttribute(`data-${key}`);
2517
+ for (const name of prev?.classes ?? []) if (!next.classes.has(name)) node.classList.remove(name);
2518
+ decorated.set(node, next);
2286
2519
  }
2287
2520
  function applyGeometry(node, a) {
2288
2521
  const box = opts.isCustom?.(a) === true ? transform.toCssRect(a.rect) : annotationBox(a, transform);
@@ -2496,7 +2729,7 @@ function createOverlay(opts) {
2496
2729
  }
2497
2730
  function render() {
2498
2731
  if (destroyed) return;
2499
- const current = withoutReplies(store.list.get()).filter((a) => a.page === page).sort(readingOrder);
2732
+ const current = withoutReplies(store.list.get()).filter((a) => a.page === page).sort(readingOrder$1);
2500
2733
  const ids = new Set(current.map((a) => a.id));
2501
2734
  const active = doc.activeElement;
2502
2735
  let focusId = [...elements].find(([, n]) => n === active)?.[0];
@@ -2561,6 +2794,13 @@ function createOverlay(opts) {
2561
2794
  render();
2562
2795
  },
2563
2796
  elementFor: (id) => elements.get(id),
2797
+ redecorate() {
2798
+ if (destroyed) return;
2799
+ for (const [id, node] of elements) {
2800
+ const a = store.get(id);
2801
+ if (a) applyDecoration(node, a);
2802
+ }
2803
+ },
2564
2804
  destroy() {
2565
2805
  if (destroyed) return;
2566
2806
  closeEditors();
@@ -2580,114 +2820,1730 @@ function createOverlay(opts) {
2580
2820
  };
2581
2821
  }
2582
2822
  //#endregion
2583
- //#region src/dom/page-mount.ts
2584
- /** Canvas, text layer and annotation overlay for one page, stacked in that order. */
2585
- function createPageMount(opts) {
2586
- const { document, page, host, transform } = opts;
2587
- const callbacks = (layer) => ({
2588
- ...opts.onError ? { onError: (error) => opts.onError?.(error, layer) } : {},
2589
- ...opts.onRendered ? { onRendered: () => opts.onRendered?.(layer) } : {}
2590
- });
2591
- const renderer = createPageRenderer({
2592
- document,
2593
- page,
2594
- host,
2595
- transform,
2596
- ...opts.devicePixelRatio === void 0 ? {} : { devicePixelRatio: opts.devicePixelRatio },
2597
- ...callbacks("canvas")
2598
- });
2599
- const textLayer = createTextLayer({
2600
- document,
2601
- page,
2602
- host,
2603
- transform,
2604
- ...callbacks("text")
2605
- });
2606
- const overlay = createOverlay({
2607
- host,
2608
- page,
2609
- store: opts.store,
2610
- transform,
2611
- document,
2612
- ...opts.devicePixelRatio === void 0 ? {} : { devicePixelRatio: opts.devicePixelRatio },
2613
- ...opts.onActivate ? { onActivate: (id) => opts.onActivate?.(id) } : {},
2614
- ...opts.isCustom ? { isCustom: (a) => opts.isCustom?.(a) === true } : {}
2823
+ //#region src/dom/plugins/api/command.ts
2824
+ const DEFAULT_ERROR_STATUS = "Something went wrong.";
2825
+ /** A failure that is the run being cancelled, not a bug: not logged, no error status. */
2826
+ function isCancellation(error) {
2827
+ if (isInkError(error) && error.code === "DocumentDestroyed") return true;
2828
+ return error instanceof Error && error.name === "AbortError";
2829
+ }
2830
+ function createCommand(ctx, id, label, run, opts = {}) {
2831
+ const status = writable("");
2832
+ let controller = null;
2833
+ ctx.onDocument(() => () => {
2834
+ controller?.abort();
2835
+ status.set("");
2615
2836
  });
2616
- return {
2617
- renderer,
2618
- textLayer,
2619
- overlay,
2620
- update(next) {
2621
- renderer.update(next);
2622
- textLayer.update(next);
2623
- overlay.update(next);
2624
- },
2625
- destroy() {
2626
- overlay.destroy();
2627
- textLayer.destroy();
2628
- renderer.destroy();
2837
+ return ctx.commands.add({
2838
+ id,
2839
+ label,
2840
+ ...opts.icon && { icon: opts.icon },
2841
+ ...opts.enabled && { enabled: opts.enabled },
2842
+ status: readonly(status),
2843
+ async run() {
2844
+ const doc = ctx.document.get();
2845
+ if (!doc) return;
2846
+ const own = new AbortController();
2847
+ controller = own;
2848
+ status.set("");
2849
+ try {
2850
+ const result = await run({
2851
+ doc,
2852
+ signal: own.signal
2853
+ });
2854
+ if (!own.signal.aborted) status.set(result ?? "");
2855
+ } catch (error) {
2856
+ if (own.signal.aborted || isCancellation(error)) return;
2857
+ console.error(error);
2858
+ status.set(opts.errorStatus ?? DEFAULT_ERROR_STATUS);
2859
+ } finally {
2860
+ if (controller === own) controller = null;
2861
+ }
2629
2862
  }
2630
- };
2863
+ });
2631
2864
  }
2632
2865
  //#endregion
2633
- //#region src/dom/tools/options.ts
2634
- function createToolOptions(tools = []) {
2635
- const byId = /* @__PURE__ */ new Map();
2636
- const views = /* @__PURE__ */ new Map();
2637
- const entry = (id) => {
2638
- const w = byId.get(id);
2639
- if (!w) throw new RangeError(`Unknown tool ${id}`);
2640
- return w;
2641
- };
2642
- function register(list) {
2643
- for (const t of list) {
2644
- if (byId.has(t.id)) continue;
2645
- const w = writable({ ...t.defaultOptions ?? {} });
2646
- byId.set(t.id, w);
2647
- views.set(t.id, readonly(w));
2648
- }
2649
- }
2650
- register(tools);
2651
- return {
2652
- readable(id) {
2653
- entry(id);
2654
- return views.get(id);
2655
- },
2656
- get: (id) => entry(id).get(),
2657
- merge(id, patch) {
2658
- const w = entry(id);
2659
- w.set({
2660
- ...w.get(),
2661
- ...patch
2662
- });
2663
- },
2664
- register
2866
+ //#region src/dom/annotation-element.ts
2867
+ /** The overlay element drawn for annotation `id` under `root`, if it is in the DOM. */
2868
+ function annotationElementFor(root, id) {
2869
+ return root.querySelector(`[data-annotation="${CSS.escape(id)}"]`);
2870
+ }
2871
+ /**
2872
+ * Resolves the overlay element for `id` and keeps resolving while `root`'s subtree changes:
2873
+ * the overlay creates and removes these elements outside any framework (a page mounts as it
2874
+ * scrolls into view), so a popover anchored to one has to follow. Calls `onChange` once now
2875
+ * and again whenever the answer changes. The returned function stops observing.
2876
+ */
2877
+ function observeAnnotationElement(root, id, onChange) {
2878
+ let last;
2879
+ const resolve = () => {
2880
+ const found = annotationElementFor(root, id);
2881
+ if (found === last) return;
2882
+ last = found;
2883
+ onChange(found);
2665
2884
  };
2885
+ resolve();
2886
+ const observer = new MutationObserver(resolve);
2887
+ observer.observe(root, {
2888
+ childList: true,
2889
+ subtree: true
2890
+ });
2891
+ return () => observer.disconnect();
2666
2892
  }
2667
2893
  //#endregion
2668
- //#region src/dom/tools/controller.ts
2669
- /** Host-relative CSS point for a client point. */
2670
- function cssIn(host, e) {
2671
- const r = host.getBoundingClientRect();
2672
- return {
2673
- x: e.clientX - r.left,
2674
- y: e.clientY - r.top
2675
- };
2894
+ //#region src/dom/popover-focus.ts
2895
+ /**
2896
+ * A press that will activate an annotation is not a dismissal: the popover follows the
2897
+ * activation. Mirrors the overlay's rule: a press on the annotation element or on its drawn
2898
+ * shape activates; a press on a custom element's own content belongs to that content.
2899
+ */
2900
+ function shouldKeepOpenOnAnnotation(target) {
2901
+ if (!(target instanceof Element)) return false;
2902
+ const annotation = target.closest("[data-annotation]");
2903
+ if (!annotation) return false;
2904
+ return target === annotation || !annotation.hasAttribute("data-custom");
2676
2905
  }
2677
- /** Like `cssIn`, clamped to the host's box, so a drag past the page edge stops at the edge. */
2678
- function clampedCssIn(host, e) {
2679
- const r = host.getBoundingClientRect();
2906
+ function createFocusReturn(opts) {
2907
+ let fallbacks = [];
2908
+ /** Set by `beforeRemove`: the close comes from removing the anchor's annotation. */
2909
+ let removing = false;
2680
2910
  return {
2681
- x: Math.min(Math.max(e.clientX - r.left, 0), r.width),
2682
- y: Math.min(Math.max(e.clientY - r.top, 0), r.height)
2911
+ beforeRemove(anchor, prefer) {
2912
+ fallbacks = [];
2913
+ removing = true;
2914
+ const siblings = anchor?.parentElement ? [...anchor.parentElement.children].filter((el) => el instanceof HTMLElement && el.hasAttribute("data-annotation")) : [];
2915
+ const i = anchor ? siblings.indexOf(anchor) : -1;
2916
+ if (i >= 0) {
2917
+ const after = siblings.slice(i + 1);
2918
+ const preferred = prefer ? after.filter((el) => el.matches(prefer)) : [];
2919
+ fallbacks.push(...preferred, ...after, ...siblings.slice(0, i).reverse());
2920
+ }
2921
+ },
2922
+ onCloseAutoFocus(event, anchor) {
2923
+ event.preventDefault();
2924
+ const plan = fallbacks;
2925
+ const removed = removing;
2926
+ fallbacks = [];
2927
+ removing = false;
2928
+ const root = opts.root();
2929
+ if (!root) return;
2930
+ const activated = opts.activated();
2931
+ if (activated !== null && annotationElementFor(root, activated)) return;
2932
+ const active = root.ownerDocument.activeElement;
2933
+ if (active && active !== root && root.contains(active)) return;
2934
+ const scroll = root.querySelector("[data-ink-scroll]");
2935
+ (removed ? [...plan, scroll] : [
2936
+ anchor,
2937
+ ...plan,
2938
+ scroll
2939
+ ]).find((el) => el?.isConnected)?.focus({ preventScroll: true });
2940
+ }
2683
2941
  };
2684
2942
  }
2685
- /** The mounted page under a client point, with the point in CSS px and user space. */
2686
- function hitFor(ctx, e) {
2687
- const hit = ctx.viewport.pageAtCss(e.clientX, e.clientY);
2688
- if (!hit) return void 0;
2689
- const css = cssIn(hit.host, e);
2690
- return {
2943
+ //#endregion
2944
+ //#region src/dom/viewer/dom.ts
2945
+ /** `document.createElement` with attributes and children. */
2946
+ function h(tag, attrs = {}, ...children) {
2947
+ const el = document.createElement(tag);
2948
+ for (const [name, value] of Object.entries(attrs)) el.setAttribute(name, value);
2949
+ el.append(...children);
2950
+ return el;
2951
+ }
2952
+ function button$1(label, onClick) {
2953
+ const b = h("button", { type: "button" }, label);
2954
+ b.addEventListener("click", onClick);
2955
+ return b;
2956
+ }
2957
+ //#endregion
2958
+ //#region src/dom/viewer/popover.ts
2959
+ /** Space between the anchor and the panel, and between the panel and the window edge. */
2960
+ const GAP = 6;
2961
+ const MARGIN = 8;
2962
+ const TABBABLE$1 = "button, input, select, textarea, a[href], [tabindex]";
2963
+ /**
2964
+ * Positions `panel` (fixed) below the `anchor` rectangle, or above it when only that fits,
2965
+ * kept inside the window. For panels anchored to something other than an annotation.
2966
+ */
2967
+ function placePanel(panel, anchor) {
2968
+ const { innerWidth: vw, innerHeight: vh } = window;
2969
+ panel.style.position = "fixed";
2970
+ panel.style.left = "0px";
2971
+ panel.style.top = "0px";
2972
+ const w = panel.offsetWidth;
2973
+ const ht = panel.offsetHeight;
2974
+ let top = anchor.bottom + GAP;
2975
+ if (top + ht > vh - MARGIN && anchor.top - GAP - ht >= MARGIN) top = anchor.top - GAP - ht;
2976
+ top = Math.max(MARGIN, Math.min(top, vh - ht - MARGIN));
2977
+ const left = Math.max(MARGIN, Math.min(anchor.left, vw - w - MARGIN));
2978
+ panel.style.left = `${left}px`;
2979
+ panel.style.top = `${top}px`;
2980
+ }
2981
+ /**
2982
+ * Calls `onChange` with `el` while it is under `root` and null while it is not: once now, then
2983
+ * whenever that changes. Returns a function that stops observing.
2984
+ */
2985
+ function observeElement(root, el, onChange) {
2986
+ let last;
2987
+ const resolve = () => {
2988
+ const found = root.contains(el) ? el : null;
2989
+ if (found === last) return;
2990
+ last = found;
2991
+ onChange(found);
2992
+ };
2993
+ resolve();
2994
+ const observer = new MutationObserver(resolve);
2995
+ observer.observe(root, {
2996
+ childList: true,
2997
+ subtree: true
2998
+ });
2999
+ return () => observer.disconnect();
3000
+ }
3001
+ /**
3002
+ * Creates a non-modal `role="dialog"` panel on `<body>`, marked `data-ink-popover` so the theme
3003
+ * gives it its tokens, that an app anchors to an annotation with `show(id)` (for instance on
3004
+ * `viewer.activated`), or to any element in the viewer with `showAt(el)`. The panel follows the annotation's element as pages mount and unmount:
3005
+ * it hides while the element is gone and comes back with it. Tab loops inside the panel, and
3006
+ * Escape or a press outside calls `onDismiss`. Call `destroy` when done.
3007
+ */
3008
+ function createPopover(opts) {
3009
+ const { root, focusReturn } = opts;
3010
+ const panel = h("div", {
3011
+ ...opts.attrs,
3012
+ role: "dialog",
3013
+ "data-ink-popover": "",
3014
+ hidden: ""
3015
+ });
3016
+ document.body.append(panel);
3017
+ /** What the panel is anchored to: an annotation id, or an element from `showAt`. */
3018
+ let anchored = null;
3019
+ let anchor = null;
3020
+ let stopObserving = () => void 0;
3021
+ /** The element that had focus in the panel when the anchor went away. */
3022
+ let stranded = null;
3023
+ const place = () => {
3024
+ if (!anchor || panel.hidden) return;
3025
+ placePanel(panel, anchor.getBoundingClientRect());
3026
+ };
3027
+ const resizes = new ResizeObserver(place);
3028
+ resizes.observe(panel);
3029
+ const onAnchor = (el, closing = false) => {
3030
+ if (anchor) resizes.unobserve(anchor);
3031
+ anchor = el;
3032
+ if (!el) {
3033
+ const active = document.activeElement;
3034
+ if (!closing && !panel.hidden && active instanceof HTMLElement && panel.contains(active)) {
3035
+ const scroll = root.querySelector("[data-ink-scroll]");
3036
+ if (scroll) {
3037
+ stranded = active;
3038
+ scroll.focus({ preventScroll: true });
3039
+ }
3040
+ }
3041
+ panel.hidden = true;
3042
+ return;
3043
+ }
3044
+ resizes.observe(el);
3045
+ const wasHidden = panel.hidden;
3046
+ panel.hidden = false;
3047
+ place();
3048
+ if (wasHidden && stranded) {
3049
+ const back = stranded;
3050
+ stranded = null;
3051
+ const scroll = root.querySelector("[data-ink-scroll]");
3052
+ if (panel.contains(back) && document.activeElement === scroll) back.focus({ preventScroll: true });
3053
+ }
3054
+ };
3055
+ const onKey = (e) => {
3056
+ if (e.key !== "Escape" || panel.hidden || e.defaultPrevented) return;
3057
+ const target = e.target;
3058
+ if (!(target instanceof Node) || !(panel.contains(target) || root.contains(target))) return;
3059
+ e.preventDefault();
3060
+ opts.onDismiss();
3061
+ };
3062
+ const onPress = (e) => {
3063
+ if (panel.hidden || panel.contains(e.target)) return;
3064
+ if (shouldKeepOpenOnAnnotation(e.target)) return;
3065
+ opts.onDismiss();
3066
+ };
3067
+ const onTab = (e) => {
3068
+ if (e.key !== "Tab" || e.altKey || e.ctrlKey || e.metaKey) return;
3069
+ const tabbable = [...panel.querySelectorAll(TABBABLE$1)].filter((el) => !el.disabled && el.tabIndex >= 0);
3070
+ const first = tabbable[0];
3071
+ const last = tabbable.at(-1);
3072
+ if (!first || !last) return;
3073
+ const active = document.activeElement;
3074
+ if (e.shiftKey ? active !== first : active !== last) return;
3075
+ e.preventDefault();
3076
+ (e.shiftKey ? last : first).focus();
3077
+ };
3078
+ panel.addEventListener("keydown", onTab);
3079
+ document.addEventListener("keydown", onKey);
3080
+ document.addEventListener("pointerdown", onPress, true);
3081
+ document.addEventListener("scroll", place, true);
3082
+ window.addEventListener("resize", place);
3083
+ return {
3084
+ panel,
3085
+ get anchor() {
3086
+ return anchor;
3087
+ },
3088
+ show(id) {
3089
+ if (id === anchored) return;
3090
+ stopObserving();
3091
+ anchored = id;
3092
+ stopObserving = observeAnnotationElement(root, id, onAnchor);
3093
+ },
3094
+ showAt(el) {
3095
+ if (el === anchored) return;
3096
+ stopObserving();
3097
+ anchored = el;
3098
+ stopObserving = observeElement(root, el, onAnchor);
3099
+ },
3100
+ hide() {
3101
+ if (anchored === null) return;
3102
+ stopObserving();
3103
+ stopObserving = () => void 0;
3104
+ const element = typeof anchored === "string" ? null : anchored;
3105
+ anchored = null;
3106
+ const back = anchor;
3107
+ stranded = null;
3108
+ onAnchor(null, true);
3109
+ queueMicrotask(() => {
3110
+ if (anchored !== null || !panel.isConnected) return;
3111
+ if (element?.isConnected) {
3112
+ const active = document.activeElement;
3113
+ if (!(active && active !== root && root.contains(active))) element.focus({ preventScroll: true });
3114
+ return;
3115
+ }
3116
+ focusReturn.onCloseAutoFocus(new Event("closeautofocus", { cancelable: true }), back);
3117
+ });
3118
+ },
3119
+ destroy() {
3120
+ stopObserving();
3121
+ resizes.disconnect();
3122
+ document.removeEventListener("keydown", onKey);
3123
+ document.removeEventListener("pointerdown", onPress, true);
3124
+ panel.removeEventListener("keydown", onTab);
3125
+ document.removeEventListener("scroll", place, true);
3126
+ window.removeEventListener("resize", place);
3127
+ panel.remove();
3128
+ }
3129
+ };
3130
+ }
3131
+ //#endregion
3132
+ //#region src/dom/plugins/api/card.ts
3133
+ function el(tag, className, ...children) {
3134
+ const e = document.createElement(tag);
3135
+ e.className = className;
3136
+ e.append(...children);
3137
+ return e;
3138
+ }
3139
+ function createCard(ctx, records, opts) {
3140
+ /** Made on first open, in the viewer root the mark is in. */
3141
+ let popover = null;
3142
+ let root = null;
3143
+ /** The record on show. */
3144
+ let shown = null;
3145
+ /** Set while an action runs: the action decides what happens after it. */
3146
+ let acting = false;
3147
+ /** The mark whose card is open: its `aria-expanded` is true. */
3148
+ let expanded = null;
3149
+ const expand = (mark) => {
3150
+ expanded?.setAttribute("aria-expanded", "false");
3151
+ expanded = mark;
3152
+ mark?.setAttribute("aria-expanded", "true");
3153
+ };
3154
+ /** The shown record as it stands now, while it still has a mark. */
3155
+ const current = () => {
3156
+ const record = shown === null ? void 0 : records.find(shown);
3157
+ return record && opts.marked(record) ? record : void 0;
3158
+ };
3159
+ const close = () => {
3160
+ shown = null;
3161
+ expand(null);
3162
+ popover?.hide();
3163
+ };
3164
+ /** Runs `fn` as one undo step, then re-renders, or closes and moves focus on. */
3165
+ const act = (fn) => {
3166
+ if (!popover || !root || shown === null) return;
3167
+ const marks = opts.marks(root);
3168
+ const i = popover.anchor ? marks.indexOf(popover.anchor) : -1;
3169
+ const after = i < 0 ? [] : [...marks.slice(i + 1), ...marks.slice(0, i).reverse()];
3170
+ acting = true;
3171
+ try {
3172
+ ctx.history.batch(fn);
3173
+ } finally {
3174
+ acting = false;
3175
+ }
3176
+ const record = current();
3177
+ if (record) return render(record);
3178
+ after.find((m) => m.isConnected)?.focus({ preventScroll: true });
3179
+ close();
3180
+ };
3181
+ /** Fills the panel from `record`, keeping focus on the same action. */
3182
+ const render = (record) => {
3183
+ const spec = opts.spec(record);
3184
+ const panel = popover.panel;
3185
+ const buttons = () => [...panel.querySelectorAll(".ink-card-actions > button")];
3186
+ const focused = buttons().indexOf(document.activeElement);
3187
+ panel.className = spec.className ? `ink-card ${spec.className}` : "ink-card";
3188
+ panel.setAttribute("aria-label", spec.label ?? spec.title);
3189
+ const parts = [el("p", "ink-card-title", spec.title)];
3190
+ if (spec.meta !== void 0) {
3191
+ const meta = el("p", "ink-card-meta", spec.meta);
3192
+ if (spec.swatch !== void 0) {
3193
+ const swatch = el("span", "ink-card-swatch");
3194
+ swatch.style.background = spec.swatch;
3195
+ meta.prepend(swatch);
3196
+ }
3197
+ parts.push(meta);
3198
+ }
3199
+ if (spec.body !== void 0) parts.push(el("p", "ink-card-body", spec.body));
3200
+ const actions = el("div", "ink-card-actions");
3201
+ for (const [label, fn] of Object.entries(spec.actions)) {
3202
+ const button = el("button", "", label);
3203
+ button.type = "button";
3204
+ button.addEventListener("click", () => act(fn));
3205
+ actions.append(button);
3206
+ }
3207
+ parts.push(actions);
3208
+ panel.replaceChildren(...parts);
3209
+ if (focused >= 0) {
3210
+ const now = buttons();
3211
+ (now[focused] ?? now[0])?.focus({ preventScroll: true });
3212
+ }
3213
+ };
3214
+ ctx.onDocument(() => {
3215
+ const stop = records.list.subscribe(() => {
3216
+ if (acting || shown === null) return;
3217
+ const record = current();
3218
+ if (record) render(record);
3219
+ else close();
3220
+ });
3221
+ return () => {
3222
+ stop();
3223
+ shown = null;
3224
+ expand(null);
3225
+ popover?.destroy();
3226
+ popover = null;
3227
+ root = null;
3228
+ };
3229
+ });
3230
+ return {
3231
+ open(record, mark) {
3232
+ const viewer = mark.closest("[data-ink-root]");
3233
+ if (!viewer) return;
3234
+ if (viewer !== root) {
3235
+ popover?.destroy();
3236
+ root = viewer;
3237
+ popover = createPopover({
3238
+ root: viewer,
3239
+ attrs: { class: "ink-card" },
3240
+ focusReturn: createFocusReturn({
3241
+ root: () => viewer,
3242
+ activated: () => null
3243
+ }),
3244
+ onDismiss: close
3245
+ });
3246
+ }
3247
+ shown = record.id;
3248
+ render(record);
3249
+ popover.showAt(mark);
3250
+ expand(mark);
3251
+ popover.panel.querySelector(".ink-card-actions > button")?.focus();
3252
+ },
3253
+ expanded: (mark) => mark === expanded
3254
+ };
3255
+ }
3256
+ //#endregion
3257
+ //#region src/dom/plugins/api/marks.ts
3258
+ /** Events stopped on a mark, so the viewer's root never sees a press on it. */
3259
+ const STOPPED = [
3260
+ "pointerdown",
3261
+ "pointerup",
3262
+ "mousedown",
3263
+ "mouseup",
3264
+ "click"
3265
+ ];
3266
+ /** Attributes the plugin's `attrs` cannot set: core sets them, or they have options of their own. */
3267
+ const RESERVED = /* @__PURE__ */ new Set([
3268
+ "style",
3269
+ "class",
3270
+ "type"
3271
+ ]);
3272
+ function createMarks(ctx, records, opts) {
3273
+ const applied = /* @__PURE__ */ new WeakMap();
3274
+ const marked = (record) => opts.where?.(record) ?? true;
3275
+ const spec = opts.card?.bind(opts);
3276
+ const card = spec && createCard(ctx, records, {
3277
+ spec,
3278
+ marked,
3279
+ marks: (root) => [...root.querySelectorAll(`.ink-plugin-layer[data-plugin="${ctx.id}"] > button`)].filter((mark) => applied.has(mark))
3280
+ });
3281
+ /** What activating a mark does. */
3282
+ const activate = (record, mark) => {
3283
+ card?.open(record, mark);
3284
+ opts.onActivate?.(record, mark);
3285
+ };
3286
+ const create = (doc) => {
3287
+ const mark = doc.createElement("button");
3288
+ if (opts.className) mark.className = opts.className;
3289
+ for (const type of STOPPED) mark.addEventListener(type, (e) => e.stopPropagation());
3290
+ mark.addEventListener("click", () => {
3291
+ const state = applied.get(mark);
3292
+ if (state) activate(state.record, mark);
3293
+ });
3294
+ return mark;
3295
+ };
3296
+ /** Brings `mark` up to date with `record`, placed through `transform`. */
3297
+ const apply = (mark, record, transform) => {
3298
+ const prev = applied.get(mark);
3299
+ const style = opts.style?.(record) ?? {};
3300
+ const styleNames = Object.keys(style).map(toCssName);
3301
+ const attrs = Object.fromEntries(Object.entries(opts.attrs?.(record) ?? {}).filter(([name]) => !RESERVED.has(name.toLowerCase())));
3302
+ for (const name of prev?.style ?? []) if (!styleNames.includes(name)) mark.style.removeProperty(name);
3303
+ for (const key of prev?.attrs ?? []) if (!(key in attrs)) mark.removeAttribute(key);
3304
+ for (const [key, value] of Object.entries(style)) mark.style.setProperty(toCssName(key), value);
3305
+ for (const [name, value] of Object.entries(attrs)) mark.setAttribute(name, value);
3306
+ mark.type = "button";
3307
+ mark.style.position = "absolute";
3308
+ mark.dataset.record = record.id;
3309
+ mark.dataset.plugin = ctx.id;
3310
+ mark.setAttribute("aria-label", opts.label(record));
3311
+ if (card) {
3312
+ mark.setAttribute("aria-haspopup", "dialog");
3313
+ mark.setAttribute("aria-expanded", String(card.expanded(mark)));
3314
+ }
3315
+ const r = transform.toCssRect(opts.rect(record));
3316
+ mark.style.left = `${r.left}px`;
3317
+ mark.style.top = `${r.top}px`;
3318
+ mark.style.width = `${r.width}px`;
3319
+ mark.style.height = `${r.height}px`;
3320
+ applied.set(mark, {
3321
+ record,
3322
+ style: styleNames,
3323
+ attrs: Object.keys(attrs)
3324
+ });
3325
+ };
3326
+ /**
3327
+ * Makes `el`'s marks match the page's records, in list order: marks that stay are moved,
3328
+ * new ones inserted, the rest removed.
3329
+ */
3330
+ const draw = (el, page, transform) => {
3331
+ const list = records.list.get().filter((r) => r.page === page && marked(r));
3332
+ const ids = new Set(list.map((r) => r.id));
3333
+ const kept = /* @__PURE__ */ new Map();
3334
+ for (const mark of [...el.children]) {
3335
+ const id = mark.dataset.record;
3336
+ if (id !== void 0 && ids.has(id) && !kept.has(id)) kept.set(id, mark);
3337
+ else mark.remove();
3338
+ }
3339
+ let next = el.firstElementChild;
3340
+ for (const record of list) {
3341
+ const mark = kept.get(record.id) ?? create(el.ownerDocument);
3342
+ apply(mark, record, transform);
3343
+ if (mark === next) next = mark.nextElementSibling;
3344
+ else el.insertBefore(mark, next);
3345
+ }
3346
+ };
3347
+ let scrolling = false;
3348
+ /** Ids that started to get a mark in the change under way, read once it settles. */
3349
+ const fresh = /* @__PURE__ */ new Set();
3350
+ /** Scrolls to the first fresh record still marked, so a batch of adds scrolls once. */
3351
+ const reveal = () => {
3352
+ const [first] = [...fresh].map((id) => records.find(id)).filter((r) => r !== void 0 && marked(r)).sort(readingOrder((r) => opts.rect(r)));
3353
+ fresh.clear();
3354
+ if (first) ctx.scrollTo(first.page, opts.rect(first), { behavior: reducedMotion() ? "instant" : "smooth" });
3355
+ };
3356
+ /** Notes a record that starts to get a mark; replaces (undo, redo, reset) never count. */
3357
+ const watch = (change) => {
3358
+ const record = change.kind === "add" ? change.record : change.kind === "update" && !marked(change.before) ? change.after : void 0;
3359
+ if (!record || !marked(record)) return;
3360
+ if (fresh.size === 0) queueMicrotask(reveal);
3361
+ fresh.add(record.id);
3362
+ };
3363
+ const layer = ctx.layer({ render: draw });
3364
+ ctx.onDocument(() => {
3365
+ const stopDraw = records.list.subscribe(() => layer.invalidate());
3366
+ const stopWatch = records.store()?.on("change", (change) => {
3367
+ if (scrolling) watch(change);
3368
+ });
3369
+ return () => {
3370
+ stopDraw();
3371
+ stopWatch?.();
3372
+ fresh.clear();
3373
+ };
3374
+ });
3375
+ const handle = {
3376
+ refresh: () => layer.invalidate(),
3377
+ scrollToFirst() {
3378
+ scrolling = true;
3379
+ return handle;
3380
+ }
3381
+ };
3382
+ return handle;
3383
+ }
3384
+ /** Page first, then top to bottom and left to right, by each record's rect in user space. */
3385
+ function readingOrder(rect) {
3386
+ return (a, b) => {
3387
+ const ra = rect(a);
3388
+ const rb = rect(b);
3389
+ return a.page - b.page || rb.y + rb.height - (ra.y + ra.height) || ra.x - rb.x;
3390
+ };
3391
+ }
3392
+ function reducedMotion() {
3393
+ return globalThis.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
3394
+ }
3395
+ /** `borderColor` → `border-color`, as `setProperty` takes it; `--custom` names stay as given. */
3396
+ function toCssName(key) {
3397
+ return key.startsWith("--") ? key : key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
3398
+ }
3399
+ //#endregion
3400
+ //#region src/dom/plugins/api/links.ts
3401
+ function isObject(value) {
3402
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3403
+ }
3404
+ /** Links `type` annotations to records; registers one `onAnnotationEdit` listener. */
3405
+ function createLink(deps, type, spec) {
3406
+ const linkOf = (a) => {
3407
+ if (a.type !== type) return void 0;
3408
+ const own = a.custom?.[deps.plugin];
3409
+ const id = isObject(own) ? own[deps.name] : void 0;
3410
+ return typeof id === "string" ? id : void 0;
3411
+ };
3412
+ deps.onAnnotationEdit((edit) => {
3413
+ if (edit.kind === "add") return;
3414
+ const annotation = edit.kind === "remove" ? edit.annotation : edit.after;
3415
+ const id = linkOf(annotation);
3416
+ const record = id === void 0 ? void 0 : deps.find(id);
3417
+ if (!record) return;
3418
+ if (edit.kind === "remove") spec.onRemoved?.(record, annotation);
3419
+ else spec.onChanged?.(record, annotation);
3420
+ });
3421
+ return {
3422
+ add(record) {
3423
+ const doc = deps.document();
3424
+ if (!doc) return void 0;
3425
+ const { color, custom, ...fields } = spec.from(record);
3426
+ const own = custom?.[deps.plugin];
3427
+ return doc.annotations.add({
3428
+ ...fields,
3429
+ type,
3430
+ color: color ?? doc.annotations.defaults.color(type),
3431
+ custom: {
3432
+ ...custom,
3433
+ [deps.plugin]: {
3434
+ ...isObject(own) ? own : {},
3435
+ [deps.name]: record.id
3436
+ }
3437
+ }
3438
+ });
3439
+ },
3440
+ of(record) {
3441
+ const doc = deps.document();
3442
+ return doc ? doc.annotations.list.get().filter((a) => linkOf(a) === record.id) : [];
3443
+ }
3444
+ };
3445
+ }
3446
+ //#endregion
3447
+ //#region src/dom/plugins/api/records.ts
3448
+ const EMPTY$1 = Object.freeze([]);
3449
+ /** The list of whichever store `source` holds, `[]` for none; emits on swaps and changes. */
3450
+ function listOf(source) {
3451
+ const read = (store) => store?.list.get() ?? EMPTY$1;
3452
+ return {
3453
+ get: () => read(source.get()),
3454
+ subscribe(fn) {
3455
+ let last = read(source.get());
3456
+ let stopList;
3457
+ const emit = (next) => {
3458
+ if (Object.is(next, last) || next.length === 0 && last.length === 0) return;
3459
+ last = next;
3460
+ fn(next);
3461
+ };
3462
+ const follow = (store) => {
3463
+ stopList?.();
3464
+ stopList = store?.list.subscribe(emit);
3465
+ emit(read(store));
3466
+ };
3467
+ follow(source.get());
3468
+ const stopSource = source.subscribe(follow);
3469
+ return () => {
3470
+ stopSource();
3471
+ stopList?.();
3472
+ };
3473
+ }
3474
+ };
3475
+ }
3476
+ function matches(record, match) {
3477
+ return Object.entries(match).every(([k, v]) => record[k] === v);
3478
+ }
3479
+ /**
3480
+ * Builds a handle over `source`, a plugin store from `ctx.store`. Every call reads the store
3481
+ * `source` holds then.
3482
+ */
3483
+ function createRecords(deps, source, base = source) {
3484
+ const current = () => source.get();
3485
+ const all = () => current()?.list.get() ?? EMPTY$1;
3486
+ return {
3487
+ list: listOf(source),
3488
+ all,
3489
+ find: (id) => current()?.get(id),
3490
+ has: (id) => current()?.get(id) !== void 0,
3491
+ where: (match) => all().filter(typeof match === "function" ? match : (r) => matches(r, match)),
3492
+ add: (record) => current()?.add(record),
3493
+ addMissing(records) {
3494
+ const store = current();
3495
+ if (!store) return [];
3496
+ const added = [];
3497
+ deps.batch(() => {
3498
+ for (const record of records) if (!store.get(record.id)) added.push(store.add(record));
3499
+ });
3500
+ return added;
3501
+ },
3502
+ update: (id, patch) => current()?.update(id, patch),
3503
+ remove: (id) => current()?.remove(id),
3504
+ in(doc) {
3505
+ const captured = deps.storesDocument() === doc ? base.get() : null;
3506
+ return createRecords(deps, derived(base, (store) => store && store === captured ? store : null), base);
3507
+ },
3508
+ store: current,
3509
+ annotates: (type, spec) => createLink({
3510
+ plugin: deps.plugin,
3511
+ name: deps.name,
3512
+ onAnnotationEdit: (fn) => deps.onAnnotationEdit(fn),
3513
+ document: () => current() ? deps.storesDocument() : null,
3514
+ find: (id) => current()?.get(id)
3515
+ }, type, spec)
3516
+ };
3517
+ }
3518
+ //#endregion
3519
+ //#region src/dom/plugins/api/api.ts
3520
+ function createPluginApi(ctx, options) {
3521
+ let storesDocument = null;
3522
+ ctx.onDocument((doc) => {
3523
+ storesDocument = doc;
3524
+ return () => {
3525
+ storesDocument = null;
3526
+ };
3527
+ });
3528
+ const batch = (fn) => ctx.history.batch(fn);
3529
+ return {
3530
+ ...ctx,
3531
+ records(name, opts) {
3532
+ const source = ctx.store(name, opts ?? options.records?.[name]);
3533
+ return createRecords({
3534
+ batch,
3535
+ storesDocument: () => storesDocument,
3536
+ plugin: ctx.id,
3537
+ name,
3538
+ onAnnotationEdit: (fn) => ctx.onAnnotationEdit(fn)
3539
+ }, source);
3540
+ },
3541
+ batch,
3542
+ command: (id, label, run, opts) => createCommand(ctx, id, label, run, opts),
3543
+ marks: (records, opts) => createMarks(ctx, records, opts)
3544
+ };
3545
+ }
3546
+ //#endregion
3547
+ //#region src/dom/plugins/define.ts
3548
+ const PLUGIN_ID = /^[a-z][a-z0-9-]*$/;
3549
+ function checkId(id) {
3550
+ if (!PLUGIN_ID.test(id)) throw new InkError("InvalidPluginId", `Plugin id ${JSON.stringify(id)} must be lower-case kebab`, { id });
3551
+ }
3552
+ function definePlugin(plugin, setup) {
3553
+ if (typeof plugin !== "string") {
3554
+ checkId(plugin.id);
3555
+ return plugin;
3556
+ }
3557
+ const id = plugin;
3558
+ checkId(id);
3559
+ return (options = {}) => ({
3560
+ id,
3561
+ setup: (ctx) => setup?.(createPluginApi(ctx, options), options)
3562
+ });
3563
+ }
3564
+ //#endregion
3565
+ //#region src/dom/plugins/commands.ts
3566
+ /**
3567
+ * The error for a command id no plugin registered, naming the ids that were. Bindings log it
3568
+ * rather than throw, and show the control as it is before the plugins are set up.
3569
+ */
3570
+ function unknownCommand(id, registered) {
3571
+ const known = registered.length > 0 ? registered.join(", ") : "none";
3572
+ return new InkError("UnknownCommand", `No plugin registered a command ${id} (registered: ${known})`, {
3573
+ id,
3574
+ registered
3575
+ });
3576
+ }
3577
+ const EMPTY = {
3578
+ get: () => "",
3579
+ subscribe: () => () => {}
3580
+ };
3581
+ /**
3582
+ * A boolean readable computed from `sources`. Like `derived`, it subscribes to the sources only
3583
+ * while it has subscribers of its own; `watch` re-reads them, so a source can be swapped while
3584
+ * subscribed (a document's status when the document changes).
3585
+ */
3586
+ function computed(compute, watch) {
3587
+ const subs = /* @__PURE__ */ new Set();
3588
+ let cached = false;
3589
+ let stop;
3590
+ const changed = () => {
3591
+ const next = compute();
3592
+ if (next === cached) return;
3593
+ cached = next;
3594
+ for (const sub of [...subs]) sub(next);
3595
+ };
3596
+ return {
3597
+ get: () => stop ? cached : compute(),
3598
+ subscribe(sub) {
3599
+ if (!stop) {
3600
+ cached = compute();
3601
+ stop = watch(changed);
3602
+ }
3603
+ subs.add(sub);
3604
+ return () => {
3605
+ if (!subs.delete(sub) || subs.size > 0) return;
3606
+ stop?.();
3607
+ stop = void 0;
3608
+ };
3609
+ }
3610
+ };
3611
+ }
3612
+ /** True while `document` holds a document whose status is ready. */
3613
+ function documentReady(document) {
3614
+ return computed(() => document.get()?.status.get().state === "ready", (changed) => {
3615
+ let stopStatus;
3616
+ const follow = (doc) => {
3617
+ stopStatus?.();
3618
+ stopStatus = doc?.status.subscribe(changed);
3619
+ changed();
3620
+ };
3621
+ const stopDocument = document.subscribe(follow);
3622
+ follow(document.get());
3623
+ return () => {
3624
+ stopDocument();
3625
+ stopStatus?.();
3626
+ };
3627
+ });
3628
+ }
3629
+ /**
3630
+ * Plugin commands by namespaced id. A command is busy from `run()` until the promise its spec
3631
+ * returned settles, and is disabled meanwhile, so a double click runs it once.
3632
+ */
3633
+ function createCommandRegistry(opts) {
3634
+ const commands = /* @__PURE__ */ new Map();
3635
+ const ready = documentReady(opts.document);
3636
+ return {
3637
+ commands,
3638
+ add(plugin, spec) {
3639
+ const id = `${plugin}.${spec.id}`;
3640
+ if (commands.has(id)) throw new InkError("DuplicateId", `A command with id ${id} already exists`, { id });
3641
+ if (!spec.label.trim()) throw new InkError("InvalidCommandLabel", `Command ${id} needs a label`, { id });
3642
+ const busy = writable(false);
3643
+ const allowed = spec.enabled ?? ready;
3644
+ const enabled = computed(() => allowed.get() && !busy.get(), (changed) => {
3645
+ const stops = [allowed.subscribe(changed), busy.subscribe(changed)];
3646
+ return () => {
3647
+ for (const stop of stops) stop();
3648
+ };
3649
+ });
3650
+ const command = {
3651
+ id,
3652
+ label: spec.label,
3653
+ icon: spec.icon,
3654
+ enabled,
3655
+ busy: readonly(busy),
3656
+ status: spec.status ?? EMPTY,
3657
+ async run() {
3658
+ if (!enabled.get()) return;
3659
+ busy.set(true);
3660
+ try {
3661
+ await spec.run();
3662
+ } catch (error) {
3663
+ if (!(isInkError(error) && error.code === "DocumentDestroyed")) console.error(error);
3664
+ } finally {
3665
+ busy.set(false);
3666
+ }
3667
+ }
3668
+ };
3669
+ commands.set(id, command);
3670
+ return command;
3671
+ }
3672
+ };
3673
+ }
3674
+ //#endregion
3675
+ //#region src/dom/plugins/layer.ts
3676
+ function createLayerRegistry() {
3677
+ const layers = [];
3678
+ const listeners = /* @__PURE__ */ new Set();
3679
+ const emit = (event) => {
3680
+ for (const listener of [...listeners]) listener(event);
3681
+ };
3682
+ return {
3683
+ layers,
3684
+ watchLayers(listener) {
3685
+ listeners.add(listener);
3686
+ return () => listeners.delete(listener);
3687
+ },
3688
+ add(layer) {
3689
+ layers.push(layer);
3690
+ emit({
3691
+ type: "add",
3692
+ layer
3693
+ });
3694
+ return { invalidate: (page) => emit({
3695
+ type: "invalidate",
3696
+ layer,
3697
+ page
3698
+ }) };
3699
+ }
3700
+ };
3701
+ }
3702
+ /** Runs a plugin's drawing; a throw is logged so one plugin cannot break the page. */
3703
+ function guard$1(fn) {
3704
+ try {
3705
+ fn();
3706
+ } catch (error) {
3707
+ console.error(error);
3708
+ }
3709
+ }
3710
+ const CHILD_RULE = ":where(.ink-plugin-layer > *) { pointer-events: auto; }";
3711
+ /** One sheet per window: a constructed sheet can only be adopted in its own window. */
3712
+ const childSheets = /* @__PURE__ */ new WeakMap();
3713
+ /**
3714
+ * Puts the children's `pointer-events: auto` rule in the document or shadow root `el` lives
3715
+ * in: an adopted sheet, again if the host has since replaced its adopted sheets, or one
3716
+ * `<style>` element where adopted sheets are missing. A layer not yet connected gets it on a
3717
+ * later draw, which calls this again.
3718
+ */
3719
+ function adoptChildRule(el) {
3720
+ if (!el.isConnected) return;
3721
+ const root = el.getRootNode();
3722
+ const doc = el.ownerDocument;
3723
+ const win = doc.defaultView;
3724
+ if (!root.adoptedStyleSheets || !win) {
3725
+ const parent = root === doc ? doc.head ?? doc.documentElement : root;
3726
+ if ([...parent.children].some((c) => c.matches("style[data-ink-plugin-layer]"))) return;
3727
+ const style = doc.createElement("style");
3728
+ style.dataset.inkPluginLayer = "";
3729
+ style.textContent = CHILD_RULE;
3730
+ parent.append(style);
3731
+ return;
3732
+ }
3733
+ let sheet = childSheets.get(win);
3734
+ if (!sheet) {
3735
+ sheet = new win.CSSStyleSheet();
3736
+ sheet.replaceSync(CHILD_RULE);
3737
+ childSheets.set(win, sheet);
3738
+ }
3739
+ if (!root.adoptedStyleSheets.includes(sheet)) root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
3740
+ }
3741
+ /**
3742
+ * Mounts `source`'s layers on the pages the engine mounts, and follows it: a layer added later
3743
+ * is drawn on every mounted page, and an invalidate redraws its page, or all of them.
3744
+ *
3745
+ * @internal For bindings, which mount plugin layers; plugin authors add a layer with
3746
+ * `ctx.layer`.
3747
+ */
3748
+ function createPluginLayers(source) {
3749
+ const pages = /* @__PURE__ */ new Map();
3750
+ const draw = (layer, el, page, t) => {
3751
+ adoptChildRule(el);
3752
+ guard$1(() => layer.render(el, page, t));
3753
+ };
3754
+ const add = (layer, page, state) => {
3755
+ const el = state.host.ownerDocument.createElement("div");
3756
+ el.className = "ink-plugin-layer";
3757
+ el.dataset.plugin = layer.plugin;
3758
+ el.style.cssText = "position:absolute;inset:0;pointer-events:none;";
3759
+ state.host.append(el);
3760
+ state.elements.set(layer, el);
3761
+ draw(layer, el, page, state.transform);
3762
+ };
3763
+ return {
3764
+ mount(page, host, transform) {
3765
+ const state = {
3766
+ host,
3767
+ transform,
3768
+ elements: /* @__PURE__ */ new Map()
3769
+ };
3770
+ pages.set(page, state);
3771
+ for (const layer of source.layers) add(layer, page, state);
3772
+ return {
3773
+ update(next) {
3774
+ state.transform = next;
3775
+ for (const [layer, el] of state.elements) draw(layer, el, page, next);
3776
+ },
3777
+ destroy() {
3778
+ if (pages.get(page) === state) pages.delete(page);
3779
+ for (const [layer, el] of state.elements) {
3780
+ if (layer.destroy) guard$1(() => layer.destroy?.(el, page));
3781
+ el.remove();
3782
+ }
3783
+ state.elements.clear();
3784
+ }
3785
+ };
3786
+ },
3787
+ destroy: source.watchLayers((event) => {
3788
+ if (event.type === "add") {
3789
+ for (const [page, state] of pages) add(event.layer, page, state);
3790
+ return;
3791
+ }
3792
+ const redraw = (page, state) => {
3793
+ const el = state.elements.get(event.layer);
3794
+ if (el) draw(event.layer, el, page, state.transform);
3795
+ };
3796
+ if (event.page === void 0) for (const [page, state] of pages) redraw(page, state);
3797
+ else {
3798
+ const state = pages.get(event.page);
3799
+ if (state) redraw(event.page, state);
3800
+ }
3801
+ })
3802
+ };
3803
+ }
3804
+ //#endregion
3805
+ //#region src/dom/plugins/host.ts
3806
+ /**
3807
+ * `value` seen with `extra` laid over it, read-only. Everything else is read from and written to
3808
+ * `value` itself, and its methods are bound to it, so a method always runs on the original
3809
+ * (a class instance keeps its `#private` fields, and `this.id` is the plugin's own id).
3810
+ */
3811
+ function withProps(value, extra) {
3812
+ const bound = /* @__PURE__ */ new Map();
3813
+ const own = (key) => Object.hasOwn(extra, key);
3814
+ return new Proxy(Object.create(null), {
3815
+ get(_, key) {
3816
+ if (own(key)) return extra[key];
3817
+ const v = Reflect.get(value, key, value);
3818
+ if (typeof v !== "function") return v;
3819
+ let entry = bound.get(key);
3820
+ if (entry?.fn !== v) {
3821
+ entry = {
3822
+ fn: v,
3823
+ bound: v.bind(value)
3824
+ };
3825
+ bound.set(key, entry);
3826
+ }
3827
+ return entry.bound;
3828
+ },
3829
+ set: (_, key, v) => !own(key) && Reflect.set(value, key, v, value),
3830
+ defineProperty(target, key, d) {
3831
+ if (own(key) || !Reflect.defineProperty(value, key, d)) return false;
3832
+ const defined = Reflect.getOwnPropertyDescriptor(value, key);
3833
+ if (defined && !defined.configurable) Reflect.defineProperty(target, key, defined);
3834
+ return true;
3835
+ },
3836
+ deleteProperty: (_, key) => !own(key) && Reflect.deleteProperty(value, key),
3837
+ has: (_, key) => own(key) || Reflect.has(value, key),
3838
+ ownKeys: () => [.../* @__PURE__ */ new Set([...Reflect.ownKeys(value), ...Reflect.ownKeys(extra)])],
3839
+ getOwnPropertyDescriptor(target, key) {
3840
+ const d = Reflect.getOwnPropertyDescriptor(own(key) ? extra : value, key);
3841
+ const fixed = Reflect.getOwnPropertyDescriptor(target, key)?.configurable === false;
3842
+ return d && {
3843
+ ...d,
3844
+ configurable: !fixed
3845
+ };
3846
+ },
3847
+ getPrototypeOf: () => Reflect.getPrototypeOf(value)
3848
+ });
3849
+ }
3850
+ /** Runs a plugin callback; a throw is logged so one plugin cannot break the others. */
3851
+ function guard(fn) {
3852
+ try {
3853
+ return fn();
3854
+ } catch (error) {
3855
+ console.error(error);
3856
+ return;
3857
+ }
3858
+ }
3859
+ /**
3860
+ * @internal For bindings and `createViewer`, which place a viewer's plugins; plugin authors use
3861
+ * `definePlugin` and never call it.
3862
+ */
3863
+ function createPluginHost(opts) {
3864
+ const seen = /* @__PURE__ */ new Set();
3865
+ for (const plugin of opts.plugins) {
3866
+ if (seen.has(plugin.id)) throw new InkError("DuplicatePlugin", `A plugin with id ${plugin.id} is registered twice`, { id: plugin.id });
3867
+ seen.add(plugin.id);
3868
+ }
3869
+ const registry = createCommandRegistry({ document: opts.document });
3870
+ const tools = [];
3871
+ const decorators = [];
3872
+ const layers = createLayerRegistry();
3873
+ const requests = [];
3874
+ const documentFns = [];
3875
+ const editFns = [];
3876
+ const setupCleanups = [];
3877
+ const redecorators = /* @__PURE__ */ new Set();
3878
+ let opened = null;
3879
+ let destroyed = false;
3880
+ /** Creates a requested store for the open document and has its history track it. */
3881
+ const openStore = (request, state, history) => {
3882
+ const store = createRecordStore(request.key, {
3883
+ initial: request.opts.initial?.(state.doc) ?? [],
3884
+ ...request.opts.onChange && { onChange: request.opts.onChange }
3885
+ });
3886
+ state.stores.push(store);
3887
+ state.detach.push(history.track(store));
3888
+ request.current.set(store);
3889
+ };
3890
+ const runOnDocument = (fn, state) => {
3891
+ const cleanup = guard(() => fn(state.doc));
3892
+ if (typeof cleanup === "function") state.cleanups.push(cleanup);
3893
+ };
3894
+ const open = (doc) => {
3895
+ const history = opts.history();
3896
+ const state = {
3897
+ doc,
3898
+ stores: [],
3899
+ detach: [],
3900
+ cleanups: []
3901
+ };
3902
+ opened = state;
3903
+ for (const request of requests) openStore(request, state, history);
3904
+ for (const fn of editFns) state.detach.push(history.react(fn));
3905
+ for (const fn of documentFns) runOnDocument(fn, state);
3906
+ };
3907
+ const close = () => {
3908
+ const state = opened;
3909
+ if (!state) return;
3910
+ opened = null;
3911
+ for (const detach of state.detach) detach();
3912
+ for (const store of state.stores) store.destroy();
3913
+ for (const request of requests) request.current.set(null);
3914
+ for (const cleanup of state.cleanups.reverse()) guard(cleanup);
3915
+ };
3916
+ const contextFor = (plugin) => {
3917
+ const storeNames = /* @__PURE__ */ new Set();
3918
+ return {
3919
+ id: plugin.id,
3920
+ document: opts.document,
3921
+ history: { batch: (fn) => opts.history().batch(fn) },
3922
+ activated: opts.activated,
3923
+ setActivated: (id) => opts.setActivated(id),
3924
+ root: opts.root,
3925
+ commands: { add: (spec) => registry.add(plugin.id, spec) },
3926
+ tools: { add(tool) {
3927
+ const id = `${plugin.id}.${tool.id}`;
3928
+ if (tools.some((t) => t.id === id)) throw new InkError("DuplicateId", `A tool with id ${id} already exists`, { id });
3929
+ tools.push(withProps(tool, { id }));
3930
+ } },
3931
+ decorate(fn) {
3932
+ decorators.push(fn);
3933
+ },
3934
+ redecorate() {
3935
+ for (const listener of [...redecorators]) listener();
3936
+ },
3937
+ store(name, storeOpts = {}) {
3938
+ const key = `${plugin.id}.${name}`;
3939
+ if (storeNames.has(name)) throw new InkError("DuplicateId", `A store with key ${key} already exists`, { key });
3940
+ storeNames.add(name);
3941
+ const request = {
3942
+ key,
3943
+ opts: storeOpts,
3944
+ current: writable(null)
3945
+ };
3946
+ requests.push(request);
3947
+ if (opened) openStore(request, opened, opts.history());
3948
+ return readonly(request.current);
3949
+ },
3950
+ onDocument(fn) {
3951
+ documentFns.push(fn);
3952
+ if (opened) runOnDocument(fn, opened);
3953
+ },
3954
+ onAnnotationEdit(fn) {
3955
+ editFns.push(fn);
3956
+ if (opened) opened.detach.push(opts.history().react(fn));
3957
+ },
3958
+ layer(spec) {
3959
+ return layers.add(withProps(spec, { plugin: plugin.id }));
3960
+ },
3961
+ scrollTo(page, rect, scrollOpts) {
3962
+ opts.viewport?.()?.scrollToRect(page, rect, scrollOpts);
3963
+ }
3964
+ };
3965
+ };
3966
+ for (const plugin of opts.plugins) {
3967
+ let cleanup;
3968
+ try {
3969
+ cleanup = plugin.setup(contextFor(plugin));
3970
+ } catch (error) {
3971
+ for (const done of setupCleanups.reverse()) guard(done);
3972
+ throw error;
3973
+ }
3974
+ if (typeof cleanup === "function") setupCleanups.push(cleanup);
3975
+ }
3976
+ const current = opts.document.get();
3977
+ if (current) open(current);
3978
+ const stopDocument = opts.document.subscribe((doc) => {
3979
+ close();
3980
+ if (doc) open(doc);
3981
+ });
3982
+ return {
3983
+ commands: registry.commands,
3984
+ command: (id) => registry.commands.get(id),
3985
+ tools,
3986
+ decorate(annotation) {
3987
+ const all = [opts.decorate?.(), ...decorators];
3988
+ let merged;
3989
+ for (const fn of all) {
3990
+ const d = fn && guard(() => fn(annotation));
3991
+ if (!d) continue;
3992
+ merged ??= {};
3993
+ if (d.data) merged = {
3994
+ ...merged,
3995
+ data: {
3996
+ ...d.data,
3997
+ ...merged.data
3998
+ }
3999
+ };
4000
+ if (d.classes) merged = {
4001
+ ...merged,
4002
+ classes: [...merged.classes ?? [], ...d.classes]
4003
+ };
4004
+ if (merged.description === void 0 && d.description !== void 0) merged = {
4005
+ ...merged,
4006
+ description: d.description
4007
+ };
4008
+ }
4009
+ return merged;
4010
+ },
4011
+ layers: layers.layers,
4012
+ watchLayers: (listener) => layers.watchLayers(listener),
4013
+ watchRedecorate(listener) {
4014
+ const run = () => listener();
4015
+ redecorators.add(run);
4016
+ return () => {
4017
+ redecorators.delete(run);
4018
+ };
4019
+ },
4020
+ destroy() {
4021
+ if (destroyed) return;
4022
+ destroyed = true;
4023
+ stopDocument();
4024
+ close();
4025
+ for (const cleanup of setupCleanups.reverse()) guard(cleanup);
4026
+ }
4027
+ };
4028
+ }
4029
+ //#endregion
4030
+ //#region src/dom/page-mount.ts
4031
+ /**
4032
+ * Canvas, text layer, annotation overlay and any plugin layers for one page, stacked in that
4033
+ * order.
4034
+ */
4035
+ function createPageMount(opts) {
4036
+ const { document, page, host, transform } = opts;
4037
+ const callbacks = (layer) => ({
4038
+ ...opts.onError ? { onError: (error) => opts.onError?.(error, layer) } : {},
4039
+ ...opts.onRendered ? { onRendered: () => opts.onRendered?.(layer) } : {}
4040
+ });
4041
+ const renderer = createPageRenderer({
4042
+ document,
4043
+ page,
4044
+ host,
4045
+ transform,
4046
+ ...opts.devicePixelRatio === void 0 ? {} : { devicePixelRatio: opts.devicePixelRatio },
4047
+ ...callbacks("canvas")
4048
+ });
4049
+ const textLayer = createTextLayer({
4050
+ document,
4051
+ page,
4052
+ host,
4053
+ transform,
4054
+ ...callbacks("text")
4055
+ });
4056
+ const overlay = createOverlay({
4057
+ host,
4058
+ page,
4059
+ store: opts.store,
4060
+ transform,
4061
+ document,
4062
+ ...opts.devicePixelRatio === void 0 ? {} : { devicePixelRatio: opts.devicePixelRatio },
4063
+ ...opts.onActivate ? { onActivate: (id) => opts.onActivate?.(id) } : {},
4064
+ ...opts.isCustom ? { isCustom: (a) => opts.isCustom?.(a) === true } : {},
4065
+ ...opts.decorate ? { decorate: opts.decorate } : {}
4066
+ });
4067
+ const layers = opts.layers?.mount(page, host, transform);
4068
+ return {
4069
+ renderer,
4070
+ textLayer,
4071
+ overlay,
4072
+ update(next) {
4073
+ renderer.update(next);
4074
+ textLayer.update(next);
4075
+ overlay.update(next);
4076
+ layers?.update(next);
4077
+ },
4078
+ destroy() {
4079
+ layers?.destroy();
4080
+ overlay.destroy();
4081
+ textLayer.destroy();
4082
+ renderer.destroy();
4083
+ }
4084
+ };
4085
+ }
4086
+ //#endregion
4087
+ //#region src/dom/tools/options.ts
4088
+ function createToolOptions(tools = []) {
4089
+ const byId = /* @__PURE__ */ new Map();
4090
+ const views = /* @__PURE__ */ new Map();
4091
+ const entry = (id) => {
4092
+ const w = byId.get(id);
4093
+ if (!w) throw new RangeError(`Unknown tool ${id}`);
4094
+ return w;
4095
+ };
4096
+ function register(list) {
4097
+ for (const t of list) {
4098
+ if (byId.has(t.id)) continue;
4099
+ const w = writable({ ...t.defaultOptions ?? {} });
4100
+ byId.set(t.id, w);
4101
+ views.set(t.id, readonly(w));
4102
+ }
4103
+ }
4104
+ register(tools);
4105
+ return {
4106
+ readable(id) {
4107
+ entry(id);
4108
+ return views.get(id);
4109
+ },
4110
+ get: (id) => entry(id).get(),
4111
+ merge(id, patch) {
4112
+ const w = entry(id);
4113
+ w.set({
4114
+ ...w.get(),
4115
+ ...patch
4116
+ });
4117
+ },
4118
+ register
4119
+ };
4120
+ }
4121
+ //#endregion
4122
+ //#region src/dom/tools/selection-edit.ts
4123
+ const CORNERS = [
4124
+ "nw",
4125
+ "ne",
4126
+ "sw",
4127
+ "se"
4128
+ ];
4129
+ const ENDS = ["start", "end"];
4130
+ const CALLOUT = "callout";
4131
+ /** Pointer travel, CSS px, before a press on an annotation becomes a move. */
4132
+ const DRAG_THRESHOLD = 3;
4133
+ function createSelectionEdit(ctx, opts = {}) {
4134
+ let mode = { kind: "idle" };
4135
+ const batch = (fn) => ctx.history ? ctx.history.batch(fn) : fn();
4136
+ const reset = () => {
4137
+ if (mode.kind === "marquee") mode.el.remove();
4138
+ if (mode.kind === "move") {
4139
+ for (const el of mode.els) el.style.transform = "";
4140
+ ctx.root.removeAttribute("data-ink-dragging");
4141
+ }
4142
+ if (mode.kind === "resize" && mode.el) {
4143
+ mode.el.style.transform = "";
4144
+ mode.el.style.transformOrigin = "";
4145
+ mode.el.style.visibility = "";
4146
+ }
4147
+ if (mode.kind === "resize") mode.preview?.remove();
4148
+ if (mode.kind === "endpoint") {
4149
+ mode.preview.remove();
4150
+ if (mode.el) mode.el.style.visibility = "";
4151
+ }
4152
+ mode = { kind: "idle" };
4153
+ };
4154
+ const elementIn = (root, id) => root.querySelector(`[data-annotation="${CSS.escape(id)}"]`) ?? void 0;
4155
+ const previewFor = (host, a) => {
4156
+ const preview = host.ownerDocument.createElement("div");
4157
+ preview.className = "ink-annotation ink-edit-preview";
4158
+ preview.dataset.inkPreview = "";
4159
+ preview.dataset.type = a.type;
4160
+ preview.style.cssText = "position:absolute;pointer-events:none;";
4161
+ return preview;
4162
+ };
4163
+ /** Shows `draft` in `preview` beside `el`, which is hidden meanwhile. */
4164
+ const showPreview = (preview, el, draft, start) => {
4165
+ drawPreview(preview, draft, start);
4166
+ if (el) el.style.visibility = "hidden";
4167
+ if (!preview.isConnected) (el?.parentElement ?? start.host).append(preview);
4168
+ };
4169
+ function startHandle(e, hit, a, handle) {
4170
+ const el = elementIn(hit.host, a.id);
4171
+ if (CORNERS.includes(handle) && hasCornerHandles(a)) {
4172
+ const text = a.type === "freetext";
4173
+ mode = {
4174
+ kind: "resize",
4175
+ id: a.id,
4176
+ start: hit,
4177
+ corner: handle,
4178
+ box: text ? hit.transform.toCssRect(freeTextBox(a)) : handleBox(a, hit.transform),
4179
+ el,
4180
+ locked: keepsAspect(a),
4181
+ preview: text && (a.fringe !== void 0 || hasCallout(a)) ? previewFor(hit.host, a) : void 0
4182
+ };
4183
+ } else if (ENDS.includes(handle) && a.type === "line" || handle === CALLOUT && hasCallout(a)) {
4184
+ const preview = previewFor(hit.host, a);
4185
+ mode = {
4186
+ kind: "endpoint",
4187
+ id: a.id,
4188
+ start: hit,
4189
+ which: handle,
4190
+ from: hit.transform.toCss(endOf(a, handle)),
4191
+ el,
4192
+ preview
4193
+ };
4194
+ } else return false;
4195
+ e.stopPropagation();
4196
+ e.preventDefault();
4197
+ return true;
4198
+ }
4199
+ return {
4200
+ reset,
4201
+ onPointerDown(e, hit, annotationId) {
4202
+ if (!hit) return;
4203
+ reset();
4204
+ if (annotationId !== void 0) {
4205
+ const a = ctx.store.get(annotationId);
4206
+ if (!a) return;
4207
+ const markup = isTextMarkup(a);
4208
+ if (markup && !opts.marquee) return;
4209
+ const handle = handleOf(e.target);
4210
+ if (handle !== void 0 && !a.locked && startHandle(e, hit, a, handle)) return true;
4211
+ const selected = ctx.store.selection.get();
4212
+ if (e.shiftKey) {
4213
+ e.stopPropagation();
4214
+ ctx.store.select(selected.includes(annotationId) ? selected.filter((s) => s !== annotationId) : [...selected, annotationId]);
4215
+ return;
4216
+ }
4217
+ if (inText(e.target) && canEditFreeText(a, elementIn(hit.host, annotationId))) {
4218
+ e.stopPropagation();
4219
+ ctx.store.select([annotationId]);
4220
+ ctx.onActivate?.(annotationId);
4221
+ mode = {
4222
+ kind: "edit",
4223
+ id: annotationId,
4224
+ start: hit,
4225
+ caret: {
4226
+ clientX: e.clientX,
4227
+ clientY: e.clientY
4228
+ }
4229
+ };
4230
+ e.preventDefault();
4231
+ return true;
4232
+ }
4233
+ const narrow = selected.length > 1 && selected.includes(annotationId);
4234
+ if (narrow) e.stopPropagation();
4235
+ else if (markup) {
4236
+ e.stopPropagation();
4237
+ ctx.store.select([annotationId]);
4238
+ ctx.onActivate?.(annotationId);
4239
+ }
4240
+ mode = {
4241
+ kind: "press",
4242
+ id: annotationId,
4243
+ start: hit,
4244
+ narrow
4245
+ };
4246
+ e.preventDefault();
4247
+ return true;
4248
+ }
4249
+ if (!opts.marquee) {
4250
+ if (!e.shiftKey) ctx.store.select([]);
4251
+ return;
4252
+ }
4253
+ const el = hit.host.ownerDocument.createElement("div");
4254
+ el.className = "ink-marquee";
4255
+ el.dataset.inkMarquee = "";
4256
+ el.style.cssText = `position:absolute;pointer-events:none;left:${hit.css.x}px;top:${hit.css.y}px;width:0;height:0;`;
4257
+ hit.host.append(el);
4258
+ mode = {
4259
+ kind: "marquee",
4260
+ start: hit,
4261
+ el,
4262
+ additive: e.shiftKey
4263
+ };
4264
+ if (!e.shiftKey) ctx.store.select([]);
4265
+ e.preventDefault();
4266
+ return true;
4267
+ },
4268
+ onPointerMove(e) {
4269
+ if (mode.kind === "idle") return;
4270
+ if (e.buttons === 0 || !mode.start.host.isConnected) {
4271
+ reset();
4272
+ return;
4273
+ }
4274
+ const { start } = mode;
4275
+ if (mode.kind === "edit") return;
4276
+ if (mode.kind === "resize") {
4277
+ const { box, el, preview } = mode;
4278
+ const next = resizeCss(box, mode.corner, cornerAt(mode, e), minCss(start), {
4279
+ aspect: mode.locked || e.shiftKey,
4280
+ page: start.transform
4281
+ });
4282
+ if (preview) {
4283
+ const a = ctx.store.get(mode.id);
4284
+ if (a?.type !== "freetext") return;
4285
+ const draft = {
4286
+ ...a,
4287
+ ...resizedBox(a, userBox(start.transform, next))
4288
+ };
4289
+ showPreview(preview, el, draft, start);
4290
+ } else if (el) {
4291
+ const ox = box.left - (parseFloat(el.style.left) || 0);
4292
+ const oy = box.top - (parseFloat(el.style.top) || 0);
4293
+ el.style.transformOrigin = `${ox}px ${oy}px`;
4294
+ el.style.transform = `translate(${next.left - box.left}px, ${next.top - box.top}px) scale(${next.width / box.width}, ${next.height / box.height})`;
4295
+ }
4296
+ return;
4297
+ }
4298
+ if (mode.kind === "endpoint") {
4299
+ const a = ctx.store.get(mode.id);
4300
+ if (!a) return;
4301
+ const patch = endPatch(a, mode.which, endAt(mode, e));
4302
+ if (!patch) return;
4303
+ showPreview(mode.preview, mode.el, {
4304
+ ...a,
4305
+ ...patch
4306
+ }, start);
4307
+ return;
4308
+ }
4309
+ const css = cssIn(start.host, e);
4310
+ const dx = css.x - start.css.x;
4311
+ const dy = css.y - start.css.y;
4312
+ if (mode.kind === "press") {
4313
+ if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
4314
+ const a = ctx.store.get(mode.id);
4315
+ if (!a || a.locked) {
4316
+ mode = { kind: "idle" };
4317
+ return;
4318
+ }
4319
+ const selected = ctx.store.selection.get();
4320
+ const pressed = mode.id;
4321
+ const ids = selected.includes(pressed) ? selected.filter((id) => {
4322
+ const m = ctx.store.get(id);
4323
+ return m !== void 0 && !m.locked && (id === pressed || !isTextMarkup(m));
4324
+ }) : [pressed];
4325
+ mode = {
4326
+ kind: "move",
4327
+ ids,
4328
+ start,
4329
+ els: ids.map((id) => elementIn(ctx.root, id)).filter((el) => el !== void 0)
4330
+ };
4331
+ ctx.root.setAttribute("data-ink-dragging", "");
4332
+ }
4333
+ if (mode.kind === "move") {
4334
+ for (const el of mode.els) el.style.transform = `translate(${dx}px, ${dy}px)`;
4335
+ return;
4336
+ }
4337
+ mode.el.style.left = `${Math.min(start.css.x, css.x)}px`;
4338
+ mode.el.style.top = `${Math.min(start.css.y, css.y)}px`;
4339
+ mode.el.style.width = `${Math.abs(dx)}px`;
4340
+ mode.el.style.height = `${Math.abs(dy)}px`;
4341
+ },
4342
+ onPointerCancel() {
4343
+ reset();
4344
+ },
4345
+ onPointerUp(e) {
4346
+ const current = mode;
4347
+ reset();
4348
+ if (current.kind !== "idle" && !current.start.host.isConnected) return;
4349
+ switch (current.kind) {
4350
+ case "press":
4351
+ if (current.narrow) {
4352
+ ctx.store.select([current.id]);
4353
+ ctx.onActivate?.(current.id);
4354
+ }
4355
+ return;
4356
+ case "edit": {
4357
+ const el = elementIn(current.start.host, current.id);
4358
+ const a = ctx.store.get(current.id);
4359
+ if (!el || !canEditFreeText(a, el) || editorFor(el)) return;
4360
+ editFreeTextIn(ctx.store, el, a.id, ctx.viewport.transformFor(a.page), { caret: current.caret });
4361
+ return;
4362
+ }
4363
+ case "move": {
4364
+ const { start } = current;
4365
+ const end = start.transform.fromCss(cssIn(start.host, e));
4366
+ const dx = end.x - start.user.x;
4367
+ const dy = end.y - start.user.y;
4368
+ batch(() => {
4369
+ for (const id of current.ids) {
4370
+ const a = ctx.store.get(id);
4371
+ if (a && !a.locked) ctx.store.update(id, translated(a, dx, dy));
4372
+ }
4373
+ });
4374
+ return;
4375
+ }
4376
+ case "resize": {
4377
+ const { start, box } = current;
4378
+ const next = resizeCss(box, current.corner, cornerAt(current, e), minCss(start), {
4379
+ aspect: current.locked || e.shiftKey,
4380
+ page: start.transform
4381
+ });
4382
+ if (next.left === box.left && next.top === box.top && next.width === box.width && next.height === box.height) return;
4383
+ const a = ctx.store.get(current.id);
4384
+ if (!a || a.locked) return;
4385
+ const to = userBox(start.transform, next);
4386
+ if (a.type === "freetext") {
4387
+ ctx.store.update(a.id, resizedBox(a, to));
4388
+ return;
4389
+ }
4390
+ const from = hasRectGeometry(a) ? a.rect : start.transform.fromCssRect(box);
4391
+ ctx.store.update(a.id, scaled(a, from, to));
4392
+ return;
4393
+ }
4394
+ case "endpoint": {
4395
+ const a = ctx.store.get(current.id);
4396
+ if (!a || a.locked) return;
4397
+ const css = cssIn(current.start.host, e);
4398
+ if (css.x === current.start.css.x && css.y === current.start.css.y) return;
4399
+ const patch = endPatch(a, current.which, endAt(current, e));
4400
+ if (patch) ctx.store.update(a.id, patch);
4401
+ return;
4402
+ }
4403
+ case "marquee": {
4404
+ const { start } = current;
4405
+ const box = rectFromPoints(start.user, start.transform.fromCss(cssIn(start.host, e)));
4406
+ if (box.width === 0 && box.height === 0) return;
4407
+ const ids = withoutReplies(ctx.store.list.get()).filter((a) => a.page === start.page && rectsIntersect(a.rect, box)).map((a) => a.id);
4408
+ ctx.store.select(current.additive ? [.../* @__PURE__ */ new Set([...ctx.store.selection.get(), ...ids])] : ids);
4409
+ return;
4410
+ }
4411
+ }
4412
+ },
4413
+ onKeyDown(e) {
4414
+ if (e.key === "Escape") {
4415
+ reset();
4416
+ ctx.store.select([]);
4417
+ return;
4418
+ }
4419
+ if (e.key !== "Delete" && e.key !== "Backspace") return;
4420
+ if (e.defaultPrevented) return;
4421
+ if ((e.target instanceof Element ? e.target : null)?.closest("[data-annotation], input, textarea, [contenteditable]:not([contenteditable=\"false\"])")) return;
4422
+ for (const id of ctx.store.selection.get()) if (ctx.store.get(id)?.locked !== true) ctx.store.remove(id);
4423
+ }
4424
+ };
4425
+ }
4426
+ /** The handle (`nw`, `start`, ...) a press landed on, if it landed on one. */
4427
+ function handleOf(target) {
4428
+ if (!(target instanceof Element)) return void 0;
4429
+ return target.closest("[data-handle]")?.getAttribute("data-handle") ?? void 0;
4430
+ }
4431
+ /** True when a press landed inside a free text's drawn text box. */
4432
+ function inText(target) {
4433
+ return target instanceof Element && target.closest(".ink-free-text") !== null;
4434
+ }
4435
+ /** The point a line's end or a callout's tip handle sits on. */
4436
+ function endOf(a, which) {
4437
+ if (which === "callout") return hasCallout(a) ? a.callout[0] : void 0;
4438
+ return a.type === "line" ? a[which] : void 0;
4439
+ }
4440
+ /** The patch that moves a line's end, or a callout's tip, to `to`; undefined for neither. */
4441
+ function endPatch(a, which, to) {
4442
+ if (which === "callout") return hasCallout(a) ? movedTip(a, to) : void 0;
4443
+ return a.type === "line" ? movedEndpoint(a, which, to) : void 0;
4444
+ }
4445
+ /** A resized CSS box in user space, at least `MIN_SIZE` a side. */
4446
+ function userBox(t, css) {
4447
+ const r = t.fromCssRect(css);
4448
+ return {
4449
+ ...r,
4450
+ width: Math.max(1, r.width),
4451
+ height: Math.max(1, r.height)
4452
+ };
4453
+ }
4454
+ /** Draws `a` into the preview element at its own box. */
4455
+ function drawPreview(el, a, start) {
4456
+ const box = annotationBox(a, start.transform);
4457
+ el.style.left = `${box.left}px`;
4458
+ el.style.top = `${box.top}px`;
4459
+ el.style.width = `${box.width}px`;
4460
+ el.style.height = `${box.height}px`;
4461
+ renderAnnotationContent(el, a, start.transform);
4462
+ }
4463
+ /** Keeps a CSS point on the page. */
4464
+ function onPage(start, x, y) {
4465
+ const { cssWidth, cssHeight } = start.transform;
4466
+ return {
4467
+ x: Math.min(Math.max(x, 0), cssWidth),
4468
+ y: Math.min(Math.max(y, 0), cssHeight)
4469
+ };
4470
+ }
4471
+ /**
4472
+ * Where the dragged line end is now, in user space: where it started plus the pointer's
4473
+ * travel, kept on the page.
4474
+ */
4475
+ function endAt(mode, e) {
4476
+ const { start, from } = mode;
4477
+ const css = cssIn(start.host, e);
4478
+ return start.transform.fromCss(onPage(start, from.x + css.x - start.css.x, from.y + css.y - start.css.y));
4479
+ }
4480
+ /**
4481
+ * Where the dragged corner is now: where it started plus the pointer's travel since the
4482
+ * press, kept on the page. Keeping the press's offset from the corner means a click on a
4483
+ * handle, with no drag, leaves the box as it was.
4484
+ */
4485
+ function cornerAt(mode, e) {
4486
+ const { start, box, corner } = mode;
4487
+ const css = cssIn(start.host, e);
4488
+ return onPage(start, (corner[1] === "w" ? box.left : box.left + box.width) + css.x - start.css.x, (corner[0] === "n" ? box.top : box.top + box.height) + css.y - start.css.y);
4489
+ }
4490
+ /** `MIN_SIZE` points as CSS px on the press's page. */
4491
+ function minCss(start) {
4492
+ return start.transform.toCssLength(1);
4493
+ }
4494
+ /**
4495
+ * `box` with its `corner` dragged to `p` and the opposite corner fixed, at least `min` a side.
4496
+ * With `aspect`, the box keeps its proportions: it scales by the larger of the two axes'
4497
+ * changes, kept on the page.
4498
+ */
4499
+ function resizeCss(box, corner, p, min, opts) {
4500
+ const west = corner[1] === "w";
4501
+ const north = corner[0] === "n";
4502
+ /** The fixed corner. */
4503
+ const fx = west ? box.left + box.width : box.left;
4504
+ const fy = north ? box.top + box.height : box.top;
4505
+ let width = Math.max(west ? fx - p.x : p.x - fx, min);
4506
+ let height = Math.max(north ? fy - p.y : p.y - fy, min);
4507
+ if (opts.aspect && box.width > 0 && box.height > 0) {
4508
+ const roomX = west ? fx : opts.page.cssWidth - fx;
4509
+ const roomY = north ? fy : opts.page.cssHeight - fy;
4510
+ const lo = min / Math.min(box.width, box.height);
4511
+ const hi = Math.min(roomX / box.width, roomY / box.height);
4512
+ const s = Math.min(Math.max(width / box.width, height / box.height, lo), Math.max(hi, lo));
4513
+ width = box.width * s;
4514
+ height = box.height * s;
4515
+ }
4516
+ return {
4517
+ left: west ? fx - width : fx,
4518
+ top: north ? fy - height : fy,
4519
+ width,
4520
+ height
4521
+ };
4522
+ }
4523
+ //#endregion
4524
+ //#region src/dom/tools/controller.ts
4525
+ /** Host-relative CSS point for a client point. */
4526
+ function cssIn(host, e) {
4527
+ const r = host.getBoundingClientRect();
4528
+ return {
4529
+ x: e.clientX - r.left,
4530
+ y: e.clientY - r.top
4531
+ };
4532
+ }
4533
+ /** Like `cssIn`, clamped to the host's box, so a drag past the page edge stops at the edge. */
4534
+ function clampedCssIn(host, e) {
4535
+ const r = host.getBoundingClientRect();
4536
+ return {
4537
+ x: Math.min(Math.max(e.clientX - r.left, 0), r.width),
4538
+ y: Math.min(Math.max(e.clientY - r.top, 0), r.height)
4539
+ };
4540
+ }
4541
+ /** The mounted page under a client point, with the point in CSS px and user space. */
4542
+ function hitFor(ctx, e) {
4543
+ const hit = ctx.viewport.pageAtCss(e.clientX, e.clientY);
4544
+ if (!hit) return void 0;
4545
+ const css = cssIn(hit.host, e);
4546
+ return {
2691
4547
  ...hit,
2692
4548
  css,
2693
4549
  user: hit.transform.fromCss(css)
@@ -2710,26 +4566,92 @@ function createToolController$1(opts) {
2710
4566
  activeGroup: () => current?.styleGroup,
2711
4567
  active
2712
4568
  }),
2713
- options: () => registry.get(current?.id ?? active.get())
4569
+ options: () => registry.get(current?.id ?? active.get()),
4570
+ made(ids) {
4571
+ if (current?.kind !== "one-shot" || ids.length === 0) return;
4572
+ clearMade();
4573
+ made = [...ids];
4574
+ ctx.root.dataset.inkMade = "";
4575
+ stopMade = [ctx.store.list.subscribe(checkMade), ctx.store.selection.subscribe(checkMade)];
4576
+ },
4577
+ done() {
4578
+ clearMade();
4579
+ ctx.store.select([]);
4580
+ set("select-text");
4581
+ }
2714
4582
  };
2715
4583
  const originalCursor = ctx.root.style.cursor;
2716
4584
  /** The pointer between a primary press and its release or cancel. */
2717
4585
  let pressed;
2718
4586
  /** Pointers whose release belongs to a tool that is no longer active. */
2719
4587
  const swallow = /* @__PURE__ */ new Set();
4588
+ /** What the active one-shot tool made; undefined while it is ready. */
4589
+ let made;
4590
+ let stopMade = [];
4591
+ /** Whether the press now under way found the tool made (see the top of the file). */
4592
+ let pressFoundMade = false;
4593
+ /** Moves and resizes what the active one-shot tool made. */
4594
+ let madeEdit;
4595
+ /** The press now under way went to `madeEdit`, not the tool. */
4596
+ let editingMade = false;
4597
+ function clearMade() {
4598
+ stopMade.forEach((stop) => stop());
4599
+ stopMade = [];
4600
+ made = void 0;
4601
+ delete ctx.root.dataset.inkMade;
4602
+ }
4603
+ /** Leaves the made state when its items are gone, or switches when the selection moved on. */
4604
+ function checkMade() {
4605
+ if (!made) return;
4606
+ const store = ctx.store;
4607
+ if (!made.some((id) => (store.get(id) ?? store._getProposed(id)) !== void 0)) {
4608
+ clearMade();
4609
+ return;
4610
+ }
4611
+ const selected = store.selection.get();
4612
+ if (!made.some((id) => selected.includes(id))) set("select-text");
4613
+ }
2720
4614
  /** True for events from a pointer other than the one whose press is live. */
2721
4615
  const foreign = (e) => pressed !== void 0 && pressed !== e.pointerId;
4616
+ const readFirst = () => {
4617
+ pressFoundMade = made !== void 0;
4618
+ };
4619
+ const readAfterEditor = () => {
4620
+ pressFoundMade ||= made !== void 0;
4621
+ };
2722
4622
  const onDown = (e) => {
2723
4623
  if (!e.isPrimary || e.button !== 0 || inCustomContent(e.target)) return;
2724
4624
  if (foreign(e)) return;
2725
4625
  swallow.delete(e.pointerId);
2726
- pressed = e.pointerId;
2727
4626
  const annotationId = (e.target instanceof Element ? e.target : null)?.closest("[data-annotation]")?.getAttribute("data-annotation") ?? void 0;
4627
+ if (current?.kind === "one-shot") {
4628
+ if (pressFoundMade && annotationId === void 0 && !onScrollBar(e, ctx.root)) {
4629
+ e.preventDefault();
4630
+ swallow.add(e.pointerId);
4631
+ ctx.done();
4632
+ return;
4633
+ }
4634
+ const a = annotationId === void 0 ? void 0 : ctx.store.get(annotationId);
4635
+ if (a && made?.includes(a.id) && madeEdit) {
4636
+ pressed = e.pointerId;
4637
+ editingMade = true;
4638
+ if (madeEdit.onPointerDown(e, hitFor(ctx, e), a.id) === true) capture(e.pointerId);
4639
+ return;
4640
+ }
4641
+ if (a && (!isTextMarkup(a) || pressFoundMade)) set("select-text");
4642
+ }
4643
+ pressed = e.pointerId;
2728
4644
  if (current?.onPointerDown?.(e, hitFor(ctx, e), annotationId) === true) capture(e.pointerId);
2729
4645
  };
2730
4646
  const onMove = (e) => {
2731
4647
  if (!e.isPrimary || foreign(e)) return;
2732
- if (pressed === e.pointerId && e.buttons === 0) pressed = void 0;
4648
+ const missed = pressed === e.pointerId && e.buttons === 0;
4649
+ if (missed) pressed = void 0;
4650
+ if (editingMade) {
4651
+ madeEdit?.onPointerMove(e, hitFor(ctx, e));
4652
+ if (missed) editingMade = false;
4653
+ return;
4654
+ }
2733
4655
  current?.onPointerMove?.(e, hitFor(ctx, e));
2734
4656
  };
2735
4657
  const onUp = (e) => {
@@ -2739,7 +4661,10 @@ function createToolController$1(opts) {
2739
4661
  release(e.pointerId);
2740
4662
  return;
2741
4663
  }
2742
- current?.onPointerUp?.(e, hitFor(ctx, e));
4664
+ if (editingMade) {
4665
+ editingMade = false;
4666
+ madeEdit?.onPointerUp(e, hitFor(ctx, e));
4667
+ } else current?.onPointerUp?.(e, hitFor(ctx, e));
2743
4668
  release(e.pointerId);
2744
4669
  };
2745
4670
  const onCancel = (e) => {
@@ -2747,13 +4672,41 @@ function createToolController$1(opts) {
2747
4672
  if (e.type === "lostpointercapture" && pressed !== e.pointerId) return;
2748
4673
  if (pressed === e.pointerId) pressed = void 0;
2749
4674
  swallow.delete(e.pointerId);
2750
- current?.onPointerCancel?.(e);
4675
+ if (editingMade) {
4676
+ editingMade = false;
4677
+ madeEdit?.onPointerCancel();
4678
+ } else current?.onPointerCancel?.(e);
2751
4679
  };
2752
4680
  const onKey = (e) => {
2753
4681
  if (inCustomContent(e.target)) return;
4682
+ if (made && e.key === "Escape" && !e.defaultPrevented) {
4683
+ e.preventDefault();
4684
+ ctx.done();
4685
+ return;
4686
+ }
4687
+ if (made && (e.key === "Delete" || e.key === "Backspace")) {
4688
+ madeEdit?.onKeyDown(e);
4689
+ return;
4690
+ }
2754
4691
  current?.onKeyDown?.(e);
2755
4692
  if (isViewportEnter(e, ctx.root)) editSelectedText(e);
2756
4693
  };
4694
+ /**
4695
+ * Escape in an editor on a made text box, however it was opened (a press inside, Enter, a
4696
+ * double-click): commits and ends the tool. Captured, as the editor keeps its keys to itself.
4697
+ */
4698
+ const onEditorEscape = (e) => {
4699
+ if (!made || e.key !== "Escape" || e.isComposing || e.defaultPrevented) return;
4700
+ const target = e.target instanceof Element ? e.target : null;
4701
+ if (!target?.closest("[data-ink-editor]")) return;
4702
+ const el = target.closest("[data-annotation]");
4703
+ const id = el?.getAttribute("data-annotation");
4704
+ if (!el || !id || !made.includes(id)) return;
4705
+ e.preventDefault();
4706
+ e.stopPropagation();
4707
+ editorFor(el)?.close(true);
4708
+ ctx.done();
4709
+ };
2757
4710
  function editSelectedText(e) {
2758
4711
  const selected = ctx.store.selection.get();
2759
4712
  if (selected.length !== 1) return;
@@ -2774,26 +4727,35 @@ function createToolController$1(opts) {
2774
4727
  if (ctx.root.hasPointerCapture?.(id)) ctx.root.releasePointerCapture(id);
2775
4728
  } catch {}
2776
4729
  }
4730
+ const doc = ctx.root.ownerDocument;
4731
+ doc.defaultView?.addEventListener("pointerdown", readFirst, { capture: true });
4732
+ doc.addEventListener("pointerdown", readAfterEditor, { capture: true });
2777
4733
  ctx.root.addEventListener("pointerdown", onDown, { capture: true });
2778
4734
  ctx.root.addEventListener("pointermove", onMove);
2779
4735
  ctx.root.addEventListener("pointerup", onUp);
2780
4736
  ctx.root.addEventListener("pointercancel", onCancel);
2781
4737
  ctx.root.addEventListener("lostpointercapture", onCancel);
2782
4738
  ctx.root.addEventListener("keydown", onKey);
4739
+ ctx.root.addEventListener("keydown", onEditorEscape, { capture: true });
2783
4740
  function set(id, options) {
2784
4741
  const next = byId.get(id);
2785
4742
  if (!next) throw new RangeError(`Unknown tool ${id}`);
2786
4743
  if (options) registry.merge(id, options);
2787
4744
  if (pressed !== void 0) {
2788
- current?.onPointerCancel?.(new PointerEvent("pointercancel", {
4745
+ if (editingMade) madeEdit?.onPointerCancel();
4746
+ else current?.onPointerCancel?.(new PointerEvent("pointercancel", {
2789
4747
  pointerId: pressed,
2790
4748
  isPrimary: true
2791
4749
  }));
2792
4750
  swallow.add(pressed);
2793
4751
  pressed = void 0;
2794
4752
  }
4753
+ editingMade = false;
4754
+ madeEdit?.reset();
2795
4755
  current?.deactivate();
4756
+ clearMade();
2796
4757
  current = next;
4758
+ madeEdit = next.kind === "one-shot" ? createSelectionEdit(ctx) : void 0;
2797
4759
  next.activate(ctx);
2798
4760
  ctx.root.dataset.inkTool = id;
2799
4761
  ctx.root.style.cursor = next.cursor;
@@ -2812,14 +4774,20 @@ function createToolController$1(opts) {
2812
4774
  return registry.readable(id);
2813
4775
  },
2814
4776
  destroy() {
4777
+ madeEdit?.reset();
4778
+ madeEdit = void 0;
2815
4779
  current?.deactivate();
4780
+ clearMade();
2816
4781
  current = void 0;
4782
+ doc.defaultView?.removeEventListener("pointerdown", readFirst, { capture: true });
4783
+ doc.removeEventListener("pointerdown", readAfterEditor, { capture: true });
2817
4784
  ctx.root.removeEventListener("pointerdown", onDown, { capture: true });
2818
4785
  ctx.root.removeEventListener("pointermove", onMove);
2819
4786
  ctx.root.removeEventListener("pointerup", onUp);
2820
4787
  ctx.root.removeEventListener("pointercancel", onCancel);
2821
4788
  ctx.root.removeEventListener("lostpointercapture", onCancel);
2822
4789
  ctx.root.removeEventListener("keydown", onKey);
4790
+ ctx.root.removeEventListener("keydown", onEditorEscape, { capture: true });
2823
4791
  delete ctx.root.dataset.inkTool;
2824
4792
  ctx.root.style.cursor = originalCursor;
2825
4793
  }
@@ -2841,12 +4809,25 @@ function isViewportEnter(e, root) {
2841
4809
  return target.closest("[data-ink-text-layer], [data-ink-canvas-layer]") !== null;
2842
4810
  }
2843
4811
  /**
4812
+ * True for a press on `root` itself outside its client area: on a scroll bar (or its border).
4813
+ * Client coordinates only, so a synthetic press gives the same answer as a real one.
4814
+ */
4815
+ function onScrollBar(e, root) {
4816
+ if (e.target !== root) return false;
4817
+ const r = root.getBoundingClientRect();
4818
+ const x = e.clientX - r.left - root.clientLeft;
4819
+ const y = e.clientY - r.top - root.clientTop;
4820
+ return x < 0 || y < 0 || x >= root.clientWidth || y >= root.clientHeight;
4821
+ }
4822
+ /**
2844
4823
  * True for a target inside a custom annotation element (`data-custom`) but not that element
2845
4824
  * itself: the host's buttons and inputs there own their presses and keys, as in the overlay.
2846
4825
  */
2847
4826
  function inCustomContent(target) {
2848
4827
  if (!(target instanceof Element)) return false;
2849
4828
  if (target.closest("[data-ink-editor]")) return true;
4829
+ const layer = target.closest(".ink-plugin-layer");
4830
+ if (layer && layer !== target) return true;
2850
4831
  const el = target.closest("[data-annotation]");
2851
4832
  return el !== null && el !== target && el.hasAttribute("data-custom") && target.closest("[data-handle]") === null;
2852
4833
  }
@@ -2917,6 +4898,7 @@ function freehandTool(opts) {
2917
4898
  };
2918
4899
  return {
2919
4900
  id: opts.id,
4901
+ kind: "sticky",
2920
4902
  cursor: "crosshair",
2921
4903
  styleGroup: "ink",
2922
4904
  activate(c) {
@@ -3123,7 +5105,7 @@ function freetextTool() {
3123
5105
  };
3124
5106
  const editor = editFreeText(el, draftBox, {
3125
5107
  transform: t,
3126
- commit(contents, rect) {
5108
+ commit(contents, rect, _grown, how) {
3127
5109
  done();
3128
5110
  if (contents.trim() === "") return refocus();
3129
5111
  const created = c.store.add({
@@ -3133,7 +5115,9 @@ function freetextTool() {
3133
5115
  rect
3134
5116
  });
3135
5117
  c.store.select([created.id]);
5118
+ c.made([created.id]);
3136
5119
  refocus(created.id);
5120
+ if (how === "escape") c.done();
3137
5121
  },
3138
5122
  cancel() {
3139
5123
  done();
@@ -3159,6 +5143,7 @@ function freetextTool() {
3159
5143
  };
3160
5144
  return {
3161
5145
  id: "freetext",
5146
+ kind: "one-shot",
3162
5147
  cursor: "text",
3163
5148
  styleGroup: "text",
3164
5149
  defaultOptions: {},
@@ -3263,6 +5248,7 @@ function highlightTool() {
3263
5248
  };
3264
5249
  return {
3265
5250
  id: "highlight",
5251
+ kind: "sticky",
3266
5252
  cursor: "text",
3267
5253
  styleGroup: "markup",
3268
5254
  defaultOptions: { ...DEFAULT_HIGHLIGHT_OPTIONS },
@@ -3306,10 +5292,12 @@ function noteTool() {
3306
5292
  }
3307
5293
  });
3308
5294
  c.store.select([a.id]);
5295
+ c.made([a.id]);
3309
5296
  return a.id;
3310
5297
  };
3311
5298
  return {
3312
5299
  id: "note",
5300
+ kind: "one-shot",
3313
5301
  cursor: "copy",
3314
5302
  activate(c) {
3315
5303
  ctx = c;
@@ -3378,17 +5366,24 @@ function redactAreaTool() {
3378
5366
  let ctx;
3379
5367
  let start;
3380
5368
  let draft;
5369
+ /** Selects what the tool just added, together, and reports it made. */
5370
+ const made = (c, ids) => {
5371
+ c.store.select(ids);
5372
+ c.made(ids);
5373
+ };
3381
5374
  /** Text redactions from the live selection, one per page. False when nothing is selected. */
3382
5375
  const fromSelection = () => {
3383
5376
  if (!ctx) return false;
3384
5377
  const selections = ctx.selectionQuads();
3385
5378
  if (selections.length === 0) return false;
3386
- for (const s of selections) ctx.store.add(ctx.toolStyle.resolve("redact", {
5379
+ const c = ctx;
5380
+ const ids = selections.map((s) => c.store.add(c.toolStyle.resolve("redact", {
3387
5381
  type: "redact",
3388
5382
  page: s.page,
3389
5383
  quads: s.quads
3390
- }));
3391
- ctx.clearSelection();
5384
+ })).id);
5385
+ c.clearSelection();
5386
+ made(c, ids);
3392
5387
  return true;
3393
5388
  };
3394
5389
  const clear = () => {
@@ -3398,6 +5393,7 @@ function redactAreaTool() {
3398
5393
  };
3399
5394
  return {
3400
5395
  id: "redact-area",
5396
+ kind: "one-shot",
3401
5397
  cursor: "crosshair",
3402
5398
  styleGroup: "redact",
3403
5399
  activate(c) {
@@ -3428,422 +5424,49 @@ function redactAreaTool() {
3428
5424
  if (!draft) {
3429
5425
  draft = start.host.ownerDocument.createElement("div");
3430
5426
  draft.className = "ink-draft";
3431
- draft.dataset.inkDraft = "redact";
3432
- draft.style.position = "absolute";
3433
- draft.style.pointerEvents = "none";
3434
- start.host.append(draft);
3435
- }
3436
- draft.style.left = `${Math.min(start.css.x, css.x)}px`;
3437
- draft.style.top = `${Math.min(start.css.y, css.y)}px`;
3438
- draft.style.width = `${Math.abs(css.x - start.css.x)}px`;
3439
- draft.style.height = `${Math.abs(css.y - start.css.y)}px`;
3440
- },
3441
- onPointerCancel() {
3442
- clear();
3443
- },
3444
- onPointerUp(e) {
3445
- if (!ctx || !start) return;
3446
- if (!start.host.isConnected) {
3447
- clear();
3448
- return;
3449
- }
3450
- const css = clampedCssIn(start.host, e);
3451
- const width = Math.abs(css.x - start.css.x);
3452
- const height = Math.abs(css.y - start.css.y);
3453
- if (width >= MIN_CSS$1 && height >= MIN_CSS$1) {
3454
- const rect = start.transform.fromCssRect({
3455
- left: Math.min(start.css.x, css.x),
3456
- top: Math.min(start.css.y, css.y),
3457
- width,
3458
- height
3459
- });
3460
- ctx.store.add(ctx.toolStyle.resolve("redact", {
3461
- type: "redact",
3462
- page: start.page,
3463
- rect
3464
- }));
3465
- } else fromSelection();
3466
- clear();
3467
- },
3468
- onKeyDown(e) {
3469
- if (!!ctx && isViewportEnter(e, ctx.root) && fromSelection()) e.preventDefault();
3470
- }
3471
- };
3472
- }
3473
- //#endregion
3474
- //#region src/dom/tools/selection-edit.ts
3475
- const CORNERS = [
3476
- "nw",
3477
- "ne",
3478
- "sw",
3479
- "se"
3480
- ];
3481
- const ENDS = ["start", "end"];
3482
- const CALLOUT = "callout";
3483
- /** Pointer travel, CSS px, before a press on an annotation becomes a move. */
3484
- const DRAG_THRESHOLD = 3;
3485
- function createSelectionEdit(ctx, opts = {}) {
3486
- let mode = { kind: "idle" };
3487
- const batch = (fn) => ctx.history ? ctx.history.batch(fn) : fn();
3488
- const reset = () => {
3489
- if (mode.kind === "marquee") mode.el.remove();
3490
- if (mode.kind === "move") for (const el of mode.els) el.style.transform = "";
3491
- if (mode.kind === "resize" && mode.el) {
3492
- mode.el.style.transform = "";
3493
- mode.el.style.transformOrigin = "";
3494
- mode.el.style.visibility = "";
3495
- }
3496
- if (mode.kind === "resize") mode.preview?.remove();
3497
- if (mode.kind === "endpoint") {
3498
- mode.preview.remove();
3499
- if (mode.el) mode.el.style.visibility = "";
3500
- }
3501
- mode = { kind: "idle" };
3502
- };
3503
- const elementIn = (root, id) => root.querySelector(`[data-annotation="${CSS.escape(id)}"]`) ?? void 0;
3504
- const previewFor = (host, a) => {
3505
- const preview = host.ownerDocument.createElement("div");
3506
- preview.className = "ink-annotation ink-edit-preview";
3507
- preview.dataset.inkPreview = "";
3508
- preview.dataset.type = a.type;
3509
- preview.style.cssText = "position:absolute;pointer-events:none;";
3510
- return preview;
3511
- };
3512
- /** Shows `draft` in `preview` beside `el`, which is hidden meanwhile. */
3513
- const showPreview = (preview, el, draft, start) => {
3514
- drawPreview(preview, draft, start);
3515
- if (el) el.style.visibility = "hidden";
3516
- if (!preview.isConnected) (el?.parentElement ?? start.host).append(preview);
3517
- };
3518
- function startHandle(e, hit, a, handle) {
3519
- const el = elementIn(hit.host, a.id);
3520
- if (CORNERS.includes(handle) && hasCornerHandles(a)) {
3521
- const text = a.type === "freetext";
3522
- mode = {
3523
- kind: "resize",
3524
- id: a.id,
3525
- start: hit,
3526
- corner: handle,
3527
- box: text ? hit.transform.toCssRect(freeTextBox(a)) : el ? boxOf(el) : annotationBox(a, hit.transform),
3528
- el,
3529
- locked: keepsAspect(a),
3530
- preview: text && (a.fringe !== void 0 || hasCallout(a)) ? previewFor(hit.host, a) : void 0
3531
- };
3532
- } else if (ENDS.includes(handle) && a.type === "line" || handle === CALLOUT && hasCallout(a)) {
3533
- const preview = previewFor(hit.host, a);
3534
- mode = {
3535
- kind: "endpoint",
3536
- id: a.id,
3537
- start: hit,
3538
- which: handle,
3539
- from: hit.transform.toCss(endOf(a, handle)),
3540
- el,
3541
- preview
3542
- };
3543
- } else return false;
3544
- e.stopPropagation();
3545
- e.preventDefault();
3546
- return true;
3547
- }
3548
- return {
3549
- reset,
3550
- onPointerDown(e, hit, annotationId) {
3551
- if (!hit) return;
3552
- reset();
3553
- if (annotationId !== void 0) {
3554
- const a = ctx.store.get(annotationId);
3555
- if (!a) return;
3556
- const markup = isTextMarkup(a);
3557
- if (markup && !opts.marquee) return;
3558
- const handle = handleOf(e.target);
3559
- if (handle !== void 0 && !a.locked && startHandle(e, hit, a, handle)) return true;
3560
- const selected = ctx.store.selection.get();
3561
- if (e.shiftKey) {
3562
- e.stopPropagation();
3563
- ctx.store.select(selected.includes(annotationId) ? selected.filter((s) => s !== annotationId) : [...selected, annotationId]);
3564
- return;
3565
- }
3566
- const narrow = selected.length > 1 && selected.includes(annotationId);
3567
- if (narrow) e.stopPropagation();
3568
- else if (markup) {
3569
- e.stopPropagation();
3570
- ctx.store.select([annotationId]);
3571
- ctx.onActivate?.(annotationId);
3572
- }
3573
- mode = {
3574
- kind: "press",
3575
- id: annotationId,
3576
- start: hit,
3577
- narrow
3578
- };
3579
- e.preventDefault();
3580
- return true;
3581
- }
3582
- if (!opts.marquee) return;
3583
- const el = hit.host.ownerDocument.createElement("div");
3584
- el.className = "ink-marquee";
3585
- el.dataset.inkMarquee = "";
3586
- el.style.cssText = `position:absolute;pointer-events:none;left:${hit.css.x}px;top:${hit.css.y}px;width:0;height:0;`;
3587
- hit.host.append(el);
3588
- mode = {
3589
- kind: "marquee",
3590
- start: hit,
3591
- el,
3592
- additive: e.shiftKey
3593
- };
3594
- if (!e.shiftKey) ctx.store.select([]);
3595
- e.preventDefault();
3596
- return true;
3597
- },
3598
- onPointerMove(e) {
3599
- if (mode.kind === "idle") return;
3600
- if (e.buttons === 0 || !mode.start.host.isConnected) {
3601
- reset();
3602
- return;
3603
- }
3604
- const { start } = mode;
3605
- if (mode.kind === "resize") {
3606
- const { box, el, preview } = mode;
3607
- const next = resizeCss(box, mode.corner, cornerAt(mode, e), minCss(start), {
3608
- aspect: mode.locked || e.shiftKey,
3609
- page: start.transform
3610
- });
3611
- if (preview) {
3612
- const a = ctx.store.get(mode.id);
3613
- if (a?.type !== "freetext") return;
3614
- const draft = {
3615
- ...a,
3616
- ...resizedBox(a, userBox(start.transform, next))
3617
- };
3618
- showPreview(preview, el, draft, start);
3619
- } else if (el) {
3620
- el.style.transformOrigin = "0 0";
3621
- el.style.transform = `translate(${next.left - box.left}px, ${next.top - box.top}px) scale(${next.width / box.width}, ${next.height / box.height})`;
3622
- }
3623
- return;
3624
- }
3625
- if (mode.kind === "endpoint") {
3626
- const a = ctx.store.get(mode.id);
3627
- if (!a) return;
3628
- const patch = endPatch(a, mode.which, endAt(mode, e));
3629
- if (!patch) return;
3630
- showPreview(mode.preview, mode.el, {
3631
- ...a,
3632
- ...patch
3633
- }, start);
3634
- return;
3635
- }
3636
- const css = cssIn(start.host, e);
3637
- const dx = css.x - start.css.x;
3638
- const dy = css.y - start.css.y;
3639
- if (mode.kind === "press") {
3640
- if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
3641
- const a = ctx.store.get(mode.id);
3642
- if (!a || a.locked) {
3643
- mode = { kind: "idle" };
3644
- return;
3645
- }
3646
- const selected = ctx.store.selection.get();
3647
- const pressed = mode.id;
3648
- const ids = selected.includes(pressed) ? selected.filter((id) => {
3649
- const m = ctx.store.get(id);
3650
- return m !== void 0 && !m.locked && (id === pressed || !isTextMarkup(m));
3651
- }) : [pressed];
3652
- mode = {
3653
- kind: "move",
3654
- ids,
3655
- start,
3656
- els: ids.map((id) => elementIn(ctx.root, id)).filter((el) => el !== void 0)
3657
- };
3658
- }
3659
- if (mode.kind === "move") {
3660
- for (const el of mode.els) el.style.transform = `translate(${dx}px, ${dy}px)`;
3661
- return;
5427
+ draft.dataset.inkDraft = "redact";
5428
+ draft.style.position = "absolute";
5429
+ draft.style.pointerEvents = "none";
5430
+ start.host.append(draft);
3662
5431
  }
3663
- mode.el.style.left = `${Math.min(start.css.x, css.x)}px`;
3664
- mode.el.style.top = `${Math.min(start.css.y, css.y)}px`;
3665
- mode.el.style.width = `${Math.abs(dx)}px`;
3666
- mode.el.style.height = `${Math.abs(dy)}px`;
5432
+ draft.style.left = `${Math.min(start.css.x, css.x)}px`;
5433
+ draft.style.top = `${Math.min(start.css.y, css.y)}px`;
5434
+ draft.style.width = `${Math.abs(css.x - start.css.x)}px`;
5435
+ draft.style.height = `${Math.abs(css.y - start.css.y)}px`;
3667
5436
  },
3668
5437
  onPointerCancel() {
3669
- reset();
5438
+ clear();
3670
5439
  },
3671
5440
  onPointerUp(e) {
3672
- const current = mode;
3673
- reset();
3674
- if (current.kind !== "idle" && !current.start.host.isConnected) return;
3675
- switch (current.kind) {
3676
- case "press":
3677
- if (current.narrow) {
3678
- ctx.store.select([current.id]);
3679
- ctx.onActivate?.(current.id);
3680
- }
3681
- return;
3682
- case "move": {
3683
- const { start } = current;
3684
- const end = start.transform.fromCss(cssIn(start.host, e));
3685
- const dx = end.x - start.user.x;
3686
- const dy = end.y - start.user.y;
3687
- batch(() => {
3688
- for (const id of current.ids) {
3689
- const a = ctx.store.get(id);
3690
- if (a && !a.locked) ctx.store.update(id, translated(a, dx, dy));
3691
- }
3692
- });
3693
- return;
3694
- }
3695
- case "resize": {
3696
- const { start, box } = current;
3697
- const next = resizeCss(box, current.corner, cornerAt(current, e), minCss(start), {
3698
- aspect: current.locked || e.shiftKey,
3699
- page: start.transform
3700
- });
3701
- if (next.left === box.left && next.top === box.top && next.width === box.width && next.height === box.height) return;
3702
- const a = ctx.store.get(current.id);
3703
- if (!a || a.locked) return;
3704
- const to = userBox(start.transform, next);
3705
- if (a.type === "freetext") {
3706
- ctx.store.update(a.id, resizedBox(a, to));
3707
- return;
3708
- }
3709
- const from = hasRectGeometry(a) ? a.rect : start.transform.fromCssRect(box);
3710
- ctx.store.update(a.id, scaled(a, from, to));
3711
- return;
3712
- }
3713
- case "endpoint": {
3714
- const a = ctx.store.get(current.id);
3715
- if (!a || a.locked) return;
3716
- const css = cssIn(current.start.host, e);
3717
- if (css.x === current.start.css.x && css.y === current.start.css.y) return;
3718
- const patch = endPatch(a, current.which, endAt(current, e));
3719
- if (patch) ctx.store.update(a.id, patch);
3720
- return;
3721
- }
3722
- case "marquee": {
3723
- const { start } = current;
3724
- const box = rectFromPoints(start.user, start.transform.fromCss(cssIn(start.host, e)));
3725
- if (box.width === 0 && box.height === 0) return;
3726
- const ids = withoutReplies(ctx.store.list.get()).filter((a) => a.page === start.page && rectsIntersect(a.rect, box)).map((a) => a.id);
3727
- ctx.store.select(current.additive ? [.../* @__PURE__ */ new Set([...ctx.store.selection.get(), ...ids])] : ids);
3728
- return;
3729
- }
5441
+ if (!ctx || !start) return;
5442
+ if (!start.host.isConnected) {
5443
+ clear();
5444
+ return;
3730
5445
  }
5446
+ const css = clampedCssIn(start.host, e);
5447
+ const width = Math.abs(css.x - start.css.x);
5448
+ const height = Math.abs(css.y - start.css.y);
5449
+ if (width >= MIN_CSS$1 && height >= MIN_CSS$1) {
5450
+ const rect = start.transform.fromCssRect({
5451
+ left: Math.min(start.css.x, css.x),
5452
+ top: Math.min(start.css.y, css.y),
5453
+ width,
5454
+ height
5455
+ });
5456
+ const a = ctx.store.add(ctx.toolStyle.resolve("redact", {
5457
+ type: "redact",
5458
+ page: start.page,
5459
+ rect
5460
+ }));
5461
+ made(ctx, [a.id]);
5462
+ } else fromSelection();
5463
+ clear();
3731
5464
  },
3732
5465
  onKeyDown(e) {
3733
- if (e.key === "Escape") {
3734
- reset();
3735
- ctx.store.select([]);
3736
- return;
3737
- }
3738
- if (e.key !== "Delete" && e.key !== "Backspace") return;
3739
- if (e.defaultPrevented) return;
3740
- if ((e.target instanceof Element ? e.target : null)?.closest("[data-annotation], input, textarea, [contenteditable]:not([contenteditable=\"false\"])")) return;
3741
- for (const id of ctx.store.selection.get()) if (ctx.store.get(id)?.locked !== true) ctx.store.remove(id);
5466
+ if (!!ctx && isViewportEnter(e, ctx.root) && fromSelection()) e.preventDefault();
3742
5467
  }
3743
5468
  };
3744
5469
  }
3745
- /** The handle (`nw`, `start`, ...) a press landed on, if it landed on one. */
3746
- function handleOf(target) {
3747
- if (!(target instanceof Element)) return void 0;
3748
- return target.closest("[data-handle]")?.getAttribute("data-handle") ?? void 0;
3749
- }
3750
- /** An overlay element's CSS box, from its inline geometry. */
3751
- function boxOf(el) {
3752
- return {
3753
- left: parseFloat(el.style.left) || 0,
3754
- top: parseFloat(el.style.top) || 0,
3755
- width: parseFloat(el.style.width) || 0,
3756
- height: parseFloat(el.style.height) || 0
3757
- };
3758
- }
3759
- /** The point a line's end or a callout's tip handle sits on. */
3760
- function endOf(a, which) {
3761
- if (which === "callout") return hasCallout(a) ? a.callout[0] : void 0;
3762
- return a.type === "line" ? a[which] : void 0;
3763
- }
3764
- /** The patch that moves a line's end, or a callout's tip, to `to`; undefined for neither. */
3765
- function endPatch(a, which, to) {
3766
- if (which === "callout") return hasCallout(a) ? movedTip(a, to) : void 0;
3767
- return a.type === "line" ? movedEndpoint(a, which, to) : void 0;
3768
- }
3769
- /** A resized CSS box in user space, at least `MIN_SIZE` a side. */
3770
- function userBox(t, css) {
3771
- const r = t.fromCssRect(css);
3772
- return {
3773
- ...r,
3774
- width: Math.max(1, r.width),
3775
- height: Math.max(1, r.height)
3776
- };
3777
- }
3778
- /** Draws `a` into the preview element at its own box. */
3779
- function drawPreview(el, a, start) {
3780
- const box = annotationBox(a, start.transform);
3781
- el.style.left = `${box.left}px`;
3782
- el.style.top = `${box.top}px`;
3783
- el.style.width = `${box.width}px`;
3784
- el.style.height = `${box.height}px`;
3785
- renderAnnotationContent(el, a, start.transform);
3786
- }
3787
- /** Keeps a CSS point on the page. */
3788
- function onPage(start, x, y) {
3789
- const { cssWidth, cssHeight } = start.transform;
3790
- return {
3791
- x: Math.min(Math.max(x, 0), cssWidth),
3792
- y: Math.min(Math.max(y, 0), cssHeight)
3793
- };
3794
- }
3795
- /**
3796
- * Where the dragged line end is now, in user space: where it started plus the pointer's
3797
- * travel, kept on the page.
3798
- */
3799
- function endAt(mode, e) {
3800
- const { start, from } = mode;
3801
- const css = cssIn(start.host, e);
3802
- return start.transform.fromCss(onPage(start, from.x + css.x - start.css.x, from.y + css.y - start.css.y));
3803
- }
3804
- /**
3805
- * Where the dragged corner is now: where it started plus the pointer's travel since the
3806
- * press, kept on the page. Keeping the press's offset from the corner means a click on a
3807
- * handle, with no drag, leaves the box as it was.
3808
- */
3809
- function cornerAt(mode, e) {
3810
- const { start, box, corner } = mode;
3811
- const css = cssIn(start.host, e);
3812
- return onPage(start, (corner[1] === "w" ? box.left : box.left + box.width) + css.x - start.css.x, (corner[0] === "n" ? box.top : box.top + box.height) + css.y - start.css.y);
3813
- }
3814
- /** `MIN_SIZE` points as CSS px on the press's page. */
3815
- function minCss(start) {
3816
- return start.transform.toCssLength(1);
3817
- }
3818
- /**
3819
- * `box` with its `corner` dragged to `p` and the opposite corner fixed, at least `min` a side.
3820
- * With `aspect`, the box keeps its proportions: it scales by the larger of the two axes'
3821
- * changes, kept on the page.
3822
- */
3823
- function resizeCss(box, corner, p, min, opts) {
3824
- const west = corner[1] === "w";
3825
- const north = corner[0] === "n";
3826
- /** The fixed corner. */
3827
- const fx = west ? box.left + box.width : box.left;
3828
- const fy = north ? box.top + box.height : box.top;
3829
- let width = Math.max(west ? fx - p.x : p.x - fx, min);
3830
- let height = Math.max(north ? fy - p.y : p.y - fy, min);
3831
- if (opts.aspect && box.width > 0 && box.height > 0) {
3832
- const roomX = west ? fx : opts.page.cssWidth - fx;
3833
- const roomY = north ? fy : opts.page.cssHeight - fy;
3834
- const lo = min / Math.min(box.width, box.height);
3835
- const hi = Math.min(roomX / box.width, roomY / box.height);
3836
- const s = Math.min(Math.max(width / box.width, height / box.height, lo), Math.max(hi, lo));
3837
- width = box.width * s;
3838
- height = box.height * s;
3839
- }
3840
- return {
3841
- left: west ? fx - width : fx,
3842
- top: north ? fy - height : fy,
3843
- width,
3844
- height
3845
- };
3846
- }
3847
5470
  //#endregion
3848
5471
  //#region src/dom/tools/select.ts
3849
5472
  /**
@@ -3921,6 +5544,15 @@ function shapeTool() {
3921
5544
  };
3922
5545
  /** The input with the shape group's style and, for a bubble, the text group's text style. */
3923
5546
  const styled = (c, input) => {
5547
+ if (input.type === "line") {
5548
+ const line = c.toolStyle.resolve("shape", input);
5549
+ const arrow = input.intent === "LineArrow";
5550
+ return {
5551
+ ...line,
5552
+ head: line.head ?? "None",
5553
+ tail: line.tail ?? (arrow ? "OpenArrow" : "None")
5554
+ };
5555
+ }
3924
5556
  if (input.type !== "freetext") return c.toolStyle.resolve("shape", input);
3925
5557
  const shape = c.toolStyle.resolve("shape", {
3926
5558
  ...input,
@@ -3955,7 +5587,9 @@ function shapeTool() {
3955
5587
  let waiting;
3956
5588
  const openEditor = (c, a) => {
3957
5589
  const el = c.root.querySelector(`[data-annotation="${CSS.escape(a.id)}"]`);
3958
- return el !== null && editFreeTextIn(c.store, el, a.id, c.viewport.transformFor(a.page)) !== void 0;
5590
+ return el !== null && editFreeTextIn(c.store, el, a.id, c.viewport.transformFor(a.page), { onClose: (how) => {
5591
+ if (how === "escape" && ctx === c) c.done();
5592
+ } }) !== void 0;
3959
5593
  };
3960
5594
  /**
3961
5595
  * Opens the in-place editor on a new bubble. When a controlled host has not applied it yet,
@@ -3977,6 +5611,7 @@ function shapeTool() {
3977
5611
  if (!input) return;
3978
5612
  const created = c.store.add(styled(c, input));
3979
5613
  c.store.select([created.id]);
5614
+ c.made([created.id]);
3980
5615
  if (isBubble(created)) edit(c, created);
3981
5616
  };
3982
5617
  const paint = (c, from, e) => {
@@ -4045,6 +5680,7 @@ function shapeTool() {
4045
5680
  };
4046
5681
  return {
4047
5682
  id: "shape",
5683
+ kind: "one-shot",
4048
5684
  cursor: "crosshair",
4049
5685
  styleGroup: "shape",
4050
5686
  defaultOptions: { ...DEFAULT_SHAPE_OPTIONS },
@@ -4114,6 +5750,7 @@ function signatureTool() {
4114
5750
  const input = placeSignature(t, page, at, 150, rotation);
4115
5751
  const a = c.store.add(input.type === "ink" ? c.toolStyle.resolve("ink", input) : input);
4116
5752
  c.store.select([a.id]);
5753
+ c.made([a.id]);
4117
5754
  };
4118
5755
  /**
4119
5756
  * Places the selected template, from the library at once, or after reloading it when the
@@ -4137,6 +5774,7 @@ function signatureTool() {
4137
5774
  };
4138
5775
  return {
4139
5776
  id: "signature",
5777
+ kind: "one-shot",
4140
5778
  cursor: "copy",
4141
5779
  defaultOptions: {},
4142
5780
  activate(c) {
@@ -4647,9 +6285,11 @@ const COLOR_LABELS = {
4647
6285
  const COLOR_COLUMNS = 12;
4648
6286
  /**
4649
6287
  * What a colour menu shows for a style: the palette with the matching swatch checked, "No fill"
4650
- * for `fill`, and the trigger's swatch. Shared by the bindings and the vanilla menu.
6288
+ * for `fill`, and the trigger's swatch. Shared by the bindings and the vanilla menu. `fallback`
6289
+ * is the colour new annotations get while the style leaves it unset: the trigger shows it
6290
+ * rather than an empty swatch.
4651
6291
  */
4652
- function colorMenuModel(property, current, supported) {
6292
+ function colorMenuModel(property, current, supported, fallback) {
4653
6293
  const value = current[property];
4654
6294
  const enabled = supported.has(property);
4655
6295
  const color = typeof value === "string" && value !== "mixed" ? value.toUpperCase() : void 0;
@@ -4662,6 +6302,9 @@ function colorMenuModel(property, current, supported) {
4662
6302
  const trigger = !enabled ? { kind: "empty" } : value === "mixed" ? { kind: "mixed" } : value === null ? { kind: "none" } : typeof value === "string" ? {
4663
6303
  kind: "color",
4664
6304
  color: value
6305
+ } : fallback !== void 0 ? {
6306
+ kind: "color",
6307
+ color: fallback
4665
6308
  } : { kind: "empty" };
4666
6309
  return {
4667
6310
  label: COLOR_LABELS[property],
@@ -4679,7 +6322,7 @@ function colorMenuModel(property, current, supported) {
4679
6322
  checked: value === null,
4680
6323
  patch: { fill: null }
4681
6324
  } : null,
4682
- customValue: color ?? "#000000",
6325
+ customValue: color ?? fallback?.toUpperCase() ?? "#000000",
4683
6326
  custom: (hex) => patchFor(hex.toUpperCase())
4684
6327
  };
4685
6328
  }
@@ -4955,7 +6598,7 @@ function handleTabKeys(e, tablist) {
4955
6598
  tabs[next].focus({ preventScroll: true });
4956
6599
  tabs[next].click();
4957
6600
  }
4958
- const TABBABLE$1 = [
6601
+ const TABBABLE = [
4959
6602
  "a[href]",
4960
6603
  "button:not(:disabled)",
4961
6604
  "input:not(:disabled):not([type=\"hidden\"])",
@@ -4964,104 +6607,26 @@ const TABBABLE$1 = [
4964
6607
  "[tabindex]"
4965
6608
  ].map((s) => `${s}:not([tabindex="-1"])`).join(", ");
4966
6609
  /**
4967
- * Keeps Tab inside `container`, a modal: Tab from its last stop goes to its first, and
4968
- * Shift+Tab from the first to the last.
4969
- */
4970
- function trapTab(e, container) {
4971
- if (e.key !== "Tab" || e.defaultPrevented) return;
4972
- const stops = [...container.querySelectorAll(TABBABLE$1)].filter((el) => !el.matches(":disabled") && el.getClientRects().length > 0);
4973
- if (stops.length === 0) {
4974
- e.preventDefault();
4975
- return;
4976
- }
4977
- const first = stops[0];
4978
- const last = stops[stops.length - 1];
4979
- const active = document.activeElement;
4980
- if (e.shiftKey && (active === first || !container.contains(active))) {
4981
- e.preventDefault();
4982
- last.focus();
4983
- } else if (!e.shiftKey && (active === last || !container.contains(active))) {
4984
- e.preventDefault();
4985
- first.focus();
4986
- }
4987
- }
4988
- //#endregion
4989
- //#region src/dom/annotation-element.ts
4990
- /** The overlay element drawn for annotation `id` under `root`, if it is in the DOM. */
4991
- function annotationElementFor(root, id) {
4992
- return root.querySelector(`[data-annotation="${CSS.escape(id)}"]`);
4993
- }
4994
- /**
4995
- * Resolves the overlay element for `id` and keeps resolving while `root`'s subtree changes:
4996
- * the overlay creates and removes these elements outside any framework (a page mounts as it
4997
- * scrolls into view), so a popover anchored to one has to follow. Calls `onChange` once now
4998
- * and again whenever the answer changes. The returned function stops observing.
4999
- */
5000
- function observeAnnotationElement(root, id, onChange) {
5001
- let last;
5002
- const resolve = () => {
5003
- const found = annotationElementFor(root, id);
5004
- if (found === last) return;
5005
- last = found;
5006
- onChange(found);
5007
- };
5008
- resolve();
5009
- const observer = new MutationObserver(resolve);
5010
- observer.observe(root, {
5011
- childList: true,
5012
- subtree: true
5013
- });
5014
- return () => observer.disconnect();
5015
- }
5016
- //#endregion
5017
- //#region src/dom/popover-focus.ts
5018
- /**
5019
- * A press that will activate an annotation is not a dismissal: the popover follows the
5020
- * activation. Mirrors the overlay's rule: a press on the annotation element or on its drawn
5021
- * shape activates; a press on a custom element's own content belongs to that content.
6610
+ * Keeps Tab inside `container`, a modal: Tab from its last stop goes to its first, and
6611
+ * Shift+Tab from the first to the last.
5022
6612
  */
5023
- function shouldKeepOpenOnAnnotation(target) {
5024
- if (!(target instanceof Element)) return false;
5025
- const annotation = target.closest("[data-annotation]");
5026
- if (!annotation) return false;
5027
- return target === annotation || !annotation.hasAttribute("data-custom");
5028
- }
5029
- function createFocusReturn(opts) {
5030
- let fallbacks = [];
5031
- /** Set by `beforeRemove`: the close comes from removing the anchor's annotation. */
5032
- let removing = false;
5033
- return {
5034
- beforeRemove(anchor, prefer) {
5035
- fallbacks = [];
5036
- removing = true;
5037
- const siblings = anchor?.parentElement ? [...anchor.parentElement.children].filter((el) => el instanceof HTMLElement && el.hasAttribute("data-annotation")) : [];
5038
- const i = anchor ? siblings.indexOf(anchor) : -1;
5039
- if (i >= 0) {
5040
- const after = siblings.slice(i + 1);
5041
- const preferred = prefer ? after.filter((el) => el.matches(prefer)) : [];
5042
- fallbacks.push(...preferred, ...after, ...siblings.slice(0, i).reverse());
5043
- }
5044
- },
5045
- onCloseAutoFocus(event, anchor) {
5046
- event.preventDefault();
5047
- const plan = fallbacks;
5048
- const removed = removing;
5049
- fallbacks = [];
5050
- removing = false;
5051
- const root = opts.root();
5052
- if (!root) return;
5053
- const activated = opts.activated();
5054
- if (activated !== null && annotationElementFor(root, activated)) return;
5055
- const active = root.ownerDocument.activeElement;
5056
- if (active && active !== root && root.contains(active)) return;
5057
- const scroll = root.querySelector("[data-ink-scroll]");
5058
- (removed ? [...plan, scroll] : [
5059
- anchor,
5060
- ...plan,
5061
- scroll
5062
- ]).find((el) => el?.isConnected)?.focus({ preventScroll: true });
5063
- }
5064
- };
6613
+ function trapTab(e, container) {
6614
+ if (e.key !== "Tab" || e.defaultPrevented) return;
6615
+ const stops = [...container.querySelectorAll(TABBABLE)].filter((el) => !el.matches(":disabled") && el.getClientRects().length > 0);
6616
+ if (stops.length === 0) {
6617
+ e.preventDefault();
6618
+ return;
6619
+ }
6620
+ const first = stops[0];
6621
+ const last = stops[stops.length - 1];
6622
+ const active = document.activeElement;
6623
+ if (e.shiftKey && (active === first || !container.contains(active))) {
6624
+ e.preventDefault();
6625
+ last.focus();
6626
+ } else if (!e.shiftKey && (active === last || !container.contains(active))) {
6627
+ e.preventDefault();
6628
+ first.focus();
6629
+ }
5065
6630
  }
5066
6631
  //#endregion
5067
6632
  //#region src/dom/viewer-engine.ts
@@ -5076,7 +6641,15 @@ function mountViewer(opts) {
5076
6641
  let stopKeys = () => void 0;
5077
6642
  let stopActive = () => void 0;
5078
6643
  let destroyed = false;
5079
- const hostTools = opts.tools ?? [];
6644
+ const plugins = opts.plugins;
6645
+ const hostTools = [...opts.tools ?? [], ...plugins?.tools ?? []];
6646
+ /** Plugin layers follow the host for the engine's life, across rebuilds. */
6647
+ const layers = plugins && createPluginLayers(plugins);
6648
+ const decorate = plugins ? (a) => plugins.decorate(a) : (a) => opts.decorateAnnotation?.(a);
6649
+ /** A plugin's `ctx.redecorate()` reaches every page this engine has mounted. */
6650
+ const stopRedecorate = plugins?.watchRedecorate(() => {
6651
+ for (const mount of pages.values()) mount.overlay.redecorate();
6652
+ });
5080
6653
  /** Option values outlive rebuilds: every controller shares this registry. */
5081
6654
  const toolOptions = opts.toolOptions ?? createToolOptions();
5082
6655
  /** Without a host style, the engine's own follows the active tool across rebuilds. */
@@ -5105,6 +6678,8 @@ function mountViewer(opts) {
5105
6678
  store: doc.annotations,
5106
6679
  onActivate: (id) => opts.onActivate?.(id),
5107
6680
  isCustom: () => isCustom,
6681
+ decorate,
6682
+ ...layers ? { layers } : {},
5108
6683
  onError: (cause, layer) => {
5109
6684
  failed.add(layer);
5110
6685
  if (error) return;
@@ -5281,232 +6856,20 @@ function mountViewer(opts) {
5281
6856
  if (next.customAnnotations !== void 0) custom = next.customAnnotations;
5282
6857
  build(state);
5283
6858
  },
6859
+ redecorate() {
6860
+ if (destroyed) return;
6861
+ for (const mount of pages.values()) mount.overlay.redecorate();
6862
+ },
5284
6863
  destroy() {
5285
6864
  if (destroyed) return;
5286
6865
  destroyed = true;
5287
6866
  teardown();
6867
+ layers?.destroy();
6868
+ stopRedecorate?.();
5288
6869
  }
5289
6870
  };
5290
6871
  }
5291
6872
  //#endregion
5292
- //#region src/dom/viewer/dom.ts
5293
- /** `document.createElement` with attributes and children. */
5294
- function h(tag, attrs = {}, ...children) {
5295
- const el = document.createElement(tag);
5296
- for (const [name, value] of Object.entries(attrs)) el.setAttribute(name, value);
5297
- el.append(...children);
5298
- return el;
5299
- }
5300
- function button$1(label, onClick) {
5301
- const b = h("button", { type: "button" }, label);
5302
- b.addEventListener("click", onClick);
5303
- return b;
5304
- }
5305
- //#endregion
5306
- //#region src/dom/viewer/popover.ts
5307
- /** Space between the anchor and the panel, and between the panel and the window edge. */
5308
- const GAP = 6;
5309
- const MARGIN = 8;
5310
- const TABBABLE = "button, input, select, textarea, a[href], [tabindex]";
5311
- /**
5312
- * Fixed-positions `panel` below `anchor` (above it when only that fits), clamped to the
5313
- * window. Shared by the annotation popovers and the trigger-anchored menus.
5314
- */
5315
- function placePanel(panel, anchor) {
5316
- const { innerWidth: vw, innerHeight: vh } = window;
5317
- const w = panel.offsetWidth;
5318
- const ht = panel.offsetHeight;
5319
- let top = anchor.bottom + GAP;
5320
- if (top + ht > vh - MARGIN && anchor.top - GAP - ht >= MARGIN) top = anchor.top - GAP - ht;
5321
- top = Math.max(MARGIN, Math.min(top, vh - ht - MARGIN));
5322
- const left = Math.max(MARGIN, Math.min(anchor.left, vw - w - MARGIN));
5323
- panel.style.position = "fixed";
5324
- panel.style.left = `${left}px`;
5325
- panel.style.top = `${top}px`;
5326
- }
5327
- function createPopover(opts) {
5328
- const { root, focusReturn } = opts;
5329
- const panel = h("div", {
5330
- ...opts.attrs,
5331
- role: "dialog",
5332
- hidden: ""
5333
- });
5334
- document.body.append(panel);
5335
- let anchoredId = null;
5336
- let anchor = null;
5337
- let stopObserving = () => void 0;
5338
- /** The element that had focus in the panel when the anchor went away. */
5339
- let stranded = null;
5340
- const place = () => {
5341
- if (!anchor || panel.hidden) return;
5342
- placePanel(panel, anchor.getBoundingClientRect());
5343
- };
5344
- const resizes = new ResizeObserver(place);
5345
- resizes.observe(panel);
5346
- const onAnchor = (el, closing = false) => {
5347
- if (anchor) resizes.unobserve(anchor);
5348
- anchor = el;
5349
- if (!el) {
5350
- const active = document.activeElement;
5351
- if (!closing && !panel.hidden && active instanceof HTMLElement && panel.contains(active)) {
5352
- const scroll = root.querySelector("[data-ink-scroll]");
5353
- if (scroll) {
5354
- stranded = active;
5355
- scroll.focus({ preventScroll: true });
5356
- }
5357
- }
5358
- panel.hidden = true;
5359
- return;
5360
- }
5361
- resizes.observe(el);
5362
- const wasHidden = panel.hidden;
5363
- panel.hidden = false;
5364
- place();
5365
- if (wasHidden && stranded) {
5366
- const back = stranded;
5367
- stranded = null;
5368
- const scroll = root.querySelector("[data-ink-scroll]");
5369
- if (panel.contains(back) && document.activeElement === scroll) back.focus({ preventScroll: true });
5370
- }
5371
- };
5372
- const onKey = (e) => {
5373
- if (e.key !== "Escape" || panel.hidden || e.defaultPrevented) return;
5374
- const target = e.target;
5375
- if (!(target instanceof Node) || !(panel.contains(target) || root.contains(target))) return;
5376
- e.preventDefault();
5377
- opts.onDismiss();
5378
- };
5379
- const onPress = (e) => {
5380
- if (panel.hidden || panel.contains(e.target)) return;
5381
- if (shouldKeepOpenOnAnnotation(e.target)) return;
5382
- opts.onDismiss();
5383
- };
5384
- const onTab = (e) => {
5385
- if (e.key !== "Tab" || e.altKey || e.ctrlKey || e.metaKey) return;
5386
- const tabbable = [...panel.querySelectorAll(TABBABLE)].filter((el) => !el.disabled && el.tabIndex >= 0);
5387
- const first = tabbable[0];
5388
- const last = tabbable.at(-1);
5389
- if (!first || !last) return;
5390
- const active = document.activeElement;
5391
- if (e.shiftKey ? active !== first : active !== last) return;
5392
- e.preventDefault();
5393
- (e.shiftKey ? last : first).focus();
5394
- };
5395
- panel.addEventListener("keydown", onTab);
5396
- document.addEventListener("keydown", onKey);
5397
- document.addEventListener("pointerdown", onPress, true);
5398
- document.addEventListener("scroll", place, true);
5399
- window.addEventListener("resize", place);
5400
- return {
5401
- panel,
5402
- get anchor() {
5403
- return anchor;
5404
- },
5405
- show(id) {
5406
- if (id === anchoredId) return;
5407
- stopObserving();
5408
- anchoredId = id;
5409
- stopObserving = observeAnnotationElement(root, id, onAnchor);
5410
- },
5411
- hide() {
5412
- if (anchoredId === null) return;
5413
- stopObserving();
5414
- stopObserving = () => void 0;
5415
- anchoredId = null;
5416
- const back = anchor;
5417
- stranded = null;
5418
- onAnchor(null, true);
5419
- queueMicrotask(() => {
5420
- if (anchoredId !== null || !panel.isConnected) return;
5421
- focusReturn.onCloseAutoFocus(new Event("closeautofocus", { cancelable: true }), back);
5422
- });
5423
- },
5424
- destroy() {
5425
- stopObserving();
5426
- resizes.disconnect();
5427
- document.removeEventListener("keydown", onKey);
5428
- document.removeEventListener("pointerdown", onPress, true);
5429
- panel.removeEventListener("keydown", onTab);
5430
- document.removeEventListener("scroll", place, true);
5431
- window.removeEventListener("resize", place);
5432
- panel.remove();
5433
- }
5434
- };
5435
- }
5436
- //#endregion
5437
- //#region src/dom/viewer/suggestion-card.ts
5438
- function createSuggestionCard(opts) {
5439
- const { root, store, activated, focusReturn } = opts;
5440
- const popover = createPopover({
5441
- root,
5442
- attrs: {
5443
- class: "ink-suggestion-card",
5444
- "data-ink-suggestion-card": "",
5445
- "aria-label": "Suggested redaction"
5446
- },
5447
- focusReturn,
5448
- onDismiss: close
5449
- });
5450
- /** The suggestion as last rendered. */
5451
- let shown = null;
5452
- function suggestion(id) {
5453
- const a = id ? store.get(id) : void 0;
5454
- return a?.type === "redact" && a.origin === "suggested" ? a : null;
5455
- }
5456
- function target() {
5457
- const selection = store.selection.get();
5458
- return suggestion(activated.get()) ?? (selection.length === 1 ? suggestion(selection[0]) : null);
5459
- }
5460
- function update() {
5461
- const t = target();
5462
- if (t === shown) return;
5463
- const previous = shown;
5464
- shown = t;
5465
- if (!t) {
5466
- popover.hide();
5467
- return;
5468
- }
5469
- const hadFocus = popover.panel.contains(document.activeElement);
5470
- const opening = previous === null || previous.id !== t.id;
5471
- render(t);
5472
- popover.show(t.id);
5473
- if ((opening || hadFocus) && !popover.panel.hidden) popover.panel.querySelector("button")?.focus();
5474
- }
5475
- function render(a) {
5476
- const { suggestion: s } = a;
5477
- const bucket = s ? confidenceBucket(s.confidence) : void 0;
5478
- popover.panel.replaceChildren(h("p", { class: "ink-suggestion-reason" }, h("strong", {}, s?.reason ?? "Suggested redaction"), ...bucket ? [h("span", {
5479
- class: "ink-suggestion-confidence",
5480
- "data-confidence": bucket
5481
- }, `${bucket} confidence`)] : []), ...s?.explanation ? [h("p", { class: "ink-suggestion-explanation" }, s.explanation)] : [], h("div", { class: "ink-suggestion-actions" }, button$1("Accept", accept), ...a.locked ? [] : [button$1("Dismiss", dismiss)]));
5482
- }
5483
- function close() {
5484
- activated.set(null);
5485
- store.select([]);
5486
- }
5487
- function accept() {
5488
- if (shown) acceptSuggestion(store, shown.id);
5489
- close();
5490
- }
5491
- /** Locked annotations cannot be removed: Dismiss is not offered for them. */
5492
- function dismiss() {
5493
- if (!shown || shown.locked) return;
5494
- focusReturn.beforeRemove(popover.anchor, "[data-origin=\"suggested\"]");
5495
- store.remove(shown.id);
5496
- close();
5497
- }
5498
- const stops = [
5499
- store.list.subscribe(update),
5500
- store.selection.subscribe(update),
5501
- activated.subscribe(update)
5502
- ];
5503
- update();
5504
- return { destroy() {
5505
- for (const stop of stops) stop();
5506
- popover.destroy();
5507
- } };
5508
- }
5509
- //#endregion
5510
6873
  //#region src/dom/viewer/thread-popover.ts
5511
6874
  function createThreadPopover(opts) {
5512
6875
  const { root, store, activated, focusReturn } = opts;
@@ -5675,8 +7038,14 @@ function createThreadForm(n, actions) {
5675
7038
  }
5676
7039
  //#endregion
5677
7040
  //#region src/dom/viewer/create-viewer.ts
7041
+ const noop = () => void 0;
7042
+ const passThrough = {
7043
+ batch: (fn) => fn(),
7044
+ track: () => noop,
7045
+ react: () => noop
7046
+ };
5678
7047
  function createViewer(el, opts) {
5679
- const { source: initial, assets, password, defaults, history, threads: withThreads = true, suggestions: withSuggestions = true, onReady, onError, onActivate, onBuild, onPageMount, onPageUnmount, onPageError, toolStyle: _toolStyle, toolOptions: _toolOptions, signatureLibrary: _signatureLibrary, signatures: signatureStore, ...view } = opts;
7048
+ const { source: initial, assets, password, defaults, history, plugins, threads: withThreads = true, onReady, onError, onActivate, onBuild, onPageMount, onPageUnmount, onPageError, toolStyle: _toolStyle, toolOptions: _toolOptions, signatureLibrary: _signatureLibrary, signatures: signatureStore, ...view } = opts;
5680
7049
  /** One library for the viewer's life, so templates and the menu outlive each document. */
5681
7050
  const signatures = createSignatureLibrary(signatureStore ?? createMemorySignatureStore());
5682
7051
  /** Tool options persist for the viewer's life; the tool style is per document. */
@@ -5686,8 +7055,9 @@ function createViewer(el, opts) {
5686
7055
  const engine = writable(null);
5687
7056
  const viewport = writable(null);
5688
7057
  const tools = writable(null);
5689
- /** The activated annotation: the note thread and the suggestion card open from it. */
7058
+ /** The activated annotation: the note thread opens from it. */
5690
7059
  const activated = writable(null);
7060
+ const rootEl = writable(null);
5691
7061
  const root = h("div", {
5692
7062
  "data-ink-root": "",
5693
7063
  "data-state": "idle"
@@ -5706,6 +7076,24 @@ function createViewer(el, opts) {
5706
7076
  }, scroll);
5707
7077
  root.append(viewportRoot);
5708
7078
  el.append(root);
7079
+ rootEl.set(scroll);
7080
+ /**
7081
+ * One host for the viewer's life. It reads the history when a document opens, and `current`
7082
+ * is set before it notifies, so that is the new document's. With no document open, a
7083
+ * command's `batch` just runs its function.
7084
+ */
7085
+ const host = createPluginHost({
7086
+ plugins: plugins ?? [],
7087
+ document: readonly(current),
7088
+ history: () => current.get()?.history ?? passThrough,
7089
+ activated: readonly(activated),
7090
+ setActivated: (id) => {
7091
+ if (id === null || current.get()?.annotations.get(id)) activated.set(id);
7092
+ },
7093
+ root: readonly(rootEl),
7094
+ decorate: () => view.decorateAnnotation,
7095
+ viewport: () => viewport.get()
7096
+ });
5709
7097
  const focusReturn = createFocusReturn({
5710
7098
  root: () => root,
5711
7099
  activated: () => activated.get()
@@ -5734,20 +7122,15 @@ function createViewer(el, opts) {
5734
7122
  renderStatus(s);
5735
7123
  status.set(s);
5736
7124
  }
5737
- /** Mounts the engine and the popovers for a ready document; returns their teardown. */
7125
+ /** Mounts the engine and the thread popover for a ready document; returns their teardown. */
5738
7126
  function start(doc) {
5739
7127
  const store = doc.annotations;
5740
- const popovers = [withThreads ? createThreadPopover({
5741
- root,
5742
- store,
5743
- activated,
5744
- focusReturn
5745
- }) : null, withSuggestions ? createSuggestionCard({
7128
+ const threads = withThreads ? createThreadPopover({
5746
7129
  root,
5747
7130
  store,
5748
7131
  activated,
5749
7132
  focusReturn
5750
- }) : null];
7133
+ }) : null;
5751
7134
  const stopList = store.list.subscribe(() => {
5752
7135
  const id = activated.get();
5753
7136
  if (id !== null && !store.get(id)) activated.set(null);
@@ -5760,6 +7143,7 @@ function createViewer(el, opts) {
5760
7143
  };
5761
7144
  const mounted = mountViewer({
5762
7145
  ...view,
7146
+ plugins: host,
5763
7147
  container: scroll,
5764
7148
  document: doc,
5765
7149
  toolOptions,
@@ -5800,7 +7184,7 @@ function createViewer(el, opts) {
5800
7184
  engine.set(mounted);
5801
7185
  return () => {
5802
7186
  stopList();
5803
- for (const p of popovers) p?.destroy();
7187
+ threads?.destroy();
5804
7188
  activated.set(null);
5805
7189
  engine.set(null);
5806
7190
  mounted.destroy();
@@ -5856,22 +7240,41 @@ function createViewer(el, opts) {
5856
7240
  });
5857
7241
  }
5858
7242
  open(initial);
5859
- return {
7243
+ const viewer = {
5860
7244
  element: root,
5861
7245
  status: readonly(status),
5862
7246
  document: readonly(current),
5863
7247
  engine: readonly(engine),
5864
7248
  viewport: readonly(viewport),
5865
7249
  tools: readonly(tools),
7250
+ activated: readonly(activated),
7251
+ setActivated: (id) => {
7252
+ if (id === null || current.get()?.annotations.get(id)) activated.set(id);
7253
+ },
7254
+ commands: { get: (id) => host.command(id) },
5866
7255
  open,
5867
7256
  destroy() {
5868
7257
  if (destroyed) return;
5869
7258
  destroyed = true;
5870
7259
  loads++;
5871
7260
  close();
7261
+ host.destroy();
5872
7262
  root.remove();
5873
7263
  }
5874
7264
  };
7265
+ toolsByViewer.set(viewer, [...view.tools ?? [], ...host.tools]);
7266
+ commandIdsByViewer.set(viewer, () => [...host.commands.keys()]);
7267
+ return viewer;
7268
+ }
7269
+ const toolsByViewer = /* @__PURE__ */ new WeakMap();
7270
+ /** The host tools a viewer was given (its own and its plugins'), for the controls' labels and icons. */
7271
+ function hostToolsOf(viewer) {
7272
+ return toolsByViewer.get(viewer) ?? [];
7273
+ }
7274
+ const commandIdsByViewer = /* @__PURE__ */ new WeakMap();
7275
+ /** The ids of the commands a viewer's plugins registered, for the error on an unknown one. */
7276
+ function hostCommandIdsOf(viewer) {
7277
+ return commandIdsByViewer.get(viewer)?.() ?? [];
5875
7278
  }
5876
7279
  //#endregion
5877
7280
  //#region src/dom/viewer/controls.ts
@@ -5983,15 +7386,16 @@ function historyButton(viewer, action) {
5983
7386
  return b;
5984
7387
  }
5985
7388
  /**
5986
- * Sets the tool; pressed while it is the active one. A host tool's button has no icon (append
5987
- * your own) and is named by `label`, else by the tool id.
7389
+ * Sets the tool; pressed while it is the active one. A host or plugin tool's button takes the
7390
+ * tool's own `label` and `icon`; `opts.label` wins, and without either it is named by the tool id.
5988
7391
  */
5989
7392
  function createToolButton(viewer, tool, opts = {}) {
5990
- const b = button(opts.label ?? TOOL_LABELS[tool] ?? tool, {
7393
+ const own = hostToolsOf(viewer).find((t) => t.id === tool);
7394
+ const b = button(opts.label ?? TOOL_LABELS[tool] ?? own?.label ?? tool, {
5991
7395
  class: "ink-tool-button",
5992
7396
  "data-ink-tool-button": "",
5993
7397
  "data-tool": tool
5994
- }, TOOL_ICONS[tool]);
7398
+ }, TOOL_ICONS[tool] ?? own?.icon);
5995
7399
  const show = (active) => {
5996
7400
  const on = active === tool;
5997
7401
  b.dataset.state = on ? "on" : "off";
@@ -6001,6 +7405,48 @@ function createToolButton(viewer, tool, opts = {}) {
6001
7405
  follow(viewer.tools, b, (tools, watch) => watch(tools.active, show), () => show(null));
6002
7406
  return b;
6003
7407
  }
7408
+ /**
7409
+ * A plugin command as a button with its status text beside it. The button is named by the
7410
+ * command's label and disabled while the command is, except while it runs: then it keeps focus,
7411
+ * with `aria-busy` and `aria-disabled`, and a press does nothing. The status is a live region. A command without an icon shows its label as the button's text. For an id no
7412
+ * plugin registered it logs `InkError('UnknownCommand')` to the console and returns the same
7413
+ * markup disabled, `aria-hidden` and unnamed.
7414
+ */
7415
+ function createCommandButton(viewer, id) {
7416
+ const command = viewer.commands.get(id);
7417
+ const status = h("span", {
7418
+ class: "ink-command-status",
7419
+ role: "status"
7420
+ });
7421
+ if (!command) {
7422
+ console.error(unknownCommand(id, hostCommandIdsOf(viewer)));
7423
+ const pending = h("button", {
7424
+ type: "button",
7425
+ "data-command": id,
7426
+ "aria-hidden": "true"
7427
+ });
7428
+ pending.disabled = true;
7429
+ return h("div", { class: "ink-command" }, pending, status);
7430
+ }
7431
+ const b = button(command.label, { "data-command": id }, command.icon);
7432
+ if (!command.icon) b.textContent = command.label;
7433
+ b.addEventListener("click", () => void command.run());
7434
+ const sync = () => {
7435
+ const busy = command.busy.get();
7436
+ b.disabled = !command.enabled.get() && !busy;
7437
+ b.setAttribute("aria-busy", String(busy));
7438
+ if (busy) b.setAttribute("aria-disabled", "true");
7439
+ else b.removeAttribute("aria-disabled");
7440
+ };
7441
+ command.enabled.subscribe(sync);
7442
+ command.busy.subscribe(sync);
7443
+ sync();
7444
+ command.status.subscribe((text) => {
7445
+ status.textContent = text;
7446
+ });
7447
+ status.textContent = command.status.get();
7448
+ return h("div", { class: "ink-command" }, b, status);
7449
+ }
6004
7450
  function createZoomIn(viewer) {
6005
7451
  const b = button("Zoom in", {
6006
7452
  class: "ink-zoom-in",
@@ -6188,7 +7634,7 @@ function createMenu(trigger, opts) {
6188
7634
  let panel = null;
6189
7635
  let teardown = null;
6190
7636
  const place = () => {
6191
- if (panel) placePanel(panel, trigger.getBoundingClientRect());
7637
+ if (panel) placePanel(panel, (opts.anchor ?? trigger).getBoundingClientRect());
6192
7638
  };
6193
7639
  function close(restoreFocus = false) {
6194
7640
  if (!panel) return;
@@ -6404,7 +7850,7 @@ function createColorMenu(viewer, opts) {
6404
7850
  /** What the menu reads and writes: the whole tool style, or one group's view of it. */
6405
7851
  let style = null;
6406
7852
  let stops = [];
6407
- const model = () => colorMenuModel(property, style?.current.get() ?? {}, style?.supported.get() ?? /* @__PURE__ */ new Set());
7853
+ const model = () => colorMenuModel(property, style?.current.get() ?? {}, style?.supported.get() ?? /* @__PURE__ */ new Set(), opts.fallback?.get());
6408
7854
  function refresh() {
6409
7855
  const m = model();
6410
7856
  trigger.disabled = !m.enabled;
@@ -6518,6 +7964,7 @@ function createColorMenu(viewer, opts) {
6518
7964
  };
6519
7965
  follow(viewer.engine.get());
6520
7966
  viewer.engine.subscribe(follow);
7967
+ opts.fallback?.subscribe(refresh);
6521
7968
  return trigger;
6522
7969
  }
6523
7970
  //#endregion
@@ -6801,6 +8248,7 @@ function createShapeMenu(viewer) {
6801
8248
  }
6802
8249
  const menu = createMenu(trigger, {
6803
8250
  label: "Shapes",
8251
+ anchor: root,
6804
8252
  attrs: { "data-ink-menu": "shape" },
6805
8253
  render: (panel) => paint(panel, model())
6806
8254
  });
@@ -6859,10 +8307,13 @@ function createHighlightMenu(viewer) {
6859
8307
  }, main, trigger);
6860
8308
  main.disabled = true;
6861
8309
  trigger.disabled = true;
8310
+ /** The colour the chosen kind gets while the markup style leaves it unset. */
8311
+ const defaultColour = writable(void 0);
6862
8312
  const colour = createColorMenu(viewer, {
6863
8313
  property: "border",
6864
8314
  group: "markup",
6865
- label: COLOUR_LABEL
8315
+ label: COLOUR_LABEL,
8316
+ fallback: defaultColour
6866
8317
  });
6867
8318
  colour.setAttribute("role", "menuitem");
6868
8319
  colour.tabIndex = -1;
@@ -6883,6 +8334,7 @@ function createHighlightMenu(viewer) {
6883
8334
  const on = tools?.active.get() === "highlight";
6884
8335
  main.dataset.state = on ? "on" : "off";
6885
8336
  main.setAttribute("aria-pressed", String(on));
8337
+ defaultColour.set(viewer.document.get()?.annotations.defaults.color(m.kind));
6886
8338
  menu.refresh();
6887
8339
  }
6888
8340
  function paint(panel, m) {
@@ -6929,6 +8381,7 @@ function createHighlightMenu(viewer) {
6929
8381
  }
6930
8382
  const menu = createMenu(trigger, {
6931
8383
  label: "Highlights",
8384
+ anchor: root,
6932
8385
  attrs: { "data-ink-menu": "highlight" },
6933
8386
  owns: (t) => t instanceof Element && t.closest("[data-ink-menu=\"color-border\"]") !== null,
6934
8387
  render: (panel) => paint(panel, model())
@@ -6938,7 +8391,11 @@ function createHighlightMenu(viewer) {
6938
8391
  for (const stop of stops) stop();
6939
8392
  stops = [];
6940
8393
  tools = next;
6941
- if (tools) stops = [tools.active.subscribe(refresh), tools.options("highlight").subscribe(refresh)];
8394
+ if (tools) stops = [
8395
+ tools.active.subscribe(refresh),
8396
+ tools.options("highlight").subscribe(refresh),
8397
+ viewer.document.subscribe(refresh)
8398
+ ];
6942
8399
  if (!tools) menu.close();
6943
8400
  refresh();
6944
8401
  };
@@ -7695,6 +9152,7 @@ function createSignatureMenu(viewer, opts = {}) {
7695
9152
  }
7696
9153
  const menu = createMenu(trigger, {
7697
9154
  label: SIGNATURE_LABELS.menu,
9155
+ anchor: root,
7698
9156
  attrs: { "data-ink-menu": "signature" },
7699
9157
  render: (panel) => paint(panel, model())
7700
9158
  });
@@ -7731,6 +9189,6 @@ function createSignatureMenu(viewer, opts = {}) {
7731
9189
  return root;
7732
9190
  }
7733
9191
  //#endregion
7734
- export { ARROWHEAD_CHOICES, ARROWHEAD_ENDING, COLOR_COLUMNS, COLOR_LABELS, DASH_PATTERN, DEFAULT_CORNER_RADIUS, DEFAULT_FREETEXT_WIDTH, DEFAULT_HIGHLIGHT_OPTIONS, DEFAULT_SHAPE_SIZE, FONT_FAMILIES, LINE_WEIGHTS, MARKUP_ICONS, MARKUP_KINDS, MARKUP_LABELS, PALETTE, SHAPE_COLUMNS, SHAPE_ICONS, SHAPE_KINDS, SHAPE_LABELS, SIGNATURE_FONTS, SIGNATURE_IMAGE_MAX_LENGTH, SIGNATURE_IMAGE_MAX_SIDE, SIGNATURE_IMAGE_TYPES, SIGNATURE_LABELS, SIGNATURE_TABS, TOOL_LABELS, ZOOM_LEVELS, annotationElementFor, arrowheadChoice, attachViewportKeys, builtinTools, clampFontSize, colorMenuModel, computeCssLayout, confidenceAttr, createColorMenu, createFocusReturn, createHighlightMenu, createIcon, createImageSignature, createLineStyleMenu, createMenu, createNextPage, createOverlay, createPageCount, createPageInput, createPageMount, createPageRenderer, createPageTransform, createPrevPage, createRedo, createShapeMenu, createSignatureDialog, createSignatureDraft, createSignatureMenu, createSignaturePad, createSignaturePreview, createTextLayer, createTextStyleMenu, createToolButton, createToolController, createToolOptions, createToolbar, createToolbarSeparator, createTypedSignature, createUndo, createViewer, createViewport, createZoomIn, createZoomOut, createZoomSelect, cssRectsToUserRects, cssVariableDefaults, editFreeText, fitZoomCss, focusInitialMenuItem, fontFamilyValue, freetextTool, getSelectionQuads, handleMenuKeys, handleRadioKeys, handleTabKeys, highlightMenuModel, highlightTool, hitFor, lineStyleMenuModel, menuItems, mostVisiblePageCss, mountViewer, normalizeRotation, noteTool, observeAnnotationElement, redactAreaTool, renderAnnotationContent, renderTypedSignature, selectTextTool, selectTool, shapeMenuModel, shapeTool, shouldKeepOpenOnAnnotation, signatureMenuModel, signaturePreview, signatureTool, textStyleMenuModel, threadDeleteLabel, threadPopoverLabel, trackToolStyle, trapTab, visibleRangeCss, withHostTools };
9192
+ export { ARROWHEAD_CHOICES, ARROWHEAD_ENDING, COLOR_COLUMNS, COLOR_LABELS, DASH_PATTERN, DEFAULT_CORNER_RADIUS, DEFAULT_FREETEXT_WIDTH, DEFAULT_HIGHLIGHT_OPTIONS, DEFAULT_SHAPE_SIZE, FONT_FAMILIES, LINE_WEIGHTS, MARKUP_ICONS, MARKUP_KINDS, MARKUP_LABELS, PALETTE, SHAPE_COLUMNS, SHAPE_ICONS, SHAPE_KINDS, SHAPE_LABELS, SIGNATURE_FONTS, SIGNATURE_IMAGE_MAX_LENGTH, SIGNATURE_IMAGE_MAX_SIDE, SIGNATURE_IMAGE_TYPES, SIGNATURE_LABELS, SIGNATURE_TABS, TOOL_LABELS, ZOOM_LEVELS, annotationElementFor, arrowheadChoice, attachViewportKeys, builtinTools, clampFontSize, colorMenuModel, computeCssLayout, createColorMenu, createCommandButton, createFocusReturn, createHighlightMenu, createIcon, createImageSignature, createLineStyleMenu, createMenu, createNextPage, createOverlay, createPageCount, createPageInput, createPageMount, createPageRenderer, createPageTransform, createPluginHost, createPluginLayers, createPopover, createPrevPage, createRedo, createShapeMenu, createSignatureDialog, createSignatureDraft, createSignatureMenu, createSignaturePad, createSignaturePreview, createTextLayer, createTextStyleMenu, createToolButton, createToolController, createToolOptions, createToolbar, createToolbarSeparator, createTypedSignature, createUndo, createViewer, createViewport, createZoomIn, createZoomOut, createZoomSelect, cssRectsToUserRects, cssVariableDefaults, definePlugin, editFreeText, fitZoomCss, focusInitialMenuItem, fontFamilyValue, freetextTool, getSelectionQuads, handleMenuKeys, handleRadioKeys, handleTabKeys, highlightMenuModel, highlightTool, hitFor, lineStyleMenuModel, menuItems, mostVisiblePageCss, mountViewer, normalizeRotation, noteTool, observeAnnotationElement, placePanel, redactAreaTool, renderAnnotationContent, renderTypedSignature, selectTextTool, selectTool, shapeMenuModel, shapeTool, shouldKeepOpenOnAnnotation, signatureMenuModel, signaturePreview, signatureTool, textStyleMenuModel, threadDeleteLabel, threadPopoverLabel, trackToolStyle, trapTab, unknownCommand, visibleRangeCss, withHostTools };
7735
9193
 
7736
9194
  //# sourceMappingURL=dom.mjs.map