@inkbar/core 0.4.0 → 0.5.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 readonly, B as isTextMarkup, Bt as withCalloutBounds, E as withoutReplies, G as resizedRect, Gt as isKnown, H as movedEndpoint, Ht as LINE_ENDINGS, I as hasCallout, J as withBox, Jt as cssVariableDefaults, K as scaled, L as hasCornerHandles, Lt as calloutStroke, O as createToolStyle, R as hasRectGeometry, Rt as freeTextBox, S as isThreadRoot, T as threadRoot, U as movedTip, V as keepsAspect, W as resizedBox, Y as openDocument, Z as deriveRect, Zt as mergeLineRects, _ as defaultEnds, _n as isInkError, b as isAbandonedNote, d as recogniseStroke, dn as rectsIntersect, en as writable, f as DEFAULT_CORNER_RADIUS, g as constrain, gn as InkError, h as SHAPE_KINDS, i as createInkTemplate, l as placeSignature, ln as rectFromPoints, m as DEFAULT_SHAPE_SIZE, o as createMemorySignatureStore, p as DEFAULT_SHAPE_OPTIONS, q as translated, r as createImageTemplate, rn as invertMatrix, s as createSignatureLibrary, tn as applyMatrix, tt as isProduction, v as isPointerOnlyKind, w as threadOf, y as shapeInput, z as isBubble, zt as isStandardCallout } from "./signatures-CMapTKrC.mjs";
2
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
@@ -966,6 +966,8 @@ const CORNERS$1 = [
966
966
  ];
967
967
  /** Width in CSS px of the band along a mask's edge that takes presses. */
968
968
  const MASK_EDGE = 6;
969
+ /** Width in CSS px of the band straddling a text box's edge, which drags the box. */
970
+ const TEXT_EDGE = 6;
969
971
  /**
970
972
  * Syncs an element's edit handles (`.ink-handle[data-handle]`) with `spec`. Each is an 8px
971
973
  * `position:absolute; pointer-events:auto` box centred on its point; its look is the theme's.
@@ -999,15 +1001,62 @@ function syncHandles(node, spec) {
999
1001
  h.style.top = `${at.y - HANDLE_PX / 2}px`;
1000
1002
  }
1001
1003
  }
1004
+ /** Width in CSS px a thin stroke's invisible hit twin is widened to. */
1005
+ const HIT_STROKE = 8;
1006
+ /**
1007
+ * True when `a` is drawn as a stroke alone (a line, polyline, ink, or an unfilled square,
1008
+ * circle or polygon with a border), so a press beside the stroke would miss it. These get an
1009
+ * invisible, wider hit twin of their geometry.
1010
+ */
1011
+ function hasHitTwin(a) {
1012
+ switch (a.type) {
1013
+ case "line":
1014
+ case "polyline":
1015
+ case "ink": return true;
1016
+ case "polygon":
1017
+ case "square":
1018
+ case "circle": return a.interiorColor === void 0 && a.width > 0;
1019
+ default: return false;
1020
+ }
1021
+ }
1022
+ /** The width in CSS px of `a`'s hit twin: 8px, or the drawn stroke if that is wider. */
1023
+ function hitWidth(a, t) {
1024
+ return Math.max(HIT_STROKE, "width" in a ? t.toCssLength(a.width) : 0);
1025
+ }
1002
1026
  /**
1003
1027
  * The CSS box an annotation's default visual occupies. Usually `toCssRect(a.rect)`. Stroked
1004
1028
  * paths (lines, polylines, polygons, ink) use the bounds of their own points instead of
1005
1029
  * `rect` (which may already carry stroke padding, as a derived ink rect does), padded once by
1006
1030
  * 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)`.
1031
+ * line still has a box to draw and hit-test in. A shape with a hit twin (see `hasHitTwin`)
1032
+ * is padded by at least 4px more, so its wider hit area is not clipped. Custom elements do
1033
+ * not use this: they sit on `toCssRect(a.rect)`.
1009
1034
  */
1010
1035
  function annotationBox(a, t) {
1036
+ const drawn = handleBox(a, t);
1037
+ if (!hasHitTwin(a)) return drawn;
1038
+ const pad = hitPad(a, t);
1039
+ return {
1040
+ left: drawn.left - pad,
1041
+ top: drawn.top - pad,
1042
+ width: drawn.width + pad * 2,
1043
+ height: drawn.height + pad * 2
1044
+ };
1045
+ }
1046
+ /**
1047
+ * How far past `handleBox` a shape with a hit twin extends its element: the twin's overhang
1048
+ * beyond the drawn stroke, and at least 4px.
1049
+ */
1050
+ function hitPad(a, t) {
1051
+ const overhang = Math.max(0, hitWidth(a, t) - t.toCssLength("width" in a ? a.width : 0));
1052
+ return Math.max(HIT_STROKE / 2, overhang / 2);
1053
+ }
1054
+ /**
1055
+ * The CSS box an annotation's edit handles and resizing work on: `annotationBox` without the
1056
+ * padding its hit twin needs. `toCssRect(a.rect)` for a rect-shaped annotation; the padded
1057
+ * bounds of its points for a stroked path.
1058
+ */
1059
+ function handleBox(a, t) {
1011
1060
  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
1061
  if (!points || points.length === 0 || !("width" in a)) return t.toCssRect(a.rect);
1013
1062
  const box = t.toCssRect(pointBounds(points));
@@ -1200,7 +1249,7 @@ function renderAnnotationContent(el, a, t) {
1200
1249
  text.style.height = `${inner.height}px`;
1201
1250
  }
1202
1251
  text.style.pointerEvents = "auto";
1203
- el.append(text);
1252
+ el.append(text, edgeBand(doc, box, freeTextLocalBox(a, t)));
1204
1253
  appendSelectionFrame(el, a, t);
1205
1254
  return;
1206
1255
  }
@@ -1210,6 +1259,8 @@ function renderAnnotationContent(el, a, t) {
1210
1259
  }
1211
1260
  const svg = boxSvg(doc, box.width, box.height);
1212
1261
  svg.classList.add("ink-annotation-shape");
1262
+ /** Invisible wider copies of stroke-only geometry, added once the drawn shapes are. */
1263
+ const twins = [];
1213
1264
  switch (a.type) {
1214
1265
  case "highlight":
1215
1266
  for (const q of a.quads) svg.append(node("polygon", {
@@ -1252,23 +1303,34 @@ function renderAnnotationContent(el, a, t) {
1252
1303
  case "square":
1253
1304
  case "circle": {
1254
1305
  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
- });
1306
+ const pad = hasHitTwin(a) ? hitPad(a, t) : 0;
1307
+ const w = box.width - pad * 2;
1308
+ const h = box.height - pad * 2;
1309
+ const geometry = a.type === "square" ? {
1310
+ name: "rect",
1311
+ attrs: {
1312
+ x: pad + sw / 2,
1313
+ y: pad + sw / 2,
1314
+ width: Math.max(0, w - sw),
1315
+ height: Math.max(0, h - sw),
1316
+ ...a.cornerRadius ? { rx: t.toCssLength(a.cornerRadius) } : {}
1317
+ }
1318
+ } : {
1319
+ name: "ellipse",
1320
+ attrs: {
1321
+ cx: box.width / 2,
1322
+ cy: box.height / 2,
1323
+ rx: Math.max(0, w / 2 - sw / 2),
1324
+ ry: Math.max(0, h / 2 - sw / 2)
1325
+ }
1326
+ };
1327
+ const shape = node(geometry.name, geometry.attrs);
1267
1328
  shape.setAttribute("fill", a.interiorColor ?? "none");
1268
1329
  shape.setAttribute("stroke", a.color);
1269
1330
  shape.setAttribute("stroke-width", String(sw));
1270
1331
  dash(shape, a.dashes, t);
1271
1332
  svg.append(shape);
1333
+ if (hasHitTwin(a)) twins.push(hitTwin(node, geometry.name, geometry.attrs, hitWidth(a, t)));
1272
1334
  svg.setAttribute("opacity", String(a.opacity));
1273
1335
  break;
1274
1336
  }
@@ -1320,6 +1382,10 @@ function renderAnnotationContent(el, a, t) {
1320
1382
  });
1321
1383
  dash(stroked, a.dashes, t);
1322
1384
  svg.append(stroked);
1385
+ if (hasHitTwin(a)) twins.push(hitTwin(node, stroked.tagName, {
1386
+ points: stroked.getAttribute("points"),
1387
+ "stroke-linejoin": "round"
1388
+ }, hitWidth(a, t)));
1323
1389
  const ends = endingKinds(a);
1324
1390
  if (ends && pts.length >= 2) for (const [name, kind, tip, other] of [[
1325
1391
  "head",
@@ -1344,6 +1410,11 @@ function renderAnnotationContent(el, a, t) {
1344
1410
  });
1345
1411
  ending.setAttribute("data-ending", name);
1346
1412
  svg.append(ending);
1413
+ twins.push(hitTwin(node, "path", {
1414
+ d,
1415
+ "stroke-linejoin": "round",
1416
+ "stroke-linecap": "round"
1417
+ }, hitWidth(a, t)));
1347
1418
  }
1348
1419
  svg.setAttribute("opacity", String(a.opacity));
1349
1420
  break;
@@ -1356,14 +1427,54 @@ function renderAnnotationContent(el, a, t) {
1356
1427
  d: outlinePath(outline),
1357
1428
  fill: a.color
1358
1429
  }));
1430
+ twins.push(hitTwin(node, "polyline", {
1431
+ points: path.map(local).join(" "),
1432
+ "stroke-linejoin": "round",
1433
+ "stroke-linecap": "round"
1434
+ }, hitWidth(a, t)));
1359
1435
  });
1360
1436
  svg.setAttribute("opacity", String(a.opacity));
1361
1437
  }
1362
1438
  for (const shape of svg.children) shape.setAttribute("pointer-events", "visiblePainted");
1439
+ svg.append(...twins);
1363
1440
  el.append(svg);
1364
1441
  appendCommentMarker(el, a, t);
1365
1442
  }
1366
1443
  /**
1444
+ * An invisible copy of a stroke-only shape's geometry, `width` CSS px wide, that takes
1445
+ * presses on the stroke alone (`[data-ink-hit-stroke]`), so a thin line is easy to hit.
1446
+ */
1447
+ function hitTwin(node, name, attrs, width) {
1448
+ const twin = node(name, attrs);
1449
+ twin.setAttribute("data-ink-hit-stroke", "");
1450
+ twin.setAttribute("fill", "none");
1451
+ twin.setAttribute("stroke", "transparent");
1452
+ twin.setAttribute("stroke-width", String(width));
1453
+ twin.setAttribute("pointer-events", "stroke");
1454
+ return twin;
1455
+ }
1456
+ /**
1457
+ * The band a press on a free text's edge lands on: an invisible `rect[data-ink-edge]` stroke
1458
+ * `TEXT_EDGE` px wide along the text box's edge, half inside it and half out, in its own SVG
1459
+ * over the element. A press inside the box edits it; one on the band selects and drags it.
1460
+ * It is there whether or not the box draws a border. Geometry only.
1461
+ */
1462
+ function edgeBand(doc, el, box) {
1463
+ const svg = boxSvg(doc, el.width, el.height);
1464
+ const band = doc.createElementNS(SVG$2, "rect");
1465
+ band.setAttribute("x", String(box.left));
1466
+ band.setAttribute("y", String(box.top));
1467
+ band.setAttribute("width", String(box.width));
1468
+ band.setAttribute("height", String(box.height));
1469
+ band.setAttribute("fill", "none");
1470
+ band.setAttribute("stroke", "transparent");
1471
+ band.setAttribute("stroke-width", String(TEXT_EDGE));
1472
+ band.setAttribute("pointer-events", "stroke");
1473
+ band.setAttribute("data-ink-edge", "");
1474
+ svg.append(band);
1475
+ return svg;
1476
+ }
1477
+ /**
1367
1478
  * A stamp's `img.ink-stamp`, filling the element's box. The image is upright in user space
1368
1479
  * turned by the stamp's `rotation` (anticlockwise), and the page turns it again on screen
1369
1480
  * (clockwise), so it is drawn turned by the difference: a signature placed against the page's
@@ -1660,7 +1771,8 @@ const SAME_PX = .5;
1660
1771
  const MIN_WIDTH_EM = 2;
1661
1772
  /**
1662
1773
  * 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.
1774
+ * store yet), with the caret at `opts.caret` or after the text. `Escape` commits; a press
1775
+ * outside the editor, or focus leaving it, commits too.
1664
1776
  * Closing while the editor has focus hands focus to `el`. Keys typed in the
1665
1777
  * editor stop there, so no viewport or tool shortcut sees them. Opening a second editor on the
1666
1778
  * same element commits the first.
@@ -1716,7 +1828,7 @@ function editFreeText(el, a, opts) {
1716
1828
  return Math.abs(grown) < SAME_PX ? 0 : grown;
1717
1829
  };
1718
1830
  let closed = false;
1719
- const finish = (commit) => {
1831
+ const finish = (commit, how) => {
1720
1832
  if (closed) return;
1721
1833
  closed = true;
1722
1834
  const contents = read();
@@ -1730,27 +1842,27 @@ function editFreeText(el, a, opts) {
1730
1842
  box?.style.removeProperty("visibility");
1731
1843
  el.style.height = `${base.height}px`;
1732
1844
  refitBubble(el, a, opts.transform, base);
1733
- if (commit) opts.commit(contents, next, grown);
1845
+ if (commit) opts.commit(contents, next, grown, how);
1734
1846
  else opts.cancel();
1735
1847
  };
1736
1848
  const onOutside = (e) => {
1737
1849
  if (e.target instanceof Node && field.contains(e.target)) return;
1738
1850
  closingPresses.add(e);
1739
- finish(true);
1851
+ finish(true, "press");
1740
1852
  };
1741
1853
  view?.addEventListener("pointerdown", onOutside, true);
1742
1854
  field.addEventListener("keydown", (e) => {
1743
1855
  e.stopPropagation();
1744
1856
  if (e.key === "Escape" && !e.isComposing) {
1745
1857
  e.preventDefault();
1746
- finish(true);
1858
+ finish(true, "escape");
1747
1859
  }
1748
1860
  });
1749
1861
  field.addEventListener("focusout", (e) => {
1750
1862
  if (!doc.hasFocus()) return;
1751
1863
  const to = e.relatedTarget;
1752
1864
  if (to instanceof Node && field.contains(to)) return;
1753
- finish(true);
1865
+ finish(true, "focus");
1754
1866
  });
1755
1867
  field.addEventListener("paste", (e) => {
1756
1868
  e.preventDefault();
@@ -1762,14 +1874,17 @@ function editFreeText(el, a, opts) {
1762
1874
  field.focus({ preventScroll: true });
1763
1875
  const selection = doc.getSelection();
1764
1876
  if (selection) {
1765
- const range = doc.createRange();
1766
- range.selectNodeContents(field);
1767
- range.collapse(false);
1877
+ let range = opts.caret && caretIn(field, opts.caret);
1878
+ if (!range) {
1879
+ range = doc.createRange();
1880
+ range.selectNodeContents(field);
1881
+ range.collapse(false);
1882
+ }
1768
1883
  selection.removeAllRanges();
1769
1884
  selection.addRange(range);
1770
1885
  }
1771
1886
  grow();
1772
- const editor = { close: (commit) => finish(commit) };
1887
+ const editor = { close: (commit) => finish(commit, "api") };
1773
1888
  editors.set(el, editor);
1774
1889
  return editor;
1775
1890
  }
@@ -1785,37 +1900,44 @@ function canEditFreeText(a, el) {
1785
1900
  * Opens an editor on the overlay element `el` for the free-text box `id`, wired to the store: a
1786
1901
  * commit is one `update` with the text and the grown rect (none when neither changed), and an
1787
1902
  * 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`).
1903
+ * shows without text, so it is kept. `onClose` runs after the store change, with what closed
1904
+ * the editor; `caret` is a client point to put the caret at (`editFreeText`). Returns
1905
+ * undefined when the box cannot be edited (`canEditFreeText`).
1790
1906
  */
1791
- function editFreeTextIn(store, el, id, transform) {
1907
+ function editFreeTextIn(store, el, id, transform, opts = {}) {
1792
1908
  const a = store._getProposed(id) ?? store.get(id);
1793
1909
  if (!canEditFreeText(a, el)) return void 0;
1794
1910
  const current = () => {
1795
1911
  const now = store._getProposed(id) ?? store.get(id);
1796
1912
  return now?.type === "freetext" ? now : void 0;
1797
1913
  };
1914
+ const write = (contents, grown) => {
1915
+ const now = current();
1916
+ if (!now) return;
1917
+ if (contents.trim() === "" && !isBubble(now)) {
1918
+ store.remove(id);
1919
+ return;
1920
+ }
1921
+ if (grown === 0) {
1922
+ if (contents !== now.contents) store.update(id, { contents });
1923
+ return;
1924
+ }
1925
+ store.update(id, {
1926
+ contents,
1927
+ ...grownFreeText(now, grown, transform)
1928
+ });
1929
+ };
1798
1930
  return editFreeText(el, a, {
1799
1931
  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
- });
1932
+ caret: opts.caret,
1933
+ commit(contents, _rect, grown, how) {
1934
+ write(contents, grown);
1935
+ opts.onClose?.(how);
1815
1936
  },
1816
1937
  cancel() {
1817
1938
  const now = current();
1818
1939
  if (now && now.contents.trim() === "" && !isBubble(now)) store.remove(id);
1940
+ opts.onClose?.("api");
1819
1941
  }
1820
1942
  });
1821
1943
  }
@@ -1844,6 +1966,28 @@ function grownFreeText(a, grown, t) {
1844
1966
  };
1845
1967
  return withBox(a, box, resized.callout.map((p, i) => i === last ? p : down(p)));
1846
1968
  }
1969
+ /**
1970
+ * A collapsed range at the text position under a client point, if that position is in `field`.
1971
+ * The editor lies over the box with the same look, so a point on the box lands on the same
1972
+ * character in the editor.
1973
+ */
1974
+ function caretIn(field, at) {
1975
+ const doc = field.ownerDocument;
1976
+ let node;
1977
+ let offset = 0;
1978
+ if (typeof doc.caretPositionFromPoint === "function") {
1979
+ const p = doc.caretPositionFromPoint(at.clientX, at.clientY);
1980
+ if (p) ({offsetNode: node, offset} = p);
1981
+ } else if (typeof doc.caretRangeFromPoint === "function") {
1982
+ const r = doc.caretRangeFromPoint(at.clientX, at.clientY);
1983
+ if (r) ({startContainer: node, startOffset: offset} = r);
1984
+ }
1985
+ if (!node || !field.contains(node)) return void 0;
1986
+ const range = doc.createRange();
1987
+ range.setStart(node, offset);
1988
+ range.collapse(true);
1989
+ return range;
1990
+ }
1847
1991
  /** `plaintext-only` where the browser has it, else `true` (paste and formatting are then guarded). */
1848
1992
  function makeEditable(field) {
1849
1993
  try {
@@ -2160,12 +2304,8 @@ const LABELS = {
2160
2304
  mask: "Mask",
2161
2305
  redact: "Redaction"
2162
2306
  };
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
2307
  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];
2308
+ const base = a.subject === "Signature" && (a.type === "ink" || a.type === "stamp") ? "Signature" : LABELS[a.type];
2169
2309
  const named = hasComment(a) ? `${base} with comment` : base;
2170
2310
  return a.author ? `${named} by ${a.author}` : named;
2171
2311
  }
@@ -2173,14 +2313,60 @@ function ariaLabel(a) {
2173
2313
  function readingOrder(a, b) {
2174
2314
  return b.rect.y + b.rect.height - (a.rect.y + a.rect.height) || a.rect.x - b.rect.x;
2175
2315
  }
2316
+ /** A decoration's data key: lower-case kebab, as written after `data-`. */
2317
+ const DECORATION_KEY = /^[a-z][a-z0-9-]*$/;
2318
+ /**
2319
+ * `data-*` keys core writes on an annotation element or inside it; a decoration cannot set
2320
+ * them. `data-ink-*` is core's too.
2321
+ */
2322
+ const RESERVED_DATA = /* @__PURE__ */ new Set([
2323
+ "annotation",
2324
+ "type",
2325
+ "source",
2326
+ "locked",
2327
+ "state",
2328
+ "custom",
2329
+ "handle",
2330
+ "framed",
2331
+ "align",
2332
+ "bubble",
2333
+ "border",
2334
+ "icon",
2335
+ "ending"
2336
+ ]);
2337
+ /** Skipped decoration keys and classes already warned about. */
2338
+ const warnedDecorations = /* @__PURE__ */ new Set();
2339
+ function warnDecoration(what, why) {
2340
+ if (isProduction() || warnedDecorations.has(what)) return;
2341
+ warnedDecorations.add(what);
2342
+ console.warn(`[inkbar] annotation decoration: skipped ${what}: ${why}`);
2343
+ }
2344
+ function usableData(key) {
2345
+ if (!DECORATION_KEY.test(key)) {
2346
+ warnDecoration(`data key "${key}"`, "keys are lower-case kebab");
2347
+ return false;
2348
+ }
2349
+ if (RESERVED_DATA.has(key) || key === "ink" || key.startsWith("ink-")) {
2350
+ warnDecoration(`data key "${key}"`, "core sets it");
2351
+ return false;
2352
+ }
2353
+ return true;
2354
+ }
2355
+ function usableClass(name) {
2356
+ if (name.startsWith("ink-")) {
2357
+ warnDecoration(`class "${name}"`, "`ink-` classes are core's");
2358
+ return false;
2359
+ }
2360
+ if (name === "" || /\s/.test(name)) {
2361
+ warnDecoration(`class "${name}"`, "not a single class name");
2362
+ return false;
2363
+ }
2364
+ return true;
2365
+ }
2176
2366
  function setFlag(node, key, on) {
2177
2367
  if (on) node.dataset[key] = "";
2178
2368
  else delete node.dataset[key];
2179
2369
  }
2180
- function setData(node, key, value) {
2181
- if (value === void 0) delete node.dataset[key];
2182
- else node.dataset[key] = value;
2183
- }
2184
2370
  function createOverlay(opts) {
2185
2371
  const { store, page } = opts;
2186
2372
  const doc = opts.host.ownerDocument;
@@ -2199,6 +2385,8 @@ function createOverlay(opts) {
2199
2385
  /** What each element's content was last drawn from; absent when it has none of ours. */
2200
2386
  const drawn = /* @__PURE__ */ new Map();
2201
2387
  const hovered = /* @__PURE__ */ new Set();
2388
+ /** The decoration each element last got, so a later one can drop what it no longer has. */
2389
+ const decorated = /* @__PURE__ */ new WeakMap();
2202
2390
  let order = [];
2203
2391
  let destroyed = false;
2204
2392
  function stateOf(id, selected) {
@@ -2213,7 +2401,17 @@ function createOverlay(opts) {
2213
2401
  node.setAttribute("aria-pressed", String(state === "selected"));
2214
2402
  node.tabIndex = !anyHere || state === "selected" ? 0 : -1;
2215
2403
  const a = store.get(id);
2216
- syncHandles(node, a && !a.locked && state === "selected" && selectToolActive() ? handleSpec(node, a) : { kind: "none" });
2404
+ syncHandles(node, a && !a.locked && state === "selected" && editsSelection() ? handleSpec(node, a) : { kind: "none" });
2405
+ }
2406
+ /** `handleBox` relative to an element at `left`,`top`. */
2407
+ function handleLocalBox(a, left, top) {
2408
+ const box = handleBox(a, transform);
2409
+ return {
2410
+ left: box.left - left,
2411
+ top: box.top - top,
2412
+ width: box.width,
2413
+ height: box.height
2414
+ };
2217
2415
  }
2218
2416
  /**
2219
2417
  * Line ends for a line, corners for what a corner drag resizes (a free text's on its text
@@ -2242,8 +2440,7 @@ function createOverlay(opts) {
2242
2440
  ...freeTextLocalBox(a, transform)
2243
2441
  } : {
2244
2442
  kind: "corners",
2245
- width: parseFloat(node.style.width) || 0,
2246
- height: parseFloat(node.style.height) || 0
2443
+ ...handleLocalBox(a, left, top)
2247
2444
  };
2248
2445
  if (!hasCallout(a)) return corners;
2249
2446
  const tip = transform.toCss(a.callout[0]);
@@ -2257,11 +2454,13 @@ function createOverlay(opts) {
2257
2454
  }
2258
2455
  /**
2259
2456
  * True while the tool controller marks the tool root (an ancestor) with a select tool,
2260
- * `data-ink-tool="select"` or `"select-text"`.
2457
+ * `data-ink-tool="select"` or `"select-text"`, or with `data-ink-made`: what a creation tool
2458
+ * just made can be moved and resized too.
2261
2459
  */
2262
- function selectToolActive() {
2263
- const tool = opts.host.closest("[data-ink-tool]")?.getAttribute("data-ink-tool");
2264
- return tool === "select" || tool === "select-text";
2460
+ function editsSelection() {
2461
+ const root = opts.host.closest("[data-ink-tool]");
2462
+ const tool = root?.getAttribute("data-ink-tool");
2463
+ return tool === "select" || tool === "select-text" || root?.hasAttribute("data-ink-made") === true;
2265
2464
  }
2266
2465
  const toolRoot = opts.host.closest("[data-ink-tool]") ?? opts.host.closest("[data-ink-viewport]");
2267
2466
  const Observer = doc.defaultView?.MutationObserver;
@@ -2273,16 +2472,48 @@ function createOverlay(opts) {
2273
2472
  });
2274
2473
  toolObserver.observe(toolRoot, {
2275
2474
  attributes: true,
2276
- attributeFilter: ["data-ink-tool"]
2475
+ attributeFilter: ["data-ink-tool", "data-ink-made"]
2277
2476
  });
2278
2477
  }
2279
2478
  function applyAttributes(node, a) {
2280
2479
  node.dataset.type = a.type;
2281
2480
  node.dataset.source = a.source;
2282
2481
  setFlag(node, "locked", a.locked === true);
2283
- setData(node, "origin", a.type === "redact" ? a.origin : void 0);
2284
- setData(node, "confidence", confidenceAttr(a));
2285
2482
  node.setAttribute("aria-label", ariaLabel(a));
2483
+ applyDecoration(node, a);
2484
+ }
2485
+ /** The app's data attributes, classes and description, from `decorate`; a throw adds none. */
2486
+ function applyDecoration(node, a) {
2487
+ let decoration;
2488
+ try {
2489
+ decoration = opts.decorate?.(a);
2490
+ } catch (error) {
2491
+ console.error(error);
2492
+ }
2493
+ const prev = decorated.get(node);
2494
+ if (!decoration && !prev) return;
2495
+ const next = {
2496
+ data: /* @__PURE__ */ new Set(),
2497
+ classes: /* @__PURE__ */ new Set(),
2498
+ description: false
2499
+ };
2500
+ for (const [key, value] of Object.entries(decoration?.data ?? {})) {
2501
+ if (!usableData(key)) continue;
2502
+ node.setAttribute(`data-${key}`, value);
2503
+ next.data.add(key);
2504
+ }
2505
+ for (const name of decoration?.classes ?? []) {
2506
+ if (!usableClass(name)) continue;
2507
+ node.classList.add(name);
2508
+ next.classes.add(name);
2509
+ }
2510
+ if (decoration?.description) {
2511
+ node.setAttribute("aria-description", decoration.description);
2512
+ next.description = true;
2513
+ } else if (prev?.description) node.removeAttribute("aria-description");
2514
+ for (const key of prev?.data ?? []) if (!next.data.has(key)) node.removeAttribute(`data-${key}`);
2515
+ for (const name of prev?.classes ?? []) if (!next.classes.has(name)) node.classList.remove(name);
2516
+ decorated.set(node, next);
2286
2517
  }
2287
2518
  function applyGeometry(node, a) {
2288
2519
  const box = opts.isCustom?.(a) === true ? transform.toCssRect(a.rect) : annotationBox(a, transform);
@@ -2561,6 +2792,13 @@ function createOverlay(opts) {
2561
2792
  render();
2562
2793
  },
2563
2794
  elementFor: (id) => elements.get(id),
2795
+ redecorate() {
2796
+ if (destroyed) return;
2797
+ for (const [id, node] of elements) {
2798
+ const a = store.get(id);
2799
+ if (a) applyDecoration(node, a);
2800
+ }
2801
+ },
2564
2802
  destroy() {
2565
2803
  if (destroyed) return;
2566
2804
  closeEditors();
@@ -2611,7 +2849,8 @@ function createPageMount(opts) {
2611
2849
  document,
2612
2850
  ...opts.devicePixelRatio === void 0 ? {} : { devicePixelRatio: opts.devicePixelRatio },
2613
2851
  ...opts.onActivate ? { onActivate: (id) => opts.onActivate?.(id) } : {},
2614
- ...opts.isCustom ? { isCustom: (a) => opts.isCustom?.(a) === true } : {}
2852
+ ...opts.isCustom ? { isCustom: (a) => opts.isCustom?.(a) === true } : {},
2853
+ ...opts.decorate ? { decorate: opts.decorate } : {}
2615
2854
  });
2616
2855
  return {
2617
2856
  renderer,
@@ -2665,135 +2904,643 @@ function createToolOptions(tools = []) {
2665
2904
  };
2666
2905
  }
2667
2906
  //#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
- };
2676
- }
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();
2680
- 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)
2683
- };
2684
- }
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 {
2691
- ...hit,
2692
- css,
2693
- user: hit.transform.fromCss(css)
2694
- };
2695
- }
2696
- function createToolController$1(opts) {
2697
- const byId = new Map(opts.tools.map((t) => [t.id, t]));
2698
- const active = writable(opts.initial ?? "select-text");
2699
- let current;
2700
- const registry = opts.options ?? createToolOptions();
2701
- registry.register(opts.tools);
2702
- const ctx = {
2703
- ...opts.ctx,
2704
- get signatures() {
2705
- return opts.ctx.signatures;
2706
- },
2707
- toolStyle: opts.ctx.toolStyle ?? createToolStyle({
2708
- store: opts.ctx.store,
2709
- history: opts.ctx.history ?? { batch: (fn) => fn() },
2710
- activeGroup: () => current?.styleGroup,
2711
- active
2712
- }),
2713
- options: () => registry.get(current?.id ?? active.get())
2714
- };
2715
- const originalCursor = ctx.root.style.cursor;
2716
- /** The pointer between a primary press and its release or cancel. */
2717
- let pressed;
2718
- /** Pointers whose release belongs to a tool that is no longer active. */
2719
- const swallow = /* @__PURE__ */ new Set();
2720
- /** True for events from a pointer other than the one whose press is live. */
2721
- const foreign = (e) => pressed !== void 0 && pressed !== e.pointerId;
2722
- const onDown = (e) => {
2723
- if (!e.isPrimary || e.button !== 0 || inCustomContent(e.target)) return;
2724
- if (foreign(e)) return;
2725
- swallow.delete(e.pointerId);
2726
- pressed = e.pointerId;
2727
- const annotationId = (e.target instanceof Element ? e.target : null)?.closest("[data-annotation]")?.getAttribute("data-annotation") ?? void 0;
2728
- if (current?.onPointerDown?.(e, hitFor(ctx, e), annotationId) === true) capture(e.pointerId);
2729
- };
2730
- const onMove = (e) => {
2731
- if (!e.isPrimary || foreign(e)) return;
2732
- if (pressed === e.pointerId && e.buttons === 0) pressed = void 0;
2733
- current?.onPointerMove?.(e, hitFor(ctx, e));
2734
- };
2735
- const onUp = (e) => {
2736
- if (!e.isPrimary || e.button !== 0 || foreign(e)) return;
2737
- if (pressed === e.pointerId) pressed = void 0;
2738
- if (swallow.delete(e.pointerId)) {
2739
- release(e.pointerId);
2740
- return;
2907
+ //#region src/dom/tools/selection-edit.ts
2908
+ const CORNERS = [
2909
+ "nw",
2910
+ "ne",
2911
+ "sw",
2912
+ "se"
2913
+ ];
2914
+ const ENDS = ["start", "end"];
2915
+ const CALLOUT = "callout";
2916
+ /** Pointer travel, CSS px, before a press on an annotation becomes a move. */
2917
+ const DRAG_THRESHOLD = 3;
2918
+ function createSelectionEdit(ctx, opts = {}) {
2919
+ let mode = { kind: "idle" };
2920
+ const batch = (fn) => ctx.history ? ctx.history.batch(fn) : fn();
2921
+ const reset = () => {
2922
+ if (mode.kind === "marquee") mode.el.remove();
2923
+ if (mode.kind === "move") {
2924
+ for (const el of mode.els) el.style.transform = "";
2925
+ ctx.root.removeAttribute("data-ink-dragging");
2741
2926
  }
2742
- current?.onPointerUp?.(e, hitFor(ctx, e));
2743
- release(e.pointerId);
2927
+ if (mode.kind === "resize" && mode.el) {
2928
+ mode.el.style.transform = "";
2929
+ mode.el.style.transformOrigin = "";
2930
+ mode.el.style.visibility = "";
2931
+ }
2932
+ if (mode.kind === "resize") mode.preview?.remove();
2933
+ if (mode.kind === "endpoint") {
2934
+ mode.preview.remove();
2935
+ if (mode.el) mode.el.style.visibility = "";
2936
+ }
2937
+ mode = { kind: "idle" };
2744
2938
  };
2745
- const onCancel = (e) => {
2746
- if (!e.isPrimary || foreign(e)) return;
2747
- if (e.type === "lostpointercapture" && pressed !== e.pointerId) return;
2748
- if (pressed === e.pointerId) pressed = void 0;
2749
- swallow.delete(e.pointerId);
2750
- current?.onPointerCancel?.(e);
2939
+ const elementIn = (root, id) => root.querySelector(`[data-annotation="${CSS.escape(id)}"]`) ?? void 0;
2940
+ const previewFor = (host, a) => {
2941
+ const preview = host.ownerDocument.createElement("div");
2942
+ preview.className = "ink-annotation ink-edit-preview";
2943
+ preview.dataset.inkPreview = "";
2944
+ preview.dataset.type = a.type;
2945
+ preview.style.cssText = "position:absolute;pointer-events:none;";
2946
+ return preview;
2751
2947
  };
2752
- const onKey = (e) => {
2753
- if (inCustomContent(e.target)) return;
2754
- current?.onKeyDown?.(e);
2755
- if (isViewportEnter(e, ctx.root)) editSelectedText(e);
2948
+ /** Shows `draft` in `preview` beside `el`, which is hidden meanwhile. */
2949
+ const showPreview = (preview, el, draft, start) => {
2950
+ drawPreview(preview, draft, start);
2951
+ if (el) el.style.visibility = "hidden";
2952
+ if (!preview.isConnected) (el?.parentElement ?? start.host).append(preview);
2756
2953
  };
2757
- function editSelectedText(e) {
2758
- const selected = ctx.store.selection.get();
2759
- if (selected.length !== 1) return;
2760
- const id = selected[0];
2761
- const a = ctx.store.get(id);
2762
- const el = ctx.root.querySelector(`[data-annotation="${CSS.escape(id)}"]`);
2763
- if (!canEditFreeText(a, el) || !el) return;
2954
+ function startHandle(e, hit, a, handle) {
2955
+ const el = elementIn(hit.host, a.id);
2956
+ if (CORNERS.includes(handle) && hasCornerHandles(a)) {
2957
+ const text = a.type === "freetext";
2958
+ mode = {
2959
+ kind: "resize",
2960
+ id: a.id,
2961
+ start: hit,
2962
+ corner: handle,
2963
+ box: text ? hit.transform.toCssRect(freeTextBox(a)) : handleBox(a, hit.transform),
2964
+ el,
2965
+ locked: keepsAspect(a),
2966
+ preview: text && (a.fringe !== void 0 || hasCallout(a)) ? previewFor(hit.host, a) : void 0
2967
+ };
2968
+ } else if (ENDS.includes(handle) && a.type === "line" || handle === CALLOUT && hasCallout(a)) {
2969
+ const preview = previewFor(hit.host, a);
2970
+ mode = {
2971
+ kind: "endpoint",
2972
+ id: a.id,
2973
+ start: hit,
2974
+ which: handle,
2975
+ from: hit.transform.toCss(endOf(a, handle)),
2976
+ el,
2977
+ preview
2978
+ };
2979
+ } else return false;
2980
+ e.stopPropagation();
2764
2981
  e.preventDefault();
2765
- editFreeTextIn(ctx.store, el, id, ctx.viewport.transformFor(a.page));
2766
- }
2767
- function capture(id) {
2768
- try {
2769
- ctx.root.setPointerCapture?.(id);
2770
- } catch {}
2771
- }
2772
- function release(id) {
2773
- try {
2774
- if (ctx.root.hasPointerCapture?.(id)) ctx.root.releasePointerCapture(id);
2775
- } catch {}
2982
+ return true;
2776
2983
  }
2777
- ctx.root.addEventListener("pointerdown", onDown, { capture: true });
2778
- ctx.root.addEventListener("pointermove", onMove);
2779
- ctx.root.addEventListener("pointerup", onUp);
2780
- ctx.root.addEventListener("pointercancel", onCancel);
2781
- ctx.root.addEventListener("lostpointercapture", onCancel);
2782
- ctx.root.addEventListener("keydown", onKey);
2783
- function set(id, options) {
2984
+ return {
2985
+ reset,
2986
+ onPointerDown(e, hit, annotationId) {
2987
+ if (!hit) return;
2988
+ reset();
2989
+ if (annotationId !== void 0) {
2990
+ const a = ctx.store.get(annotationId);
2991
+ if (!a) return;
2992
+ const markup = isTextMarkup(a);
2993
+ if (markup && !opts.marquee) return;
2994
+ const handle = handleOf(e.target);
2995
+ if (handle !== void 0 && !a.locked && startHandle(e, hit, a, handle)) return true;
2996
+ const selected = ctx.store.selection.get();
2997
+ if (e.shiftKey) {
2998
+ e.stopPropagation();
2999
+ ctx.store.select(selected.includes(annotationId) ? selected.filter((s) => s !== annotationId) : [...selected, annotationId]);
3000
+ return;
3001
+ }
3002
+ if (inText(e.target) && canEditFreeText(a, elementIn(hit.host, annotationId))) {
3003
+ e.stopPropagation();
3004
+ ctx.store.select([annotationId]);
3005
+ ctx.onActivate?.(annotationId);
3006
+ mode = {
3007
+ kind: "edit",
3008
+ id: annotationId,
3009
+ start: hit,
3010
+ caret: {
3011
+ clientX: e.clientX,
3012
+ clientY: e.clientY
3013
+ }
3014
+ };
3015
+ e.preventDefault();
3016
+ return true;
3017
+ }
3018
+ const narrow = selected.length > 1 && selected.includes(annotationId);
3019
+ if (narrow) e.stopPropagation();
3020
+ else if (markup) {
3021
+ e.stopPropagation();
3022
+ ctx.store.select([annotationId]);
3023
+ ctx.onActivate?.(annotationId);
3024
+ }
3025
+ mode = {
3026
+ kind: "press",
3027
+ id: annotationId,
3028
+ start: hit,
3029
+ narrow
3030
+ };
3031
+ e.preventDefault();
3032
+ return true;
3033
+ }
3034
+ if (!opts.marquee) {
3035
+ if (!e.shiftKey) ctx.store.select([]);
3036
+ return;
3037
+ }
3038
+ const el = hit.host.ownerDocument.createElement("div");
3039
+ el.className = "ink-marquee";
3040
+ el.dataset.inkMarquee = "";
3041
+ el.style.cssText = `position:absolute;pointer-events:none;left:${hit.css.x}px;top:${hit.css.y}px;width:0;height:0;`;
3042
+ hit.host.append(el);
3043
+ mode = {
3044
+ kind: "marquee",
3045
+ start: hit,
3046
+ el,
3047
+ additive: e.shiftKey
3048
+ };
3049
+ if (!e.shiftKey) ctx.store.select([]);
3050
+ e.preventDefault();
3051
+ return true;
3052
+ },
3053
+ onPointerMove(e) {
3054
+ if (mode.kind === "idle") return;
3055
+ if (e.buttons === 0 || !mode.start.host.isConnected) {
3056
+ reset();
3057
+ return;
3058
+ }
3059
+ const { start } = mode;
3060
+ if (mode.kind === "edit") return;
3061
+ if (mode.kind === "resize") {
3062
+ const { box, el, preview } = mode;
3063
+ const next = resizeCss(box, mode.corner, cornerAt(mode, e), minCss(start), {
3064
+ aspect: mode.locked || e.shiftKey,
3065
+ page: start.transform
3066
+ });
3067
+ if (preview) {
3068
+ const a = ctx.store.get(mode.id);
3069
+ if (a?.type !== "freetext") return;
3070
+ const draft = {
3071
+ ...a,
3072
+ ...resizedBox(a, userBox(start.transform, next))
3073
+ };
3074
+ showPreview(preview, el, draft, start);
3075
+ } else if (el) {
3076
+ const ox = box.left - (parseFloat(el.style.left) || 0);
3077
+ const oy = box.top - (parseFloat(el.style.top) || 0);
3078
+ el.style.transformOrigin = `${ox}px ${oy}px`;
3079
+ el.style.transform = `translate(${next.left - box.left}px, ${next.top - box.top}px) scale(${next.width / box.width}, ${next.height / box.height})`;
3080
+ }
3081
+ return;
3082
+ }
3083
+ if (mode.kind === "endpoint") {
3084
+ const a = ctx.store.get(mode.id);
3085
+ if (!a) return;
3086
+ const patch = endPatch(a, mode.which, endAt(mode, e));
3087
+ if (!patch) return;
3088
+ showPreview(mode.preview, mode.el, {
3089
+ ...a,
3090
+ ...patch
3091
+ }, start);
3092
+ return;
3093
+ }
3094
+ const css = cssIn(start.host, e);
3095
+ const dx = css.x - start.css.x;
3096
+ const dy = css.y - start.css.y;
3097
+ if (mode.kind === "press") {
3098
+ if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
3099
+ const a = ctx.store.get(mode.id);
3100
+ if (!a || a.locked) {
3101
+ mode = { kind: "idle" };
3102
+ return;
3103
+ }
3104
+ const selected = ctx.store.selection.get();
3105
+ const pressed = mode.id;
3106
+ const ids = selected.includes(pressed) ? selected.filter((id) => {
3107
+ const m = ctx.store.get(id);
3108
+ return m !== void 0 && !m.locked && (id === pressed || !isTextMarkup(m));
3109
+ }) : [pressed];
3110
+ mode = {
3111
+ kind: "move",
3112
+ ids,
3113
+ start,
3114
+ els: ids.map((id) => elementIn(ctx.root, id)).filter((el) => el !== void 0)
3115
+ };
3116
+ ctx.root.setAttribute("data-ink-dragging", "");
3117
+ }
3118
+ if (mode.kind === "move") {
3119
+ for (const el of mode.els) el.style.transform = `translate(${dx}px, ${dy}px)`;
3120
+ return;
3121
+ }
3122
+ mode.el.style.left = `${Math.min(start.css.x, css.x)}px`;
3123
+ mode.el.style.top = `${Math.min(start.css.y, css.y)}px`;
3124
+ mode.el.style.width = `${Math.abs(dx)}px`;
3125
+ mode.el.style.height = `${Math.abs(dy)}px`;
3126
+ },
3127
+ onPointerCancel() {
3128
+ reset();
3129
+ },
3130
+ onPointerUp(e) {
3131
+ const current = mode;
3132
+ reset();
3133
+ if (current.kind !== "idle" && !current.start.host.isConnected) return;
3134
+ switch (current.kind) {
3135
+ case "press":
3136
+ if (current.narrow) {
3137
+ ctx.store.select([current.id]);
3138
+ ctx.onActivate?.(current.id);
3139
+ }
3140
+ return;
3141
+ case "edit": {
3142
+ const el = elementIn(current.start.host, current.id);
3143
+ const a = ctx.store.get(current.id);
3144
+ if (!el || !canEditFreeText(a, el) || editorFor(el)) return;
3145
+ editFreeTextIn(ctx.store, el, a.id, ctx.viewport.transformFor(a.page), { caret: current.caret });
3146
+ return;
3147
+ }
3148
+ case "move": {
3149
+ const { start } = current;
3150
+ const end = start.transform.fromCss(cssIn(start.host, e));
3151
+ const dx = end.x - start.user.x;
3152
+ const dy = end.y - start.user.y;
3153
+ batch(() => {
3154
+ for (const id of current.ids) {
3155
+ const a = ctx.store.get(id);
3156
+ if (a && !a.locked) ctx.store.update(id, translated(a, dx, dy));
3157
+ }
3158
+ });
3159
+ return;
3160
+ }
3161
+ case "resize": {
3162
+ const { start, box } = current;
3163
+ const next = resizeCss(box, current.corner, cornerAt(current, e), minCss(start), {
3164
+ aspect: current.locked || e.shiftKey,
3165
+ page: start.transform
3166
+ });
3167
+ if (next.left === box.left && next.top === box.top && next.width === box.width && next.height === box.height) return;
3168
+ const a = ctx.store.get(current.id);
3169
+ if (!a || a.locked) return;
3170
+ const to = userBox(start.transform, next);
3171
+ if (a.type === "freetext") {
3172
+ ctx.store.update(a.id, resizedBox(a, to));
3173
+ return;
3174
+ }
3175
+ const from = hasRectGeometry(a) ? a.rect : start.transform.fromCssRect(box);
3176
+ ctx.store.update(a.id, scaled(a, from, to));
3177
+ return;
3178
+ }
3179
+ case "endpoint": {
3180
+ const a = ctx.store.get(current.id);
3181
+ if (!a || a.locked) return;
3182
+ const css = cssIn(current.start.host, e);
3183
+ if (css.x === current.start.css.x && css.y === current.start.css.y) return;
3184
+ const patch = endPatch(a, current.which, endAt(current, e));
3185
+ if (patch) ctx.store.update(a.id, patch);
3186
+ return;
3187
+ }
3188
+ case "marquee": {
3189
+ const { start } = current;
3190
+ const box = rectFromPoints(start.user, start.transform.fromCss(cssIn(start.host, e)));
3191
+ if (box.width === 0 && box.height === 0) return;
3192
+ const ids = withoutReplies(ctx.store.list.get()).filter((a) => a.page === start.page && rectsIntersect(a.rect, box)).map((a) => a.id);
3193
+ ctx.store.select(current.additive ? [.../* @__PURE__ */ new Set([...ctx.store.selection.get(), ...ids])] : ids);
3194
+ return;
3195
+ }
3196
+ }
3197
+ },
3198
+ onKeyDown(e) {
3199
+ if (e.key === "Escape") {
3200
+ reset();
3201
+ ctx.store.select([]);
3202
+ return;
3203
+ }
3204
+ if (e.key !== "Delete" && e.key !== "Backspace") return;
3205
+ if (e.defaultPrevented) return;
3206
+ if ((e.target instanceof Element ? e.target : null)?.closest("[data-annotation], input, textarea, [contenteditable]:not([contenteditable=\"false\"])")) return;
3207
+ for (const id of ctx.store.selection.get()) if (ctx.store.get(id)?.locked !== true) ctx.store.remove(id);
3208
+ }
3209
+ };
3210
+ }
3211
+ /** The handle (`nw`, `start`, ...) a press landed on, if it landed on one. */
3212
+ function handleOf(target) {
3213
+ if (!(target instanceof Element)) return void 0;
3214
+ return target.closest("[data-handle]")?.getAttribute("data-handle") ?? void 0;
3215
+ }
3216
+ /** True when a press landed inside a free text's drawn text box. */
3217
+ function inText(target) {
3218
+ return target instanceof Element && target.closest(".ink-free-text") !== null;
3219
+ }
3220
+ /** The point a line's end or a callout's tip handle sits on. */
3221
+ function endOf(a, which) {
3222
+ if (which === "callout") return hasCallout(a) ? a.callout[0] : void 0;
3223
+ return a.type === "line" ? a[which] : void 0;
3224
+ }
3225
+ /** The patch that moves a line's end, or a callout's tip, to `to`; undefined for neither. */
3226
+ function endPatch(a, which, to) {
3227
+ if (which === "callout") return hasCallout(a) ? movedTip(a, to) : void 0;
3228
+ return a.type === "line" ? movedEndpoint(a, which, to) : void 0;
3229
+ }
3230
+ /** A resized CSS box in user space, at least `MIN_SIZE` a side. */
3231
+ function userBox(t, css) {
3232
+ const r = t.fromCssRect(css);
3233
+ return {
3234
+ ...r,
3235
+ width: Math.max(1, r.width),
3236
+ height: Math.max(1, r.height)
3237
+ };
3238
+ }
3239
+ /** Draws `a` into the preview element at its own box. */
3240
+ function drawPreview(el, a, start) {
3241
+ const box = annotationBox(a, start.transform);
3242
+ el.style.left = `${box.left}px`;
3243
+ el.style.top = `${box.top}px`;
3244
+ el.style.width = `${box.width}px`;
3245
+ el.style.height = `${box.height}px`;
3246
+ renderAnnotationContent(el, a, start.transform);
3247
+ }
3248
+ /** Keeps a CSS point on the page. */
3249
+ function onPage(start, x, y) {
3250
+ const { cssWidth, cssHeight } = start.transform;
3251
+ return {
3252
+ x: Math.min(Math.max(x, 0), cssWidth),
3253
+ y: Math.min(Math.max(y, 0), cssHeight)
3254
+ };
3255
+ }
3256
+ /**
3257
+ * Where the dragged line end is now, in user space: where it started plus the pointer's
3258
+ * travel, kept on the page.
3259
+ */
3260
+ function endAt(mode, e) {
3261
+ const { start, from } = mode;
3262
+ const css = cssIn(start.host, e);
3263
+ return start.transform.fromCss(onPage(start, from.x + css.x - start.css.x, from.y + css.y - start.css.y));
3264
+ }
3265
+ /**
3266
+ * Where the dragged corner is now: where it started plus the pointer's travel since the
3267
+ * press, kept on the page. Keeping the press's offset from the corner means a click on a
3268
+ * handle, with no drag, leaves the box as it was.
3269
+ */
3270
+ function cornerAt(mode, e) {
3271
+ const { start, box, corner } = mode;
3272
+ const css = cssIn(start.host, e);
3273
+ 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);
3274
+ }
3275
+ /** `MIN_SIZE` points as CSS px on the press's page. */
3276
+ function minCss(start) {
3277
+ return start.transform.toCssLength(1);
3278
+ }
3279
+ /**
3280
+ * `box` with its `corner` dragged to `p` and the opposite corner fixed, at least `min` a side.
3281
+ * With `aspect`, the box keeps its proportions: it scales by the larger of the two axes'
3282
+ * changes, kept on the page.
3283
+ */
3284
+ function resizeCss(box, corner, p, min, opts) {
3285
+ const west = corner[1] === "w";
3286
+ const north = corner[0] === "n";
3287
+ /** The fixed corner. */
3288
+ const fx = west ? box.left + box.width : box.left;
3289
+ const fy = north ? box.top + box.height : box.top;
3290
+ let width = Math.max(west ? fx - p.x : p.x - fx, min);
3291
+ let height = Math.max(north ? fy - p.y : p.y - fy, min);
3292
+ if (opts.aspect && box.width > 0 && box.height > 0) {
3293
+ const roomX = west ? fx : opts.page.cssWidth - fx;
3294
+ const roomY = north ? fy : opts.page.cssHeight - fy;
3295
+ const lo = min / Math.min(box.width, box.height);
3296
+ const hi = Math.min(roomX / box.width, roomY / box.height);
3297
+ const s = Math.min(Math.max(width / box.width, height / box.height, lo), Math.max(hi, lo));
3298
+ width = box.width * s;
3299
+ height = box.height * s;
3300
+ }
3301
+ return {
3302
+ left: west ? fx - width : fx,
3303
+ top: north ? fy - height : fy,
3304
+ width,
3305
+ height
3306
+ };
3307
+ }
3308
+ //#endregion
3309
+ //#region src/dom/tools/controller.ts
3310
+ /** Host-relative CSS point for a client point. */
3311
+ function cssIn(host, e) {
3312
+ const r = host.getBoundingClientRect();
3313
+ return {
3314
+ x: e.clientX - r.left,
3315
+ y: e.clientY - r.top
3316
+ };
3317
+ }
3318
+ /** Like `cssIn`, clamped to the host's box, so a drag past the page edge stops at the edge. */
3319
+ function clampedCssIn(host, e) {
3320
+ const r = host.getBoundingClientRect();
3321
+ return {
3322
+ x: Math.min(Math.max(e.clientX - r.left, 0), r.width),
3323
+ y: Math.min(Math.max(e.clientY - r.top, 0), r.height)
3324
+ };
3325
+ }
3326
+ /** The mounted page under a client point, with the point in CSS px and user space. */
3327
+ function hitFor(ctx, e) {
3328
+ const hit = ctx.viewport.pageAtCss(e.clientX, e.clientY);
3329
+ if (!hit) return void 0;
3330
+ const css = cssIn(hit.host, e);
3331
+ return {
3332
+ ...hit,
3333
+ css,
3334
+ user: hit.transform.fromCss(css)
3335
+ };
3336
+ }
3337
+ function createToolController$1(opts) {
3338
+ const byId = new Map(opts.tools.map((t) => [t.id, t]));
3339
+ const active = writable(opts.initial ?? "select-text");
3340
+ let current;
3341
+ const registry = opts.options ?? createToolOptions();
3342
+ registry.register(opts.tools);
3343
+ const ctx = {
3344
+ ...opts.ctx,
3345
+ get signatures() {
3346
+ return opts.ctx.signatures;
3347
+ },
3348
+ toolStyle: opts.ctx.toolStyle ?? createToolStyle({
3349
+ store: opts.ctx.store,
3350
+ history: opts.ctx.history ?? { batch: (fn) => fn() },
3351
+ activeGroup: () => current?.styleGroup,
3352
+ active
3353
+ }),
3354
+ options: () => registry.get(current?.id ?? active.get()),
3355
+ made(ids) {
3356
+ if (current?.kind !== "one-shot" || ids.length === 0) return;
3357
+ clearMade();
3358
+ made = [...ids];
3359
+ ctx.root.dataset.inkMade = "";
3360
+ stopMade = [ctx.store.list.subscribe(checkMade), ctx.store.selection.subscribe(checkMade)];
3361
+ },
3362
+ done() {
3363
+ clearMade();
3364
+ ctx.store.select([]);
3365
+ set("select-text");
3366
+ }
3367
+ };
3368
+ const originalCursor = ctx.root.style.cursor;
3369
+ /** The pointer between a primary press and its release or cancel. */
3370
+ let pressed;
3371
+ /** Pointers whose release belongs to a tool that is no longer active. */
3372
+ const swallow = /* @__PURE__ */ new Set();
3373
+ /** What the active one-shot tool made; undefined while it is ready. */
3374
+ let made;
3375
+ let stopMade = [];
3376
+ /** Whether the press now under way found the tool made (see the top of the file). */
3377
+ let pressFoundMade = false;
3378
+ /** Moves and resizes what the active one-shot tool made. */
3379
+ let madeEdit;
3380
+ /** The press now under way went to `madeEdit`, not the tool. */
3381
+ let editingMade = false;
3382
+ function clearMade() {
3383
+ stopMade.forEach((stop) => stop());
3384
+ stopMade = [];
3385
+ made = void 0;
3386
+ delete ctx.root.dataset.inkMade;
3387
+ }
3388
+ /** Leaves the made state when its items are gone, or switches when the selection moved on. */
3389
+ function checkMade() {
3390
+ if (!made) return;
3391
+ const store = ctx.store;
3392
+ if (!made.some((id) => (store.get(id) ?? store._getProposed(id)) !== void 0)) {
3393
+ clearMade();
3394
+ return;
3395
+ }
3396
+ const selected = store.selection.get();
3397
+ if (!made.some((id) => selected.includes(id))) set("select-text");
3398
+ }
3399
+ /** True for events from a pointer other than the one whose press is live. */
3400
+ const foreign = (e) => pressed !== void 0 && pressed !== e.pointerId;
3401
+ const readFirst = () => {
3402
+ pressFoundMade = made !== void 0;
3403
+ };
3404
+ const readAfterEditor = () => {
3405
+ pressFoundMade ||= made !== void 0;
3406
+ };
3407
+ const onDown = (e) => {
3408
+ if (!e.isPrimary || e.button !== 0 || inCustomContent(e.target)) return;
3409
+ if (foreign(e)) return;
3410
+ swallow.delete(e.pointerId);
3411
+ const annotationId = (e.target instanceof Element ? e.target : null)?.closest("[data-annotation]")?.getAttribute("data-annotation") ?? void 0;
3412
+ if (current?.kind === "one-shot") {
3413
+ if (pressFoundMade && annotationId === void 0 && !onScrollBar(e, ctx.root)) {
3414
+ e.preventDefault();
3415
+ swallow.add(e.pointerId);
3416
+ ctx.done();
3417
+ return;
3418
+ }
3419
+ const a = annotationId === void 0 ? void 0 : ctx.store.get(annotationId);
3420
+ if (a && made?.includes(a.id) && madeEdit) {
3421
+ pressed = e.pointerId;
3422
+ editingMade = true;
3423
+ if (madeEdit.onPointerDown(e, hitFor(ctx, e), a.id) === true) capture(e.pointerId);
3424
+ return;
3425
+ }
3426
+ if (a && (!isTextMarkup(a) || pressFoundMade)) set("select-text");
3427
+ }
3428
+ pressed = e.pointerId;
3429
+ if (current?.onPointerDown?.(e, hitFor(ctx, e), annotationId) === true) capture(e.pointerId);
3430
+ };
3431
+ const onMove = (e) => {
3432
+ if (!e.isPrimary || foreign(e)) return;
3433
+ const missed = pressed === e.pointerId && e.buttons === 0;
3434
+ if (missed) pressed = void 0;
3435
+ if (editingMade) {
3436
+ madeEdit?.onPointerMove(e, hitFor(ctx, e));
3437
+ if (missed) editingMade = false;
3438
+ return;
3439
+ }
3440
+ current?.onPointerMove?.(e, hitFor(ctx, e));
3441
+ };
3442
+ const onUp = (e) => {
3443
+ if (!e.isPrimary || e.button !== 0 || foreign(e)) return;
3444
+ if (pressed === e.pointerId) pressed = void 0;
3445
+ if (swallow.delete(e.pointerId)) {
3446
+ release(e.pointerId);
3447
+ return;
3448
+ }
3449
+ if (editingMade) {
3450
+ editingMade = false;
3451
+ madeEdit?.onPointerUp(e, hitFor(ctx, e));
3452
+ } else current?.onPointerUp?.(e, hitFor(ctx, e));
3453
+ release(e.pointerId);
3454
+ };
3455
+ const onCancel = (e) => {
3456
+ if (!e.isPrimary || foreign(e)) return;
3457
+ if (e.type === "lostpointercapture" && pressed !== e.pointerId) return;
3458
+ if (pressed === e.pointerId) pressed = void 0;
3459
+ swallow.delete(e.pointerId);
3460
+ if (editingMade) {
3461
+ editingMade = false;
3462
+ madeEdit?.onPointerCancel();
3463
+ } else current?.onPointerCancel?.(e);
3464
+ };
3465
+ const onKey = (e) => {
3466
+ if (inCustomContent(e.target)) return;
3467
+ if (made && e.key === "Escape" && !e.defaultPrevented) {
3468
+ e.preventDefault();
3469
+ ctx.done();
3470
+ return;
3471
+ }
3472
+ if (made && (e.key === "Delete" || e.key === "Backspace")) {
3473
+ madeEdit?.onKeyDown(e);
3474
+ return;
3475
+ }
3476
+ current?.onKeyDown?.(e);
3477
+ if (isViewportEnter(e, ctx.root)) editSelectedText(e);
3478
+ };
3479
+ /**
3480
+ * Escape in an editor on a made text box, however it was opened (a press inside, Enter, a
3481
+ * double-click): commits and ends the tool. Captured, as the editor keeps its keys to itself.
3482
+ */
3483
+ const onEditorEscape = (e) => {
3484
+ if (!made || e.key !== "Escape" || e.isComposing || e.defaultPrevented) return;
3485
+ const target = e.target instanceof Element ? e.target : null;
3486
+ if (!target?.closest("[data-ink-editor]")) return;
3487
+ const el = target.closest("[data-annotation]");
3488
+ const id = el?.getAttribute("data-annotation");
3489
+ if (!el || !id || !made.includes(id)) return;
3490
+ e.preventDefault();
3491
+ e.stopPropagation();
3492
+ editorFor(el)?.close(true);
3493
+ ctx.done();
3494
+ };
3495
+ function editSelectedText(e) {
3496
+ const selected = ctx.store.selection.get();
3497
+ if (selected.length !== 1) return;
3498
+ const id = selected[0];
3499
+ const a = ctx.store.get(id);
3500
+ const el = ctx.root.querySelector(`[data-annotation="${CSS.escape(id)}"]`);
3501
+ if (!canEditFreeText(a, el) || !el) return;
3502
+ e.preventDefault();
3503
+ editFreeTextIn(ctx.store, el, id, ctx.viewport.transformFor(a.page));
3504
+ }
3505
+ function capture(id) {
3506
+ try {
3507
+ ctx.root.setPointerCapture?.(id);
3508
+ } catch {}
3509
+ }
3510
+ function release(id) {
3511
+ try {
3512
+ if (ctx.root.hasPointerCapture?.(id)) ctx.root.releasePointerCapture(id);
3513
+ } catch {}
3514
+ }
3515
+ const doc = ctx.root.ownerDocument;
3516
+ doc.defaultView?.addEventListener("pointerdown", readFirst, { capture: true });
3517
+ doc.addEventListener("pointerdown", readAfterEditor, { capture: true });
3518
+ ctx.root.addEventListener("pointerdown", onDown, { capture: true });
3519
+ ctx.root.addEventListener("pointermove", onMove);
3520
+ ctx.root.addEventListener("pointerup", onUp);
3521
+ ctx.root.addEventListener("pointercancel", onCancel);
3522
+ ctx.root.addEventListener("lostpointercapture", onCancel);
3523
+ ctx.root.addEventListener("keydown", onKey);
3524
+ ctx.root.addEventListener("keydown", onEditorEscape, { capture: true });
3525
+ function set(id, options) {
2784
3526
  const next = byId.get(id);
2785
3527
  if (!next) throw new RangeError(`Unknown tool ${id}`);
2786
3528
  if (options) registry.merge(id, options);
2787
3529
  if (pressed !== void 0) {
2788
- current?.onPointerCancel?.(new PointerEvent("pointercancel", {
3530
+ if (editingMade) madeEdit?.onPointerCancel();
3531
+ else current?.onPointerCancel?.(new PointerEvent("pointercancel", {
2789
3532
  pointerId: pressed,
2790
3533
  isPrimary: true
2791
3534
  }));
2792
3535
  swallow.add(pressed);
2793
3536
  pressed = void 0;
2794
3537
  }
3538
+ editingMade = false;
3539
+ madeEdit?.reset();
2795
3540
  current?.deactivate();
3541
+ clearMade();
2796
3542
  current = next;
3543
+ madeEdit = next.kind === "one-shot" ? createSelectionEdit(ctx) : void 0;
2797
3544
  next.activate(ctx);
2798
3545
  ctx.root.dataset.inkTool = id;
2799
3546
  ctx.root.style.cursor = next.cursor;
@@ -2812,14 +3559,20 @@ function createToolController$1(opts) {
2812
3559
  return registry.readable(id);
2813
3560
  },
2814
3561
  destroy() {
3562
+ madeEdit?.reset();
3563
+ madeEdit = void 0;
2815
3564
  current?.deactivate();
3565
+ clearMade();
2816
3566
  current = void 0;
3567
+ doc.defaultView?.removeEventListener("pointerdown", readFirst, { capture: true });
3568
+ doc.removeEventListener("pointerdown", readAfterEditor, { capture: true });
2817
3569
  ctx.root.removeEventListener("pointerdown", onDown, { capture: true });
2818
3570
  ctx.root.removeEventListener("pointermove", onMove);
2819
3571
  ctx.root.removeEventListener("pointerup", onUp);
2820
3572
  ctx.root.removeEventListener("pointercancel", onCancel);
2821
3573
  ctx.root.removeEventListener("lostpointercapture", onCancel);
2822
3574
  ctx.root.removeEventListener("keydown", onKey);
3575
+ ctx.root.removeEventListener("keydown", onEditorEscape, { capture: true });
2823
3576
  delete ctx.root.dataset.inkTool;
2824
3577
  ctx.root.style.cursor = originalCursor;
2825
3578
  }
@@ -2841,6 +3594,17 @@ function isViewportEnter(e, root) {
2841
3594
  return target.closest("[data-ink-text-layer], [data-ink-canvas-layer]") !== null;
2842
3595
  }
2843
3596
  /**
3597
+ * True for a press on `root` itself outside its client area: on a scroll bar (or its border).
3598
+ * Client coordinates only, so a synthetic press gives the same answer as a real one.
3599
+ */
3600
+ function onScrollBar(e, root) {
3601
+ if (e.target !== root) return false;
3602
+ const r = root.getBoundingClientRect();
3603
+ const x = e.clientX - r.left - root.clientLeft;
3604
+ const y = e.clientY - r.top - root.clientTop;
3605
+ return x < 0 || y < 0 || x >= root.clientWidth || y >= root.clientHeight;
3606
+ }
3607
+ /**
2844
3608
  * True for a target inside a custom annotation element (`data-custom`) but not that element
2845
3609
  * itself: the host's buttons and inputs there own their presses and keys, as in the overlay.
2846
3610
  */
@@ -2917,6 +3681,7 @@ function freehandTool(opts) {
2917
3681
  };
2918
3682
  return {
2919
3683
  id: opts.id,
3684
+ kind: "sticky",
2920
3685
  cursor: "crosshair",
2921
3686
  styleGroup: "ink",
2922
3687
  activate(c) {
@@ -3123,7 +3888,7 @@ function freetextTool() {
3123
3888
  };
3124
3889
  const editor = editFreeText(el, draftBox, {
3125
3890
  transform: t,
3126
- commit(contents, rect) {
3891
+ commit(contents, rect, _grown, how) {
3127
3892
  done();
3128
3893
  if (contents.trim() === "") return refocus();
3129
3894
  const created = c.store.add({
@@ -3133,7 +3898,9 @@ function freetextTool() {
3133
3898
  rect
3134
3899
  });
3135
3900
  c.store.select([created.id]);
3901
+ c.made([created.id]);
3136
3902
  refocus(created.id);
3903
+ if (how === "escape") c.done();
3137
3904
  },
3138
3905
  cancel() {
3139
3906
  done();
@@ -3159,6 +3926,7 @@ function freetextTool() {
3159
3926
  };
3160
3927
  return {
3161
3928
  id: "freetext",
3929
+ kind: "one-shot",
3162
3930
  cursor: "text",
3163
3931
  styleGroup: "text",
3164
3932
  defaultOptions: {},
@@ -3263,6 +4031,7 @@ function highlightTool() {
3263
4031
  };
3264
4032
  return {
3265
4033
  id: "highlight",
4034
+ kind: "sticky",
3266
4035
  cursor: "text",
3267
4036
  styleGroup: "markup",
3268
4037
  defaultOptions: { ...DEFAULT_HIGHLIGHT_OPTIONS },
@@ -3306,10 +4075,12 @@ function noteTool() {
3306
4075
  }
3307
4076
  });
3308
4077
  c.store.select([a.id]);
4078
+ c.made([a.id]);
3309
4079
  return a.id;
3310
4080
  };
3311
4081
  return {
3312
4082
  id: "note",
4083
+ kind: "one-shot",
3313
4084
  cursor: "copy",
3314
4085
  activate(c) {
3315
4086
  ctx = c;
@@ -3363,487 +4134,122 @@ function focusWhenDrawn(ctx, id) {
3363
4134
  let frames = 0;
3364
4135
  const view = doc.defaultView;
3365
4136
  const tick = () => {
3366
- if (doc.activeElement !== ctx.root) return;
3367
- const el = find();
3368
- if (el) el.focus({ preventScroll: true });
3369
- else if (++frames < FOCUS_FRAMES) view?.requestAnimationFrame(tick);
3370
- };
3371
- view?.requestAnimationFrame(tick);
3372
- }
3373
- //#endregion
3374
- //#region src/dom/tools/redact-area.ts
3375
- /** Smallest drag, in CSS px on each axis, that draws a box rather than counting as a click. */
3376
- const MIN_CSS$1 = 3;
3377
- function redactAreaTool() {
3378
- let ctx;
3379
- let start;
3380
- let draft;
3381
- /** Text redactions from the live selection, one per page. False when nothing is selected. */
3382
- const fromSelection = () => {
3383
- if (!ctx) return false;
3384
- const selections = ctx.selectionQuads();
3385
- if (selections.length === 0) return false;
3386
- for (const s of selections) ctx.store.add(ctx.toolStyle.resolve("redact", {
3387
- type: "redact",
3388
- page: s.page,
3389
- quads: s.quads
3390
- }));
3391
- ctx.clearSelection();
3392
- return true;
3393
- };
3394
- const clear = () => {
3395
- draft?.remove();
3396
- draft = void 0;
3397
- start = void 0;
3398
- };
3399
- return {
3400
- id: "redact-area",
3401
- cursor: "crosshair",
3402
- styleGroup: "redact",
3403
- activate(c) {
3404
- ctx = c;
3405
- },
3406
- deactivate() {
3407
- clear();
3408
- ctx = void 0;
3409
- },
3410
- onPointerDown(e, hit, annotationId) {
3411
- if (!hit) return;
3412
- if (annotationId !== void 0) {
3413
- const a = ctx?.store.get(annotationId);
3414
- if (!a || !isTextMarkup(a)) return;
3415
- e.stopPropagation();
3416
- }
3417
- start = hit;
3418
- e.preventDefault();
3419
- return true;
3420
- },
3421
- onPointerMove(e) {
3422
- if (!start) return;
3423
- if (e.buttons === 0 || !start.host.isConnected) {
3424
- clear();
3425
- return;
3426
- }
3427
- const css = clampedCssIn(start.host, e);
3428
- if (!draft) {
3429
- draft = start.host.ownerDocument.createElement("div");
3430
- 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;
4137
+ if (doc.activeElement !== ctx.root) return;
4138
+ const el = find();
4139
+ if (el) el.focus({ preventScroll: true });
4140
+ else if (++frames < FOCUS_FRAMES) view?.requestAnimationFrame(tick);
3511
4141
  };
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);
4142
+ view?.requestAnimationFrame(tick);
4143
+ }
4144
+ //#endregion
4145
+ //#region src/dom/tools/redact-area.ts
4146
+ /** Smallest drag, in CSS px on each axis, that draws a box rather than counting as a click. */
4147
+ const MIN_CSS$1 = 3;
4148
+ function redactAreaTool() {
4149
+ let ctx;
4150
+ let start;
4151
+ let draft;
4152
+ /** Selects what the tool just added, together, and reports it made. */
4153
+ const made = (c, ids) => {
4154
+ c.store.select(ids);
4155
+ c.made(ids);
3517
4156
  };
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();
4157
+ /** Text redactions from the live selection, one per page. False when nothing is selected. */
4158
+ const fromSelection = () => {
4159
+ if (!ctx) return false;
4160
+ const selections = ctx.selectionQuads();
4161
+ if (selections.length === 0) return false;
4162
+ const c = ctx;
4163
+ const ids = selections.map((s) => c.store.add(c.toolStyle.resolve("redact", {
4164
+ type: "redact",
4165
+ page: s.page,
4166
+ quads: s.quads
4167
+ })).id);
4168
+ c.clearSelection();
4169
+ made(c, ids);
3546
4170
  return true;
3547
- }
4171
+ };
4172
+ const clear = () => {
4173
+ draft?.remove();
4174
+ draft = void 0;
4175
+ start = void 0;
4176
+ };
3548
4177
  return {
3549
- reset,
4178
+ id: "redact-area",
4179
+ kind: "one-shot",
4180
+ cursor: "crosshair",
4181
+ styleGroup: "redact",
4182
+ activate(c) {
4183
+ ctx = c;
4184
+ },
4185
+ deactivate() {
4186
+ clear();
4187
+ ctx = void 0;
4188
+ },
3550
4189
  onPointerDown(e, hit, annotationId) {
3551
4190
  if (!hit) return;
3552
- reset();
3553
4191
  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;
4192
+ const a = ctx?.store.get(annotationId);
4193
+ if (!a || !isTextMarkup(a)) return;
4194
+ e.stopPropagation();
3581
4195
  }
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([]);
4196
+ start = hit;
3595
4197
  e.preventDefault();
3596
4198
  return true;
3597
4199
  },
3598
4200
  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);
4201
+ if (!start) return;
4202
+ if (e.buttons === 0 || !start.host.isConnected) {
4203
+ clear();
3634
4204
  return;
3635
4205
  }
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;
4206
+ const css = clampedCssIn(start.host, e);
4207
+ if (!draft) {
4208
+ draft = start.host.ownerDocument.createElement("div");
4209
+ draft.className = "ink-draft";
4210
+ draft.dataset.inkDraft = "redact";
4211
+ draft.style.position = "absolute";
4212
+ draft.style.pointerEvents = "none";
4213
+ start.host.append(draft);
3662
4214
  }
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`;
4215
+ draft.style.left = `${Math.min(start.css.x, css.x)}px`;
4216
+ draft.style.top = `${Math.min(start.css.y, css.y)}px`;
4217
+ draft.style.width = `${Math.abs(css.x - start.css.x)}px`;
4218
+ draft.style.height = `${Math.abs(css.y - start.css.y)}px`;
3667
4219
  },
3668
4220
  onPointerCancel() {
3669
- reset();
4221
+ clear();
3670
4222
  },
3671
4223
  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
- }
4224
+ if (!ctx || !start) return;
4225
+ if (!start.host.isConnected) {
4226
+ clear();
4227
+ return;
3730
4228
  }
4229
+ const css = clampedCssIn(start.host, e);
4230
+ const width = Math.abs(css.x - start.css.x);
4231
+ const height = Math.abs(css.y - start.css.y);
4232
+ if (width >= MIN_CSS$1 && height >= MIN_CSS$1) {
4233
+ const rect = start.transform.fromCssRect({
4234
+ left: Math.min(start.css.x, css.x),
4235
+ top: Math.min(start.css.y, css.y),
4236
+ width,
4237
+ height
4238
+ });
4239
+ const a = ctx.store.add(ctx.toolStyle.resolve("redact", {
4240
+ type: "redact",
4241
+ page: start.page,
4242
+ rect
4243
+ }));
4244
+ made(ctx, [a.id]);
4245
+ } else fromSelection();
4246
+ clear();
3731
4247
  },
3732
4248
  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);
4249
+ if (!!ctx && isViewportEnter(e, ctx.root) && fromSelection()) e.preventDefault();
3742
4250
  }
3743
4251
  };
3744
4252
  }
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
4253
  //#endregion
3848
4254
  //#region src/dom/tools/select.ts
3849
4255
  /**
@@ -3921,6 +4327,15 @@ function shapeTool() {
3921
4327
  };
3922
4328
  /** The input with the shape group's style and, for a bubble, the text group's text style. */
3923
4329
  const styled = (c, input) => {
4330
+ if (input.type === "line") {
4331
+ const line = c.toolStyle.resolve("shape", input);
4332
+ const arrow = input.intent === "LineArrow";
4333
+ return {
4334
+ ...line,
4335
+ head: line.head ?? "None",
4336
+ tail: line.tail ?? (arrow ? "OpenArrow" : "None")
4337
+ };
4338
+ }
3924
4339
  if (input.type !== "freetext") return c.toolStyle.resolve("shape", input);
3925
4340
  const shape = c.toolStyle.resolve("shape", {
3926
4341
  ...input,
@@ -3955,7 +4370,9 @@ function shapeTool() {
3955
4370
  let waiting;
3956
4371
  const openEditor = (c, a) => {
3957
4372
  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;
4373
+ return el !== null && editFreeTextIn(c.store, el, a.id, c.viewport.transformFor(a.page), { onClose: (how) => {
4374
+ if (how === "escape" && ctx === c) c.done();
4375
+ } }) !== void 0;
3959
4376
  };
3960
4377
  /**
3961
4378
  * Opens the in-place editor on a new bubble. When a controlled host has not applied it yet,
@@ -3977,6 +4394,7 @@ function shapeTool() {
3977
4394
  if (!input) return;
3978
4395
  const created = c.store.add(styled(c, input));
3979
4396
  c.store.select([created.id]);
4397
+ c.made([created.id]);
3980
4398
  if (isBubble(created)) edit(c, created);
3981
4399
  };
3982
4400
  const paint = (c, from, e) => {
@@ -4045,6 +4463,7 @@ function shapeTool() {
4045
4463
  };
4046
4464
  return {
4047
4465
  id: "shape",
4466
+ kind: "one-shot",
4048
4467
  cursor: "crosshair",
4049
4468
  styleGroup: "shape",
4050
4469
  defaultOptions: { ...DEFAULT_SHAPE_OPTIONS },
@@ -4114,6 +4533,7 @@ function signatureTool() {
4114
4533
  const input = placeSignature(t, page, at, 150, rotation);
4115
4534
  const a = c.store.add(input.type === "ink" ? c.toolStyle.resolve("ink", input) : input);
4116
4535
  c.store.select([a.id]);
4536
+ c.made([a.id]);
4117
4537
  };
4118
4538
  /**
4119
4539
  * Places the selected template, from the library at once, or after reloading it when the
@@ -4137,6 +4557,7 @@ function signatureTool() {
4137
4557
  };
4138
4558
  return {
4139
4559
  id: "signature",
4560
+ kind: "one-shot",
4140
4561
  cursor: "copy",
4141
4562
  defaultOptions: {},
4142
4563
  activate(c) {
@@ -4647,9 +5068,11 @@ const COLOR_LABELS = {
4647
5068
  const COLOR_COLUMNS = 12;
4648
5069
  /**
4649
5070
  * 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.
5071
+ * for `fill`, and the trigger's swatch. Shared by the bindings and the vanilla menu. `fallback`
5072
+ * is the colour new annotations get while the style leaves it unset: the trigger shows it
5073
+ * rather than an empty swatch.
4651
5074
  */
4652
- function colorMenuModel(property, current, supported) {
5075
+ function colorMenuModel(property, current, supported, fallback) {
4653
5076
  const value = current[property];
4654
5077
  const enabled = supported.has(property);
4655
5078
  const color = typeof value === "string" && value !== "mixed" ? value.toUpperCase() : void 0;
@@ -4662,6 +5085,9 @@ function colorMenuModel(property, current, supported) {
4662
5085
  const trigger = !enabled ? { kind: "empty" } : value === "mixed" ? { kind: "mixed" } : value === null ? { kind: "none" } : typeof value === "string" ? {
4663
5086
  kind: "color",
4664
5087
  color: value
5088
+ } : fallback !== void 0 ? {
5089
+ kind: "color",
5090
+ color: fallback
4665
5091
  } : { kind: "empty" };
4666
5092
  return {
4667
5093
  label: COLOR_LABELS[property],
@@ -4679,7 +5105,7 @@ function colorMenuModel(property, current, supported) {
4679
5105
  checked: value === null,
4680
5106
  patch: { fill: null }
4681
5107
  } : null,
4682
- customValue: color ?? "#000000",
5108
+ customValue: color ?? fallback?.toUpperCase() ?? "#000000",
4683
5109
  custom: (hex) => patchFor(hex.toUpperCase())
4684
5110
  };
4685
5111
  }
@@ -5031,35 +5457,190 @@ function createFocusReturn(opts) {
5031
5457
  /** Set by `beforeRemove`: the close comes from removing the anchor's annotation. */
5032
5458
  let removing = false;
5033
5459
  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
- }
5460
+ beforeRemove(anchor, prefer) {
5461
+ fallbacks = [];
5462
+ removing = true;
5463
+ const siblings = anchor?.parentElement ? [...anchor.parentElement.children].filter((el) => el instanceof HTMLElement && el.hasAttribute("data-annotation")) : [];
5464
+ const i = anchor ? siblings.indexOf(anchor) : -1;
5465
+ if (i >= 0) {
5466
+ const after = siblings.slice(i + 1);
5467
+ const preferred = prefer ? after.filter((el) => el.matches(prefer)) : [];
5468
+ fallbacks.push(...preferred, ...after, ...siblings.slice(0, i).reverse());
5469
+ }
5470
+ },
5471
+ onCloseAutoFocus(event, anchor) {
5472
+ event.preventDefault();
5473
+ const plan = fallbacks;
5474
+ const removed = removing;
5475
+ fallbacks = [];
5476
+ removing = false;
5477
+ const root = opts.root();
5478
+ if (!root) return;
5479
+ const activated = opts.activated();
5480
+ if (activated !== null && annotationElementFor(root, activated)) return;
5481
+ const active = root.ownerDocument.activeElement;
5482
+ if (active && active !== root && root.contains(active)) return;
5483
+ const scroll = root.querySelector("[data-ink-scroll]");
5484
+ (removed ? [...plan, scroll] : [
5485
+ anchor,
5486
+ ...plan,
5487
+ scroll
5488
+ ]).find((el) => el?.isConnected)?.focus({ preventScroll: true });
5489
+ }
5490
+ };
5491
+ }
5492
+ //#endregion
5493
+ //#region src/dom/viewer/dom.ts
5494
+ /** `document.createElement` with attributes and children. */
5495
+ function h(tag, attrs = {}, ...children) {
5496
+ const el = document.createElement(tag);
5497
+ for (const [name, value] of Object.entries(attrs)) el.setAttribute(name, value);
5498
+ el.append(...children);
5499
+ return el;
5500
+ }
5501
+ function button$1(label, onClick) {
5502
+ const b = h("button", { type: "button" }, label);
5503
+ b.addEventListener("click", onClick);
5504
+ return b;
5505
+ }
5506
+ //#endregion
5507
+ //#region src/dom/viewer/popover.ts
5508
+ /** Space between the anchor and the panel, and between the panel and the window edge. */
5509
+ const GAP = 6;
5510
+ const MARGIN = 8;
5511
+ const TABBABLE = "button, input, select, textarea, a[href], [tabindex]";
5512
+ /**
5513
+ * Positions `panel` (fixed) below the `anchor` rectangle, or above it when only that fits,
5514
+ * kept inside the window. For panels anchored to something other than an annotation.
5515
+ */
5516
+ function placePanel(panel, anchor) {
5517
+ const { innerWidth: vw, innerHeight: vh } = window;
5518
+ panel.style.position = "fixed";
5519
+ panel.style.left = "0px";
5520
+ panel.style.top = "0px";
5521
+ const w = panel.offsetWidth;
5522
+ const ht = panel.offsetHeight;
5523
+ let top = anchor.bottom + GAP;
5524
+ if (top + ht > vh - MARGIN && anchor.top - GAP - ht >= MARGIN) top = anchor.top - GAP - ht;
5525
+ top = Math.max(MARGIN, Math.min(top, vh - ht - MARGIN));
5526
+ const left = Math.max(MARGIN, Math.min(anchor.left, vw - w - MARGIN));
5527
+ panel.style.left = `${left}px`;
5528
+ panel.style.top = `${top}px`;
5529
+ }
5530
+ /**
5531
+ * Creates a non-modal `role="dialog"` panel on `<body>`, marked `data-ink-popover` so the theme
5532
+ * gives it its tokens, that an app anchors to an annotation with `show(id)` (for instance on
5533
+ * `viewer.activated`). The panel follows the annotation's element as pages mount and unmount:
5534
+ * it hides while the element is gone and comes back with it. Tab loops inside the panel, and
5535
+ * Escape or a press outside calls `onDismiss`. Call `destroy` when done.
5536
+ */
5537
+ function createPopover(opts) {
5538
+ const { root, focusReturn } = opts;
5539
+ const panel = h("div", {
5540
+ ...opts.attrs,
5541
+ role: "dialog",
5542
+ "data-ink-popover": "",
5543
+ hidden: ""
5544
+ });
5545
+ document.body.append(panel);
5546
+ let anchoredId = null;
5547
+ let anchor = null;
5548
+ let stopObserving = () => void 0;
5549
+ /** The element that had focus in the panel when the anchor went away. */
5550
+ let stranded = null;
5551
+ const place = () => {
5552
+ if (!anchor || panel.hidden) return;
5553
+ placePanel(panel, anchor.getBoundingClientRect());
5554
+ };
5555
+ const resizes = new ResizeObserver(place);
5556
+ resizes.observe(panel);
5557
+ const onAnchor = (el, closing = false) => {
5558
+ if (anchor) resizes.unobserve(anchor);
5559
+ anchor = el;
5560
+ if (!el) {
5561
+ const active = document.activeElement;
5562
+ if (!closing && !panel.hidden && active instanceof HTMLElement && panel.contains(active)) {
5563
+ const scroll = root.querySelector("[data-ink-scroll]");
5564
+ if (scroll) {
5565
+ stranded = active;
5566
+ scroll.focus({ preventScroll: true });
5567
+ }
5568
+ }
5569
+ panel.hidden = true;
5570
+ return;
5571
+ }
5572
+ resizes.observe(el);
5573
+ const wasHidden = panel.hidden;
5574
+ panel.hidden = false;
5575
+ place();
5576
+ if (wasHidden && stranded) {
5577
+ const back = stranded;
5578
+ stranded = null;
5579
+ const scroll = root.querySelector("[data-ink-scroll]");
5580
+ if (panel.contains(back) && document.activeElement === scroll) back.focus({ preventScroll: true });
5581
+ }
5582
+ };
5583
+ const onKey = (e) => {
5584
+ if (e.key !== "Escape" || panel.hidden || e.defaultPrevented) return;
5585
+ const target = e.target;
5586
+ if (!(target instanceof Node) || !(panel.contains(target) || root.contains(target))) return;
5587
+ e.preventDefault();
5588
+ opts.onDismiss();
5589
+ };
5590
+ const onPress = (e) => {
5591
+ if (panel.hidden || panel.contains(e.target)) return;
5592
+ if (shouldKeepOpenOnAnnotation(e.target)) return;
5593
+ opts.onDismiss();
5594
+ };
5595
+ const onTab = (e) => {
5596
+ if (e.key !== "Tab" || e.altKey || e.ctrlKey || e.metaKey) return;
5597
+ const tabbable = [...panel.querySelectorAll(TABBABLE)].filter((el) => !el.disabled && el.tabIndex >= 0);
5598
+ const first = tabbable[0];
5599
+ const last = tabbable.at(-1);
5600
+ if (!first || !last) return;
5601
+ const active = document.activeElement;
5602
+ if (e.shiftKey ? active !== first : active !== last) return;
5603
+ e.preventDefault();
5604
+ (e.shiftKey ? last : first).focus();
5605
+ };
5606
+ panel.addEventListener("keydown", onTab);
5607
+ document.addEventListener("keydown", onKey);
5608
+ document.addEventListener("pointerdown", onPress, true);
5609
+ document.addEventListener("scroll", place, true);
5610
+ window.addEventListener("resize", place);
5611
+ return {
5612
+ panel,
5613
+ get anchor() {
5614
+ return anchor;
5044
5615
  },
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 });
5616
+ show(id) {
5617
+ if (id === anchoredId) return;
5618
+ stopObserving();
5619
+ anchoredId = id;
5620
+ stopObserving = observeAnnotationElement(root, id, onAnchor);
5621
+ },
5622
+ hide() {
5623
+ if (anchoredId === null) return;
5624
+ stopObserving();
5625
+ stopObserving = () => void 0;
5626
+ anchoredId = null;
5627
+ const back = anchor;
5628
+ stranded = null;
5629
+ onAnchor(null, true);
5630
+ queueMicrotask(() => {
5631
+ if (anchoredId !== null || !panel.isConnected) return;
5632
+ focusReturn.onCloseAutoFocus(new Event("closeautofocus", { cancelable: true }), back);
5633
+ });
5634
+ },
5635
+ destroy() {
5636
+ stopObserving();
5637
+ resizes.disconnect();
5638
+ document.removeEventListener("keydown", onKey);
5639
+ document.removeEventListener("pointerdown", onPress, true);
5640
+ panel.removeEventListener("keydown", onTab);
5641
+ document.removeEventListener("scroll", place, true);
5642
+ window.removeEventListener("resize", place);
5643
+ panel.remove();
5063
5644
  }
5064
5645
  };
5065
5646
  }
@@ -5105,6 +5686,7 @@ function mountViewer(opts) {
5105
5686
  store: doc.annotations,
5106
5687
  onActivate: (id) => opts.onActivate?.(id),
5107
5688
  isCustom: () => isCustom,
5689
+ decorate: (a) => opts.decorateAnnotation?.(a),
5108
5690
  onError: (cause, layer) => {
5109
5691
  failed.add(layer);
5110
5692
  if (error) return;
@@ -5281,6 +5863,10 @@ function mountViewer(opts) {
5281
5863
  if (next.customAnnotations !== void 0) custom = next.customAnnotations;
5282
5864
  build(state);
5283
5865
  },
5866
+ redecorate() {
5867
+ if (destroyed) return;
5868
+ for (const mount of pages.values()) mount.overlay.redecorate();
5869
+ },
5284
5870
  destroy() {
5285
5871
  if (destroyed) return;
5286
5872
  destroyed = true;
@@ -5289,224 +5875,6 @@ function mountViewer(opts) {
5289
5875
  };
5290
5876
  }
5291
5877
  //#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
5878
  //#region src/dom/viewer/thread-popover.ts
5511
5879
  function createThreadPopover(opts) {
5512
5880
  const { root, store, activated, focusReturn } = opts;
@@ -5676,7 +6044,7 @@ function createThreadForm(n, actions) {
5676
6044
  //#endregion
5677
6045
  //#region src/dom/viewer/create-viewer.ts
5678
6046
  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;
6047
+ const { source: initial, assets, password, defaults, history, threads: withThreads = true, onReady, onError, onActivate, onBuild, onPageMount, onPageUnmount, onPageError, toolStyle: _toolStyle, toolOptions: _toolOptions, signatureLibrary: _signatureLibrary, signatures: signatureStore, ...view } = opts;
5680
6048
  /** One library for the viewer's life, so templates and the menu outlive each document. */
5681
6049
  const signatures = createSignatureLibrary(signatureStore ?? createMemorySignatureStore());
5682
6050
  /** Tool options persist for the viewer's life; the tool style is per document. */
@@ -5686,7 +6054,7 @@ function createViewer(el, opts) {
5686
6054
  const engine = writable(null);
5687
6055
  const viewport = writable(null);
5688
6056
  const tools = writable(null);
5689
- /** The activated annotation: the note thread and the suggestion card open from it. */
6057
+ /** The activated annotation: the note thread opens from it. */
5690
6058
  const activated = writable(null);
5691
6059
  const root = h("div", {
5692
6060
  "data-ink-root": "",
@@ -5734,20 +6102,15 @@ function createViewer(el, opts) {
5734
6102
  renderStatus(s);
5735
6103
  status.set(s);
5736
6104
  }
5737
- /** Mounts the engine and the popovers for a ready document; returns their teardown. */
6105
+ /** Mounts the engine and the thread popover for a ready document; returns their teardown. */
5738
6106
  function start(doc) {
5739
6107
  const store = doc.annotations;
5740
- const popovers = [withThreads ? createThreadPopover({
5741
- root,
5742
- store,
5743
- activated,
5744
- focusReturn
5745
- }) : null, withSuggestions ? createSuggestionCard({
6108
+ const threads = withThreads ? createThreadPopover({
5746
6109
  root,
5747
6110
  store,
5748
6111
  activated,
5749
6112
  focusReturn
5750
- }) : null];
6113
+ }) : null;
5751
6114
  const stopList = store.list.subscribe(() => {
5752
6115
  const id = activated.get();
5753
6116
  if (id !== null && !store.get(id)) activated.set(null);
@@ -5800,7 +6163,7 @@ function createViewer(el, opts) {
5800
6163
  engine.set(mounted);
5801
6164
  return () => {
5802
6165
  stopList();
5803
- for (const p of popovers) p?.destroy();
6166
+ threads?.destroy();
5804
6167
  activated.set(null);
5805
6168
  engine.set(null);
5806
6169
  mounted.destroy();
@@ -5863,6 +6226,10 @@ function createViewer(el, opts) {
5863
6226
  engine: readonly(engine),
5864
6227
  viewport: readonly(viewport),
5865
6228
  tools: readonly(tools),
6229
+ activated: readonly(activated),
6230
+ setActivated(id) {
6231
+ if (id === null || current.get()?.annotations.get(id)) activated.set(id);
6232
+ },
5866
6233
  open,
5867
6234
  destroy() {
5868
6235
  if (destroyed) return;
@@ -6188,7 +6555,7 @@ function createMenu(trigger, opts) {
6188
6555
  let panel = null;
6189
6556
  let teardown = null;
6190
6557
  const place = () => {
6191
- if (panel) placePanel(panel, trigger.getBoundingClientRect());
6558
+ if (panel) placePanel(panel, (opts.anchor ?? trigger).getBoundingClientRect());
6192
6559
  };
6193
6560
  function close(restoreFocus = false) {
6194
6561
  if (!panel) return;
@@ -6404,7 +6771,7 @@ function createColorMenu(viewer, opts) {
6404
6771
  /** What the menu reads and writes: the whole tool style, or one group's view of it. */
6405
6772
  let style = null;
6406
6773
  let stops = [];
6407
- const model = () => colorMenuModel(property, style?.current.get() ?? {}, style?.supported.get() ?? /* @__PURE__ */ new Set());
6774
+ const model = () => colorMenuModel(property, style?.current.get() ?? {}, style?.supported.get() ?? /* @__PURE__ */ new Set(), opts.fallback?.get());
6408
6775
  function refresh() {
6409
6776
  const m = model();
6410
6777
  trigger.disabled = !m.enabled;
@@ -6518,6 +6885,7 @@ function createColorMenu(viewer, opts) {
6518
6885
  };
6519
6886
  follow(viewer.engine.get());
6520
6887
  viewer.engine.subscribe(follow);
6888
+ opts.fallback?.subscribe(refresh);
6521
6889
  return trigger;
6522
6890
  }
6523
6891
  //#endregion
@@ -6801,6 +7169,7 @@ function createShapeMenu(viewer) {
6801
7169
  }
6802
7170
  const menu = createMenu(trigger, {
6803
7171
  label: "Shapes",
7172
+ anchor: root,
6804
7173
  attrs: { "data-ink-menu": "shape" },
6805
7174
  render: (panel) => paint(panel, model())
6806
7175
  });
@@ -6859,10 +7228,13 @@ function createHighlightMenu(viewer) {
6859
7228
  }, main, trigger);
6860
7229
  main.disabled = true;
6861
7230
  trigger.disabled = true;
7231
+ /** The colour the chosen kind gets while the markup style leaves it unset. */
7232
+ const defaultColour = writable(void 0);
6862
7233
  const colour = createColorMenu(viewer, {
6863
7234
  property: "border",
6864
7235
  group: "markup",
6865
- label: COLOUR_LABEL
7236
+ label: COLOUR_LABEL,
7237
+ fallback: defaultColour
6866
7238
  });
6867
7239
  colour.setAttribute("role", "menuitem");
6868
7240
  colour.tabIndex = -1;
@@ -6883,6 +7255,7 @@ function createHighlightMenu(viewer) {
6883
7255
  const on = tools?.active.get() === "highlight";
6884
7256
  main.dataset.state = on ? "on" : "off";
6885
7257
  main.setAttribute("aria-pressed", String(on));
7258
+ defaultColour.set(viewer.document.get()?.annotations.defaults.color(m.kind));
6886
7259
  menu.refresh();
6887
7260
  }
6888
7261
  function paint(panel, m) {
@@ -6929,6 +7302,7 @@ function createHighlightMenu(viewer) {
6929
7302
  }
6930
7303
  const menu = createMenu(trigger, {
6931
7304
  label: "Highlights",
7305
+ anchor: root,
6932
7306
  attrs: { "data-ink-menu": "highlight" },
6933
7307
  owns: (t) => t instanceof Element && t.closest("[data-ink-menu=\"color-border\"]") !== null,
6934
7308
  render: (panel) => paint(panel, model())
@@ -6938,7 +7312,11 @@ function createHighlightMenu(viewer) {
6938
7312
  for (const stop of stops) stop();
6939
7313
  stops = [];
6940
7314
  tools = next;
6941
- if (tools) stops = [tools.active.subscribe(refresh), tools.options("highlight").subscribe(refresh)];
7315
+ if (tools) stops = [
7316
+ tools.active.subscribe(refresh),
7317
+ tools.options("highlight").subscribe(refresh),
7318
+ viewer.document.subscribe(refresh)
7319
+ ];
6942
7320
  if (!tools) menu.close();
6943
7321
  refresh();
6944
7322
  };
@@ -7695,6 +8073,7 @@ function createSignatureMenu(viewer, opts = {}) {
7695
8073
  }
7696
8074
  const menu = createMenu(trigger, {
7697
8075
  label: SIGNATURE_LABELS.menu,
8076
+ anchor: root,
7698
8077
  attrs: { "data-ink-menu": "signature" },
7699
8078
  render: (panel) => paint(panel, model())
7700
8079
  });
@@ -7731,6 +8110,6 @@ function createSignatureMenu(viewer, opts = {}) {
7731
8110
  return root;
7732
8111
  }
7733
8112
  //#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 };
8113
+ export { ARROWHEAD_CHOICES, ARROWHEAD_ENDING, COLOR_COLUMNS, COLOR_LABELS, DASH_PATTERN, DEFAULT_CORNER_RADIUS, DEFAULT_FREETEXT_WIDTH, DEFAULT_HIGHLIGHT_OPTIONS, DEFAULT_SHAPE_SIZE, FONT_FAMILIES, LINE_WEIGHTS, MARKUP_ICONS, MARKUP_KINDS, MARKUP_LABELS, PALETTE, SHAPE_COLUMNS, SHAPE_ICONS, SHAPE_KINDS, SHAPE_LABELS, SIGNATURE_FONTS, SIGNATURE_IMAGE_MAX_LENGTH, SIGNATURE_IMAGE_MAX_SIDE, SIGNATURE_IMAGE_TYPES, SIGNATURE_LABELS, SIGNATURE_TABS, TOOL_LABELS, ZOOM_LEVELS, annotationElementFor, arrowheadChoice, attachViewportKeys, builtinTools, clampFontSize, colorMenuModel, computeCssLayout, createColorMenu, createFocusReturn, createHighlightMenu, createIcon, createImageSignature, createLineStyleMenu, createMenu, createNextPage, createOverlay, createPageCount, createPageInput, createPageMount, createPageRenderer, createPageTransform, createPopover, createPrevPage, createRedo, createShapeMenu, createSignatureDialog, createSignatureDraft, createSignatureMenu, createSignaturePad, createSignaturePreview, createTextLayer, createTextStyleMenu, createToolButton, createToolController, createToolOptions, createToolbar, createToolbarSeparator, createTypedSignature, createUndo, createViewer, createViewport, createZoomIn, createZoomOut, createZoomSelect, cssRectsToUserRects, cssVariableDefaults, editFreeText, fitZoomCss, focusInitialMenuItem, fontFamilyValue, freetextTool, getSelectionQuads, handleMenuKeys, handleRadioKeys, handleTabKeys, highlightMenuModel, highlightTool, hitFor, lineStyleMenuModel, menuItems, mostVisiblePageCss, mountViewer, normalizeRotation, noteTool, observeAnnotationElement, placePanel, redactAreaTool, renderAnnotationContent, renderTypedSignature, selectTextTool, selectTool, shapeMenuModel, shapeTool, shouldKeepOpenOnAnnotation, signatureMenuModel, signaturePreview, signatureTool, textStyleMenuModel, threadDeleteLabel, threadPopoverLabel, trackToolStyle, trapTab, visibleRangeCss, withHostTools };
7735
8114
 
7736
8115
  //# sourceMappingURL=dom.mjs.map