@embedpdf/core-annotation 3.0.0-next.1 → 3.0.0-next.11

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/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { NO_ANNOTATION_FLAGS } from "@embedpdf/engine-core/runtime";
2
- import { applyPoint, applyRect, invert, isQuarterTurn, pdfToContentMatrix, rotateAbout } from "@embedpdf/core-geometry";
2
+ import { applyPoint, applyRect, invert, isQuarterTurn, pdfToContentMatrix, rotateAbout, textQuadBounds, textQuadPoints, textQuadRing } from "@embedpdf/core-geometry";
3
3
  //#region src/kinds.ts
4
4
  const OPACITY = {
5
5
  key: "opacity",
@@ -635,11 +635,51 @@ const interactive = (f) => !f.hidden && !f.noView && !f.readOnly;
635
635
  * (ISO 32000 — a ReadOnly form field must still be movable by a form designer;
636
636
  * the form-filling layer enforces field ReadOnly itself). */
637
637
  const annotInteractive = (a) => capsFor(a.subtype).ignoresReadOnly ? !a.flags.hidden && !a.flags.noView : interactive(a.flags);
638
- /** Geometry/style/delete mutations — `locked` freezes the OBJECT, not its
639
- * contents (that's `lockedContents`). */
640
- const annotTransformable = (a) => annotInteractive(a) && !a.flags.locked;
638
+ /**
639
+ * Geometry/style mutations — `locked` freezes the OBJECT, not its contents
640
+ * (that's `lockedContents`), AND the session must hold update authority over
641
+ * the record. One predicate for hit-test, chrome, and props alike, so a
642
+ * record you may not edit renders and behaves exactly like a locked one.
643
+ */
644
+ const annotTransformable = (a) => annotInteractive(a) && !a.flags.locked && (a.authority?.update ?? true);
645
+ /** Deletion — the delete half of the authority split (a narrowed grant can
646
+ * allow update but not delete, or vice versa). Flags gate like transforms. */
647
+ const annotDeletable = (a) => annotInteractive(a) && !a.flags.locked && (a.authority?.delete ?? true);
641
648
  /** `/Contents` text edits — the contents counterpart of `locked`. */
642
- const annotContentsEditable = (a) => annotInteractive(a) && !a.flags.lockedContents;
649
+ const annotContentsEditable = (a) => annotInteractive(a) && !a.flags.lockedContents && (a.authority?.update ?? true);
650
+ //#endregion
651
+ //#region src/plane.ts
652
+ const nonEmpty = (v) => typeof v === "string" && v !== "";
653
+ /** True when this annotation belongs to the conversation plane only —
654
+ * never painted, hit, marquee-selected, or counted into a page's
655
+ * appearance epoch. */
656
+ function isConversationOnly(a) {
657
+ if (a.irt !== void 0 && a.group === void 0) return true;
658
+ if (a.subtype === "text" && a.data?.subtype === "text") {
659
+ if (nonEmpty(a.data.state) || nonEmpty(a.data.stateModel)) return true;
660
+ }
661
+ return false;
662
+ }
663
+ /**
664
+ * A link CHILD attached to another annotation — a `/Link` grouped
665
+ * (`/IRT` + `/RT /Group`) under a non-link parent. Pure substrate: it is
666
+ * the parent's `link` PROPERTY while authoring (derived via `linkOf`) and
667
+ * the navigation plane's anchor while reading — never painted, never hit
668
+ * as itself. Its rect is RECONCILED from the parent's geometry, so direct
669
+ * manipulation would be overwritten anyway.
670
+ */
671
+ function isAttachedLink(a) {
672
+ return a.subtype === "link" && a.group !== void 0;
673
+ }
674
+ /**
675
+ * The ONE page-surface cull: everything that lives in the substrate but is
676
+ * not a page visual of its own — conversation members (replies, review
677
+ * states) and attached link children. Paint order, hit-testing, marquee
678
+ * and the appearance epoch all filter through THIS, never the parts.
679
+ */
680
+ function isSubstrateOnly(a) {
681
+ return isConversationOnly(a) || isAttachedLink(a);
682
+ }
643
683
  //#endregion
644
684
  //#region src/group.ts
645
685
  /**
@@ -650,7 +690,16 @@ const annotContentsEditable = (a) => annotInteractive(a) && !a.flags.lockedConte
650
690
  * carries `group = <primary's id>` (its `refKey`). The primary is NOT stamped —
651
691
  * it is simply the annotation whose `id` equals the group key, so membership is
652
692
  * the set of annotations pointing at it plus the primary itself.
693
+ *
694
+ * ATTACHED LINK CHILDREN are the exception: they ride the same `/RT /Group`
695
+ * wire mechanism but are NOT part of the composite visual — they're substrate
696
+ * plumbing (the parent's link property). They never count as members here, so
697
+ * a linked square still selects as a SINGLE annotation with handles, never
698
+ * shows Ungroup, and the ungroup verb can never strip an attached link's
699
+ * `/IRT` (which would orphan it into an unmanaged standalone document link).
653
700
  */
701
+ /** A subordinate that counts toward a VISUAL group (not link plumbing). */
702
+ const visualMember = (a) => !!a && !isAttachedLink(a);
654
703
  function groupCaps(m, ids) {
655
704
  const members = ids.map((id) => m.byId[id]).filter((a) => !!a);
656
705
  if (members.length === 0) return {
@@ -674,8 +723,11 @@ function groupCaps(m, ids) {
674
723
  function groupKeyOf(m, id) {
675
724
  const a = m.byId[id];
676
725
  if (!a) return null;
677
- if (a.group) return a.group;
678
- for (const other of m.order) if (m.byId[other]?.group === id) return id;
726
+ if (a.group) return isAttachedLink(a) ? null : a.group;
727
+ for (const other of m.order) {
728
+ const sub = m.byId[other];
729
+ if (sub?.group === id && visualMember(sub)) return id;
730
+ }
679
731
  return null;
680
732
  }
681
733
  /**
@@ -688,7 +740,10 @@ function groupMembers(m, id) {
688
740
  if (key == null) return [id];
689
741
  const members = [];
690
742
  if (m.byId[key]) members.push(key);
691
- for (const other of m.order) if (other !== key && m.byId[other]?.group === key) members.push(other);
743
+ for (const other of m.order) {
744
+ const sub = m.byId[other];
745
+ if (other !== key && sub?.group === key && visualMember(sub)) members.push(other);
746
+ }
692
747
  return members;
693
748
  }
694
749
  /** Union of every id's full group — the selection seen as whole groups. */
@@ -1463,9 +1518,12 @@ const DEFAULT_CHROME_GEOM = {
1463
1518
  };
1464
1519
  /** Normalize degrees into `[0, 360)`. */
1465
1520
  const normalizeDeg = (d) => (d % 360 + 360) % 360;
1466
- /** A geom's applied rotation (deg), or 0 for the non-rotatable kinds. */
1521
+ /** A geom's applied rotation (deg), or 0 for the non-rotatable kinds. A
1522
+ * caret's `rot` is AUTHORING metadata (its text's baseline tilt): reported
1523
+ * here so the renderer and selection chrome follow it, while the caret's
1524
+ * caps (not movable/resizable) keep every rotate gesture away from it. */
1467
1525
  function geomRotation(g) {
1468
- if (g.t === "rect" || g.t === "line" || g.t === "poly" || g.t === "ink" || g.t === "text") return g.rot ?? 0;
1526
+ if (g.t === "rect" || g.t === "line" || g.t === "poly" || g.t === "ink" || g.t === "text" || g.t === "caret") return g.rot ?? 0;
1469
1527
  return 0;
1470
1528
  }
1471
1529
  const rectCenter = (r) => ({
@@ -1487,7 +1545,7 @@ function centroidOf(g) {
1487
1545
  x: (g.a.x + g.b.x) / 2,
1488
1546
  y: (g.a.y + g.b.y) / 2
1489
1547
  };
1490
- const pts = g.t === "poly" ? g.points : g.t === "ink" ? g.strokes.flat() : g.quads.flat();
1548
+ const pts = g.t === "poly" ? g.points : g.t === "ink" ? g.strokes.flat() : g.quads.flatMap(textQuadPoints);
1491
1549
  let sx = 0;
1492
1550
  let sy = 0;
1493
1551
  for (const p of pts) {
@@ -1500,9 +1558,12 @@ function centroidOf(g) {
1500
1558
  y: sy / n
1501
1559
  };
1502
1560
  }
1503
- /** Is this kind rotatable (the geometry carries a meaningful `rot`)? */
1561
+ /** Does the geometry carry a meaningful `rot` (an oriented local box exists)?
1562
+ * ORIENTATION is a geometry fact; whether the USER may rotate is the separate
1563
+ * `caps.rotatable` gate — a caret is oriented (it rides its text's tilt) yet
1564
+ * offers no rotate gesture. */
1504
1565
  function isRotatableGeom(g) {
1505
- return g.t === "rect" || g.t === "line" || g.t === "poly" || g.t === "ink" || g.t === "text" && !g.callout;
1566
+ return g.t === "rect" || g.t === "line" || g.t === "poly" || g.t === "ink" || g.t === "caret" || g.t === "text" && !g.callout;
1506
1567
  }
1507
1568
  /**
1508
1569
  * Rotate a geom by `deltaDeg` (clockwise) about `pivot`.
@@ -1511,7 +1572,10 @@ function isRotatableGeom(g) {
1511
1572
  * pivot IS the box centre this is a pure `rot += delta`.
1512
1573
  * - VERTEX (`line`/`poly`/`ink`): map every point through the rotation AND bump
1513
1574
  * the advisory `rot` (the points stay the authoritative visual).
1514
- * Non-rotatable kinds (caret/quads, callouts) are returned unchanged.
1575
+ * Kinds without a rotate VERB are returned unchanged: quads and callouts, and
1576
+ * also the caret — oriented (`isRotatableGeom`) but text-anchored, so its tilt
1577
+ * is authoring metadata that no gesture edits (`geomResetRotation` still
1578
+ * clears it).
1515
1579
  */
1516
1580
  function geomRotateAbout(g, pivot, deltaDeg) {
1517
1581
  if (deltaDeg === 0) return g;
@@ -1571,7 +1635,7 @@ function apFrameSize(g) {
1571
1635
  width: g.rect.width,
1572
1636
  height: g.rect.height
1573
1637
  };
1574
- const b = unionRect(g.t === "line" ? [g.a, g.b] : g.t === "poly" ? g.points : g.t === "ink" ? g.strokes.flat() : g.quads.flat());
1638
+ const b = unionRect(g.t === "line" ? [g.a, g.b] : g.t === "poly" ? g.points : g.t === "ink" ? g.strokes.flat() : g.quads.flatMap(textQuadPoints));
1575
1639
  return {
1576
1640
  width: b.width,
1577
1641
  height: b.height
@@ -1680,7 +1744,7 @@ function fitStampBox(center, desired, page, rotCW) {
1680
1744
  function geomResetRotation(g) {
1681
1745
  const rot = geomRotation(g);
1682
1746
  if (!rot) return g;
1683
- if (g.t === "rect" || g.t === "text") return {
1747
+ if (g.t === "rect" || g.t === "text" || g.t === "caret") return {
1684
1748
  ...g,
1685
1749
  rot: 0
1686
1750
  };
@@ -1697,7 +1761,7 @@ function geomResetRotation(g) {
1697
1761
  function obbFromGeom(g, strokeWidth, border) {
1698
1762
  if (!isRotatableGeom(g)) return null;
1699
1763
  const rot = geomRotation(g);
1700
- if (g.t === "rect" || g.t === "text") {
1764
+ if (g.t === "rect" || g.t === "text" || g.t === "caret") {
1701
1765
  const c = rectCenter(g.rect);
1702
1766
  return {
1703
1767
  corners: rectCornerPoints(g.rect).map((p) => rotatePoint(p, c, rot)),
@@ -2002,6 +2066,77 @@ function caretRectFromTextEnd(lineRect) {
2002
2066
  height
2003
2067
  };
2004
2068
  }
2069
+ /**
2070
+ * Caret box for a text-edit anchor: half the glyph's ink height, centered on
2071
+ * the TRAILING baseline corner in READING direction (`advance` decides which
2072
+ * end — RTL carets land on the visual left), sitting on the baseline. The box
2073
+ * stays axis-aligned; use {@link caretGeomFromAnchor} for the tilt-carrying
2074
+ * geometry.
2075
+ */
2076
+ function caretRectFromAnchor(anchor) {
2077
+ const q = anchor.glyphQuad;
2078
+ const ink = Math.hypot(q.lowerStart.x - q.upperStart.x, q.lowerStart.y - q.upperStart.y);
2079
+ const size = Math.max(ink / 2, 1);
2080
+ const corner = anchor.advance > 0 ? q.lowerEnd : q.lowerStart;
2081
+ return {
2082
+ x: corner.x - size / 2,
2083
+ y: corner.y - size,
2084
+ width: size,
2085
+ height: size
2086
+ };
2087
+ }
2088
+ /** Rotations closer than ~0.05° to upright stay upright (float noise guard). */
2089
+ const CARET_ROT_EPSILON = .05;
2090
+ /**
2091
+ * Caret GEOMETRY for a text-edit anchor: the box-family pair — an UNROTATED
2092
+ * box whose centre sits half a caret-size ascent-ward of the trailing
2093
+ * baseline corner, plus `rot` = the text's baseline tilt (deg, CW in y-down
2094
+ * content space). Rotating the box about its centre by `rot` lands it
2095
+ * hugging the rotated baseline, symbol pointing at its text. For upright
2096
+ * anchors this degenerates EXACTLY to {@link caretRectFromAnchor} with no
2097
+ * `rot` key — the dominant case is byte-identical.
2098
+ */
2099
+ function caretGeomFromAnchor(anchor) {
2100
+ const q = anchor.glyphQuad;
2101
+ const ink = Math.hypot(q.lowerStart.x - q.upperStart.x, q.lowerStart.y - q.upperStart.y);
2102
+ const size = Math.max(ink / 2, 1);
2103
+ const corner = anchor.advance > 0 ? q.lowerEnd : q.lowerStart;
2104
+ const bx = q.lowerEnd.x - q.lowerStart.x;
2105
+ const by = q.lowerEnd.y - q.lowerStart.y;
2106
+ const rot = Math.hypot(bx, by) > 0 ? normalizeDeg(Math.atan2(by, bx) * 180 / Math.PI) : 0;
2107
+ if (rot < CARET_ROT_EPSILON || rot > 360 - CARET_ROT_EPSILON) return {
2108
+ t: "caret",
2109
+ rect: {
2110
+ x: corner.x - size / 2,
2111
+ y: corner.y - size,
2112
+ width: size,
2113
+ height: size
2114
+ }
2115
+ };
2116
+ const ux = (q.upperStart.x - q.lowerStart.x) / ink;
2117
+ const uy = (q.upperStart.y - q.lowerStart.y) / ink;
2118
+ const cx = corner.x + ux * size / 2;
2119
+ const cy = corner.y + uy * size / 2;
2120
+ return {
2121
+ t: "caret",
2122
+ rect: {
2123
+ x: cx - size / 2,
2124
+ y: cy - size / 2,
2125
+ width: size,
2126
+ height: size
2127
+ },
2128
+ rot
2129
+ };
2130
+ }
2131
+ /** Map a TextQuad's corners through a point function (names ride along). */
2132
+ function mapTextQuad(q, f) {
2133
+ return {
2134
+ upperStart: f(q.upperStart),
2135
+ upperEnd: f(q.upperEnd),
2136
+ lowerStart: f(q.lowerStart),
2137
+ lowerEnd: f(q.lowerEnd)
2138
+ };
2139
+ }
2005
2140
  /** Miter limit shared by the bounds math AND the SVG renderer (`stroke-miterlimit`),
2006
2141
  * so the computed box and the drawn stroke always agree on where a sharp join
2007
2142
  * bevels instead of spiking. 10 = the PDF default (also what the baked /AP uses). */
@@ -2150,7 +2285,7 @@ function geomVisualBounds(g, strokeWidth, border) {
2150
2285
  return expandRect(unionRect(all), strokeWidth / 2);
2151
2286
  }
2152
2287
  if (g.t === "rect" || g.t === "text" || g.t === "caret") return g.rect;
2153
- if (g.t === "quads") return expandRect(unionRect(g.quads.flat()), strokeWidth / 2);
2288
+ if (g.t === "quads") return expandRect(unionRect(g.quads.flatMap(textQuadPoints)), strokeWidth / 2);
2154
2289
  if (g.t === "ink") return expandRect(unionRect(g.strokes.flat()), strokeWidth / 2);
2155
2290
  const pts = strokeOutlinePoints(g.t === "line" ? [g.a, g.b] : g.points, g.t === "poly" && g.closed, strokeWidth);
2156
2291
  for (const seg of endingSegs(g)) for (const node of endingNodes(seg.tip, seg.angle, seg.ending, strokeWidth)) if (node.kind === "poly") pts.push(...strokeOutlinePoints(node.points, node.closed, strokeWidth));
@@ -2286,7 +2421,7 @@ function geomBounds(g) {
2286
2421
  if (g.t === "line") return rectFromPoints(g.a, g.b);
2287
2422
  if (g.t === "poly") return unionRect(g.points);
2288
2423
  if (g.t === "ink") return unionRect(g.strokes.flat());
2289
- return unionRect(g.quads.flat());
2424
+ return unionRect(g.quads.flatMap(textQuadPoints));
2290
2425
  }
2291
2426
  /**
2292
2427
  * Is the content point ON the annotation: within `margin` of the stroke, or
@@ -2299,7 +2434,7 @@ function geomBounds(g) {
2299
2434
  */
2300
2435
  function geomHit(g, p, margin, filled, strokeWidth) {
2301
2436
  const tol = margin + strokeWidth / 2;
2302
- if ((g.t === "rect" || g.t === "text" && !g.callout) && (g.rot ?? 0) !== 0) p = rotatePoint(p, rectCenter(g.rect), -(g.rot ?? 0));
2437
+ if ((g.t === "rect" || g.t === "caret" || g.t === "text" && !g.callout) && (g.rot ?? 0) !== 0) p = rotatePoint(p, rectCenter(g.rect), -(g.rot ?? 0));
2303
2438
  if (g.t === "caret") return rectContains(expandRect(g.rect, margin), p);
2304
2439
  if (g.t === "text") {
2305
2440
  const rot = g.callout ? g.rot ?? 0 : 0;
@@ -2374,7 +2509,7 @@ function geomHit(g, p, margin, filled, strokeWidth) {
2374
2509
  for (const stroke of g.strokes) for (let i = 0; i < stroke.length - 1; i++) if (segDist(p, stroke[i], stroke[i + 1]) <= tol) return true;
2375
2510
  return false;
2376
2511
  }
2377
- return g.quads.some((q) => rectContains(unionRect(q), p));
2512
+ return g.quads.some((q) => pointInQuad(p, textQuadRing(q)));
2378
2513
  }
2379
2514
  function geomHandles(g) {
2380
2515
  if (g.t === "rect" || g.t === "text") {
@@ -2463,7 +2598,7 @@ function geomTranslate(g, d) {
2463
2598
  };
2464
2599
  return {
2465
2600
  ...g,
2466
- quads: g.quads.map((q) => q.map(mv))
2601
+ quads: g.quads.map((q) => mapTextQuad(q, mv))
2467
2602
  };
2468
2603
  }
2469
2604
  const OPPOSITE_HANDLE = {
@@ -2554,7 +2689,18 @@ function geomDragHandle(g, handle, to) {
2554
2689
  function geomScene(g, strokeWidth = 0, border) {
2555
2690
  if (g.t === "text") {
2556
2691
  if (!g.callout) return [];
2557
- const pts = calloutLinePoints(g);
2692
+ const pts = [...calloutLinePoints(g)];
2693
+ if (strokeWidth > 0 && pts.length >= 2) {
2694
+ const last = pts[pts.length - 1];
2695
+ const prev = pts[pts.length - 2];
2696
+ const dx = last.x - prev.x;
2697
+ const dy = last.y - prev.y;
2698
+ const len = Math.hypot(dx, dy);
2699
+ if (len > 0) pts[pts.length - 1] = {
2700
+ x: last.x + dx / len * (strokeWidth / 2),
2701
+ y: last.y + dy / len * (strokeWidth / 2)
2702
+ };
2703
+ }
2558
2704
  const nodes = [{
2559
2705
  kind: "poly",
2560
2706
  points: pts,
@@ -2563,15 +2709,16 @@ function geomScene(g, strokeWidth = 0, border) {
2563
2709
  const seg = calloutEndingSeg(pts, g.callout.ending);
2564
2710
  if (seg) nodes.push(...endingNodes(seg.tip, seg.angle, seg.ending, strokeWidth));
2565
2711
  if (strokeWidth > 0) {
2712
+ const r = insetRect(g.rect, strokeWidth / 2);
2566
2713
  const rot = g.rot ?? 0;
2567
2714
  const c = rectCenter(g.rect);
2568
2715
  nodes.push(rot ? {
2569
2716
  kind: "poly",
2570
- points: rectCornerPoints(g.rect).map((p) => rotatePoint(p, c, rot)),
2717
+ points: rectCornerPoints(r).map((p) => rotatePoint(p, c, rot)),
2571
2718
  closed: true
2572
2719
  } : {
2573
2720
  kind: "rect",
2574
- rect: g.rect
2721
+ rect: r
2575
2722
  });
2576
2723
  }
2577
2724
  return nodes;
@@ -2636,12 +2783,7 @@ function geomScene(g, strokeWidth = 0, border) {
2636
2783
  }));
2637
2784
  return g.quads.map((q) => ({
2638
2785
  kind: "poly",
2639
- points: [
2640
- q[0],
2641
- q[1],
2642
- q[3],
2643
- q[2]
2644
- ],
2786
+ points: textQuadRing(q),
2645
2787
  closed: true
2646
2788
  }));
2647
2789
  }
@@ -2841,7 +2983,9 @@ function anchoredBox(box, anchor, mode, view) {
2841
2983
  * (always beneath), then every other kind, each group preserving creation
2842
2984
  * z-order. The ONE z-order shared by rendering (`pageItems`) and hit-testing —
2843
2985
  * and the ONE visibility cull: what `/F` hides (hidden / un-engaged noView)
2844
- * neither paints nor hits, so an invisible annotation can never eat a click. */
2986
+ * neither paints nor hits, so an invisible annotation can never eat a click.
2987
+ * Conversation-plane annotations (replies, review-status states) are culled
2988
+ * here too — dialogue lives in the comments UI, never on the page. */
2845
2989
  function paintOrder(m, pon) {
2846
2990
  const markup = [];
2847
2991
  const other = [];
@@ -2849,6 +2993,7 @@ function paintOrder(m, pon) {
2849
2993
  const a = m.byId[id];
2850
2994
  if (!a || a.pon !== pon) continue;
2851
2995
  if (!viewable(a.flags, m.selected.includes(id))) continue;
2996
+ if (isSubstrateOnly(a)) continue;
2852
2997
  (isMarkup(a.subtype) ? markup : other).push(id);
2853
2998
  }
2854
2999
  return [...markup, ...other];
@@ -2875,7 +3020,7 @@ const canMove = (m, id) => {
2875
3020
  * `locked` (and inert `/F` states) suppress them at runtime — a
2876
3021
  * screen-anchored body keeps its handles: `noZoom`/`noRotate` exempt it from
2877
3022
  * the display transform, they don't freeze its size or vertices. */
2878
- const hasHandles = (a) => {
3023
+ const hasHandles = (m, a) => {
2879
3024
  if (!annotTransformable(a) || textBound(a)) return false;
2880
3025
  const c = capsFor(a.subtype);
2881
3026
  return c.resizable || c.vertexEditable;
@@ -2942,7 +3087,7 @@ function hitTest(m, pon, p, geom, strokeMargin, pageBox, inert, view) {
2942
3087
  };
2943
3088
  }
2944
3089
  }
2945
- if (hasHandles(a)) {
3090
+ if (hasHandles(m, a)) {
2946
3091
  for (const h of geomHandles(hitGeomOf(a, view))) if (Math.abs(h.at.x - p.x) <= geom.handleTol && Math.abs(h.at.y - p.y) <= geom.handleTol) return {
2947
3092
  t: "handle",
2948
3093
  id: a.id,
@@ -3029,6 +3174,24 @@ function cursorAt(m, pon, p, geom, strokeMargin, pageBox, inert, view) {
3029
3174
  return null;
3030
3175
  }
3031
3176
  //#endregion
3177
+ //#region src/links.ts
3178
+ /** Every attached link child of `parentId`, in z-order (multi-segment
3179
+ * markup parents carry several children with one shared target). */
3180
+ function linkChildrenOf(m, parentId) {
3181
+ const out = [];
3182
+ for (const id of m.order) {
3183
+ const a = m.byId[id];
3184
+ if (a && isAttachedLink(a) && a.group === parentId) out.push(a);
3185
+ }
3186
+ return out;
3187
+ }
3188
+ /** The parent's link target, derived from its first attached child — the
3189
+ * read side of the `syncLink` reconciler. Null when no child exists. */
3190
+ function linkOf(m, parentId) {
3191
+ const d = linkChildrenOf(m, parentId)[0]?.data;
3192
+ return d?.subtype === "link" ? d.target ?? null : null;
3193
+ }
3194
+ //#endregion
3032
3195
  //#region src/props.ts
3033
3196
  /**
3034
3197
  * The flat property vocabulary over the model: read and apply
@@ -3065,6 +3228,9 @@ const textStyleFromProps = (p) => ({
3065
3228
  fontColor: p.fontColor,
3066
3229
  textAlign: p.textAlign
3067
3230
  });
3231
+ /** Does this kind's prop table declare `link` (attachable link)? The link
3232
+ * KIND itself also declares it, but stores its own `/A` instead. */
3233
+ const kindTakesLink = (subtype) => propsFor(subtype).some((s) => s.key === "link");
3068
3234
  /** A geom that carries `/LE` endings: a line, or an OPEN poly (polyline). */
3069
3235
  const endingsGeom = (g) => g.t === "line" || g.t === "poly" && !g.closed;
3070
3236
  /**
@@ -3150,7 +3316,7 @@ function applyProps(a, patch) {
3150
3316
  ...next,
3151
3317
  icon: patch.icon
3152
3318
  };
3153
- if (patch.link !== void 0 && takes.has("link")) next = {
3319
+ if (patch.link !== void 0 && takes.has("link") && a.subtype === "link") next = {
3154
3320
  ...next,
3155
3321
  link: patch.link
3156
3322
  };
@@ -3664,10 +3830,10 @@ function update(m, msg) {
3664
3830
  case "createPointer": return createPointer(m, msg.phase, msg.subtype, msg.in, msg.preset, msg.intent, msg.deferInkCommit, msg.straightenInk, msg.clickCreate, msg.flags);
3665
3831
  case "finishInkDraft": return finishInkCreate(m);
3666
3832
  case "finishCreationDraft": return finishPolyCreate(m);
3667
- case "createCaret": return createCaret(m, msg.pon, msg.rect, msg.flags);
3668
- case "createReplaceText": return createReplaceText(m, msg.pon, msg.rects, msg.endRect, msg.preset);
3669
- case "createMarkup": return createMarkup(m, msg.subtype, msg.pon, msg.rects, msg.preset, msg.flags);
3670
- case "setMarkupPreview": return setMarkupPreview(m, msg.subtype, msg.rectsByPage, msg.preset);
3833
+ case "createCaret": return createCaret(m, msg.pon, msg.anchor, msg.flags);
3834
+ case "createReplaceText": return createReplaceText(m, msg.pon, msg.quads, msg.anchor, msg.preset);
3835
+ case "createMarkup": return createMarkup(m, msg.subtype, msg.pon, msg.quads, msg.preset, msg.flags);
3836
+ case "setMarkupPreview": return setMarkupPreview(m, msg.subtype, msg.quadsByPage, msg.preset);
3671
3837
  case "clearMarkupPreview": return m.preview ? [{
3672
3838
  ...m,
3673
3839
  preview: null
@@ -3712,6 +3878,7 @@ function update(m, msg) {
3712
3878
  draft: null
3713
3879
  }, []];
3714
3880
  case "loaded": return [mergeLoaded(m, msg.annots), []];
3881
+ case "hydrated": return [hydrateAnnots(m, msg.annots, msg.bumpAp ?? false), []];
3715
3882
  case "created": return [reconcile(m, msg.tempId, msg.id, msg.ref), []];
3716
3883
  case "createFailed": return [removeAnnots(m, [msg.tempId]), []];
3717
3884
  case "upsert": return [upsertAnnots(m, msg.annots, msg.bumpAp), []];
@@ -4468,33 +4635,22 @@ function finishPolyCreate(m) {
4468
4635
  id
4469
4636
  }]];
4470
4637
  }
4471
- /** Per-line selection rects → /QuadPoints quads (content space, y-down: UL, UR,
4472
- * LL, LR). Shared by markup creation and the live preview. */
4473
- const rectsToQuads = (rects) => rects.filter((r) => r.width > 0 && r.height > 0).map((r) => [
4474
- {
4475
- x: r.x,
4476
- y: r.y
4477
- },
4478
- {
4479
- x: r.x + r.width,
4480
- y: r.y
4481
- },
4482
- {
4483
- x: r.x,
4484
- y: r.y + r.height
4485
- },
4486
- {
4487
- x: r.x + r.width,
4488
- y: r.y + r.height
4489
- }
4490
- ]);
4638
+ /** Drop degenerate segment quads (zero-length baseline or ink extent). Area is
4639
+ * the cross product of the two edge vectors — orientation-safe. */
4640
+ const usableQuads = (quads) => quads.filter((q) => {
4641
+ const ux = q.upperEnd.x - q.upperStart.x;
4642
+ const uy = q.upperEnd.y - q.upperStart.y;
4643
+ const sx = q.lowerStart.x - q.upperStart.x;
4644
+ const sy = q.lowerStart.y - q.upperStart.y;
4645
+ return Math.abs(ux * sy - uy * sx) > 0;
4646
+ });
4491
4647
  /**
4492
4648
  * Build a text-markup annotation from the selection's per-line rects. The new
4493
4649
  * annotation is `vector` (rendered live by the overlay) and selected, mirroring
4494
4650
  * `createPointer`. One call per page the selection spans. Clears any live preview.
4495
4651
  */
4496
- function createMarkup(m, subtype, pon, rects, preset = subtype, flags) {
4497
- const quads = rectsToQuads(rects);
4652
+ function createMarkup(m, subtype, pon, segmentQuads, preset = subtype, flags) {
4653
+ const quads = usableQuads(segmentQuads);
4498
4654
  if (!quads.length) return [m, []];
4499
4655
  const id = `tmp:${m.seq + 1}`;
4500
4656
  const annot = {
@@ -4535,9 +4691,9 @@ function createMarkup(m, subtype, pon, rects, preset = subtype, flags) {
4535
4691
  * (`/IT /StrikeOutTextEdit`, `/IRT` caret, `/RT /Group`). Persistence performs
4536
4692
  * the two ordered writes and rolls the primary back if the subordinate fails.
4537
4693
  */
4538
- function createReplaceText(m, pon, rects, textEndRect, preset = "replace-text") {
4539
- const quads = rectsToQuads(rects);
4540
- if (!quads.length || textEndRect.width <= 0 || textEndRect.height <= 0) return [m, []];
4694
+ function createReplaceText(m, pon, segmentQuads, anchor, preset = "replace-text") {
4695
+ const quads = usableQuads(segmentQuads);
4696
+ if (!quads.length) return [m, []];
4541
4697
  const primaryId = `tmp:${m.seq + 1}`;
4542
4698
  const strikeoutId = `tmp:${m.seq + 2}`;
4543
4699
  const style = styleFromProps(defaultsFor(m, preset));
@@ -4547,10 +4703,7 @@ function createReplaceText(m, pon, rects, textEndRect, preset = "replace-text")
4547
4703
  pon,
4548
4704
  subtype: "caret",
4549
4705
  intent: "replace",
4550
- geom: {
4551
- t: "caret",
4552
- rect: caretRectFromTextEnd(textEndRect)
4553
- },
4706
+ geom: caretGeomFromAnchor(anchor),
4554
4707
  style,
4555
4708
  flags: DRAWN_FLAGS,
4556
4709
  source: "vector"
@@ -4593,20 +4746,17 @@ function createReplaceText(m, pon, rects, textEndRect, preset = "replace-text")
4593
4746
  members: [strikeoutId]
4594
4747
  }]];
4595
4748
  }
4596
- function createCaret(m, pon, textEndRect, flags) {
4597
- if (textEndRect.width <= 0 || textEndRect.height <= 0) return [m, []];
4749
+ function createCaret(m, pon, anchor, flags) {
4750
+ const caretGeom = caretGeomFromAnchor(anchor);
4751
+ if (caretGeom.rect.width <= 0 || caretGeom.rect.height <= 0) return [m, []];
4598
4752
  const id = `tmp:${m.seq + 1}`;
4599
- const def = defaultsFor(m, "caret");
4600
4753
  const annot = {
4601
4754
  id,
4602
4755
  ref: null,
4603
4756
  pon,
4604
4757
  subtype: "caret",
4605
- geom: {
4606
- t: "caret",
4607
- rect: caretRectFromTextEnd(textEndRect)
4608
- },
4609
- style: styleFromProps(def),
4758
+ geom: caretGeom,
4759
+ style: styleFromProps(defaultsFor(m, "caret")),
4610
4760
  flags: {
4611
4761
  ...DRAWN_FLAGS,
4612
4762
  ...flags
@@ -4629,11 +4779,11 @@ function createCaret(m, pon, textEndRect, flags) {
4629
4779
  id
4630
4780
  }]];
4631
4781
  }
4632
- /** Set / replace the live markup preview from the selection's per-page rects. */
4633
- function setMarkupPreview(m, subtype, rectsByPage, preset = subtype) {
4782
+ /** Set / replace the live markup preview from the selection's per-page quads. */
4783
+ function setMarkupPreview(m, subtype, quadsByPage, preset = subtype) {
4634
4784
  const byPage = {};
4635
- for (const k in rectsByPage) {
4636
- const quads = rectsToQuads(rectsByPage[k]);
4785
+ for (const k in quadsByPage) {
4786
+ const quads = usableQuads(quadsByPage[k]);
4637
4787
  if (quads.length) byPage[Number(k)] = quads;
4638
4788
  }
4639
4789
  return [{
@@ -4661,8 +4811,16 @@ function setProps(m, patch) {
4661
4811
  for (const id of m.selected) {
4662
4812
  const a = byId[id];
4663
4813
  if (!a) continue;
4814
+ const linkIntent = patch.link !== void 0 && a.subtype !== "link" && kindTakesLink(a.subtype) && annotTransformable(a);
4664
4815
  const next = applyProps(a, patch);
4665
- if (!next) continue;
4816
+ if (!next) {
4817
+ if (linkIntent) fx.push({
4818
+ fx: "syncLink",
4819
+ id,
4820
+ target: patch.link ?? null
4821
+ });
4822
+ continue;
4823
+ }
4666
4824
  const linkChanged = next.link !== a.link;
4667
4825
  const otherChanged = next.style !== a.style || next.geom !== a.geom || next.text !== a.text || next.icon !== a.icon;
4668
4826
  byId[id] = capsFor(a.subtype).opaqueBody || !otherChanged ? next : toVector(next);
@@ -4674,9 +4832,10 @@ function setProps(m, patch) {
4674
4832
  keys
4675
4833
  }
4676
4834
  });
4677
- if (linkChanged && a.subtype !== "link") fx.push({
4835
+ if (linkIntent) fx.push({
4678
4836
  fx: "syncLink",
4679
- id
4837
+ id,
4838
+ target: patch.link ?? null
4680
4839
  });
4681
4840
  }
4682
4841
  return fx.length ? [{
@@ -4693,6 +4852,14 @@ function setProps(m, patch) {
4693
4852
  * COMMITTED member; uncommitted drafts just merge (their create draft carries
4694
4853
  * the flags when it commits).
4695
4854
  */
4855
+ /**
4856
+ * The actions plane's session-visibility write (Hide actions, script
4857
+ * `annot.hidden`): merge per-id hidden overrides into the session overlay.
4858
+ * Pure session state — ZERO effects, no engine write, no authority. Hiding
4859
+ * clears transient engagement so no orphaned selection chrome or text editor
4860
+ * survives on an invisible annotation. Identity-preserving no-op when nothing
4861
+ * changes (plugin memo caches key on model identity).
4862
+ */
4696
4863
  function setFlags(m, patch, ids) {
4697
4864
  const targets = ids ?? m.selected;
4698
4865
  if (!targets.length) return [m, []];
@@ -4803,28 +4970,26 @@ function resetRotation(m) {
4803
4970
  function deleteSelection(m) {
4804
4971
  const deletable = m.selected.filter((id) => {
4805
4972
  const a = m.byId[id];
4806
- return !!a && annotTransformable(a);
4973
+ return !!a && annotDeletable(a);
4807
4974
  });
4808
4975
  if (!deletable.length) return [m, []];
4976
+ const withChildren = [...deletable, ...deletable.flatMap((id) => linkChildrenOf(m, id).map((c) => c.id))];
4809
4977
  const fx = [];
4810
- for (const id of deletable) {
4978
+ for (const id of withChildren) {
4811
4979
  const a = m.byId[id];
4812
4980
  if (a?.ref) fx.push({
4813
4981
  fx: "delete",
4814
4982
  ref: a.ref
4815
4983
  });
4816
- for (const childRef of a?.linkRefs ?? []) fx.push({
4817
- fx: "delete",
4818
- ref: childRef
4819
- });
4820
4984
  }
4821
- return [removeAnnots(m, deletable), fx];
4985
+ return [removeAnnots(m, withChildren), fx];
4822
4986
  }
4823
4987
  function annotsInBox(m, pon, a, b, inert, view) {
4824
4988
  const box = rectFromPoints(a, b);
4825
4989
  return m.order.filter((id) => {
4826
4990
  const annot = m.byId[id];
4827
4991
  if (annot?.pon !== pon || inert?.has(id) || !isSelectable(m, id)) return false;
4992
+ if (isSubstrateOnly(annot)) return false;
4828
4993
  return quadIntersectsRect(selectionQuad(anchoredGeom(annot.geom, anchorModeOf(annot), view), annot.style.strokeWidth, annot.style.border), box);
4829
4994
  });
4830
4995
  }
@@ -4843,6 +5008,39 @@ function mergeLoaded(m, annots) {
4843
5008
  };
4844
5009
  }
4845
5010
  /**
5011
+ * Whole-document hydration ingest: the snapshot is the committed truth.
5012
+ * Overwrites by id via `upsertAnnots` (gesture protection included), then
5013
+ * reaps committed entries the snapshot no longer contains — deletions that
5014
+ * happened before we subscribed (initial load) or inside a desync gap.
5015
+ * Gentler than `removeAnnots`: `tmp:` drafts and gesture-locked ids
5016
+ * survive, and an in-progress draft is NOT cancelled — reaped ids can
5017
+ * never be part of it (locked ids are excluded from reaping).
5018
+ */
5019
+ function hydrateAnnots(m, annots, bumpApFlag) {
5020
+ const incoming = new Set(annots.map((a) => a.id));
5021
+ const locked = draftIds(m.draft);
5022
+ const reaped = m.order.filter((id) => {
5023
+ if (incoming.has(id) || locked.has(id)) return false;
5024
+ const a = m.byId[id];
5025
+ return a !== void 0 && a.ref !== null;
5026
+ });
5027
+ let next = m;
5028
+ if (reaped.length > 0) {
5029
+ const gone = new Set(reaped);
5030
+ const byId = { ...m.byId };
5031
+ for (const id of reaped) delete byId[id];
5032
+ next = {
5033
+ ...m,
5034
+ byId,
5035
+ order: m.order.filter((id) => !gone.has(id)),
5036
+ selected: m.selected.filter((id) => !gone.has(id)),
5037
+ hovered: m.hovered && gone.has(m.hovered) ? null : m.hovered,
5038
+ editing: m.editing && gone.has(m.editing) ? null : m.editing
5039
+ };
5040
+ }
5041
+ return upsertAnnots(next, annots, bumpApFlag);
5042
+ }
5043
+ /**
4846
5044
  * Add-or-replace by id. Unlike `mergeLoaded` (which skips ids it already has,
4847
5045
  * for the bulk page read), this OVERWRITES — it's how the data API re-syncs an
4848
5046
  * annotation from the authoritative engine DTO and how a remote edit lands.
@@ -4858,13 +5056,8 @@ function upsertAnnots(m, annots, bumpAp = false) {
4858
5056
  if (dragging.has(a.id)) continue;
4859
5057
  if (!byId[a.id]) order.push(a.id);
4860
5058
  const prev = byId[a.id];
4861
- const fold = a.linkRefs === void 0 && prev?.linkRefs?.length && a.subtype !== "link" ? {
4862
- link: a.link !== void 0 ? a.link : prev.link,
4863
- linkRefs: prev.linkRefs
4864
- } : {};
4865
5059
  byId[a.id] = {
4866
5060
  ...a,
4867
- ...fold,
4868
5061
  apVersion: (prev?.apVersion ?? 0) + (bumpAp ? 1 : 0)
4869
5062
  };
4870
5063
  }
@@ -4972,70 +5165,99 @@ function shapePaint(style, closed) {
4972
5165
  ...style.border.kind === "cloudy" ? { join: "round" } : {}
4973
5166
  };
4974
5167
  }
4975
- /** A smooth squiggle (quadratic-bezier wave) along a baseline, adapted from v2's
4976
- * tile: one `Q` hump then reflected `T` segments alternate up/down across the run. */
4977
- function squigglePath(x, y, w, amp) {
5168
+ /** A smooth squiggle (quadratic-bezier wave) along an ARBITRARY baseline,
5169
+ * generated in the (û, n̂) basis: one `Q` hump then reflected `T` segments
5170
+ * alternate across the run. Reflection is affine-invariant, so an upright
5171
+ * quad reproduces the old axis-aligned wave byte-for-byte. `n̂` points from
5172
+ * the baseline toward the ascent side; humps rise toward the text. */
5173
+ function squigglePath(start, u, n, w, amp) {
4978
5174
  const half = Math.max(2, amp * 1.5);
4979
- let d = `M ${num(x)} ${num(y)} Q ${num(x + half / 2)} ${num(y - amp)} ${num(x + half)} ${num(y)}`;
4980
- for (let px = x + half; px + half <= x + w + .5; px += half) d += ` T ${num(px + half)} ${num(y)}`;
5175
+ const at = (t, off) => ({
5176
+ x: start.x + u.x * t + n.x * off,
5177
+ y: start.y + u.y * t + n.y * off
5178
+ });
5179
+ const p = (v) => `${num(v.x)} ${num(v.y)}`;
5180
+ const hump = at(half / 2, amp);
5181
+ let d = `M ${p(at(0, 0))} Q ${p(hump)} ${p(at(half, 0))}`;
5182
+ for (let t = half; t + half <= w + .5; t += half) d += ` T ${p(at(t + half, 0))}`;
4981
5183
  return d;
4982
5184
  }
4983
- /** Per-subtype markup nodes. Quads are axis-aligned per-line rects (UL,UR,LL,LR);
4984
- * the colour is the markup `/C` (our model keeps stroke==fill). */
5185
+ /** Per-subtype markup nodes on the quads' own edges (corner-NAMED TextQuads:
5186
+ * upper = ascent side, lower = baseline side, start → end along the frame).
5187
+ * The colour is the markup `/C` (our model keeps stroke==fill). Rotated and
5188
+ * sheared cells draw along their true baselines; upright output is identical
5189
+ * to the old axis-aligned math. */
4985
5190
  function markupScene(subtype, quads, style) {
4986
5191
  const color = style.color;
4987
5192
  const opacity = style.opacity;
4988
5193
  const nodes = [];
4989
5194
  for (const q of quads) {
4990
- const x = q[0].x;
4991
- const y = q[0].y;
4992
- const w = q[1].x - q[0].x;
4993
- const h = q[2].y - q[0].y;
5195
+ const down = {
5196
+ x: q.lowerStart.x - q.upperStart.x,
5197
+ y: q.lowerStart.y - q.upperStart.y
5198
+ };
5199
+ const h = Math.hypot(down.x, down.y);
5200
+ const wVec = {
5201
+ x: q.lowerEnd.x - q.lowerStart.x,
5202
+ y: q.lowerEnd.y - q.lowerStart.y
5203
+ };
5204
+ const w = Math.hypot(wVec.x, wVec.y);
4994
5205
  if (w <= 0 || h <= 0) continue;
5206
+ const n = {
5207
+ x: down.x / h,
5208
+ y: down.y / h
5209
+ };
4995
5210
  const lw = Math.min(2.5, Math.max(.75, h * .06));
4996
- if (subtype === "underline") {
4997
- const yy = y + h - lw;
4998
- nodes.push({
4999
- kind: "line",
5000
- a: {
5001
- x,
5002
- y: yy
5003
- },
5004
- b: {
5005
- x: x + w,
5006
- y: yy
5007
- },
5008
- paint: {
5009
- stroke: color,
5010
- width: lw,
5011
- opacity,
5012
- blend: blendFor(style)
5013
- }
5014
- });
5015
- } else if (subtype === "strikeout") {
5016
- const yy = y + h / 2;
5017
- nodes.push({
5018
- kind: "line",
5019
- a: {
5020
- x,
5021
- y: yy
5022
- },
5023
- b: {
5024
- x: x + w,
5025
- y: yy
5026
- },
5027
- paint: {
5028
- stroke: color,
5029
- width: lw,
5030
- opacity,
5031
- blend: blendFor(style)
5032
- }
5033
- });
5034
- } else if (subtype === "squiggly") {
5211
+ if (subtype === "underline") nodes.push({
5212
+ kind: "line",
5213
+ a: {
5214
+ x: q.lowerStart.x - n.x * lw,
5215
+ y: q.lowerStart.y - n.y * lw
5216
+ },
5217
+ b: {
5218
+ x: q.lowerEnd.x - n.x * lw,
5219
+ y: q.lowerEnd.y - n.y * lw
5220
+ },
5221
+ paint: {
5222
+ stroke: color,
5223
+ width: lw,
5224
+ opacity,
5225
+ blend: blendFor(style)
5226
+ }
5227
+ });
5228
+ else if (subtype === "strikeout") nodes.push({
5229
+ kind: "line",
5230
+ a: {
5231
+ x: (q.upperStart.x + q.lowerStart.x) / 2,
5232
+ y: (q.upperStart.y + q.lowerStart.y) / 2
5233
+ },
5234
+ b: {
5235
+ x: (q.upperEnd.x + q.lowerEnd.x) / 2,
5236
+ y: (q.upperEnd.y + q.lowerEnd.y) / 2
5237
+ },
5238
+ paint: {
5239
+ stroke: color,
5240
+ width: lw,
5241
+ opacity,
5242
+ blend: blendFor(style)
5243
+ }
5244
+ });
5245
+ else if (subtype === "squiggly") {
5035
5246
  const amp = Math.min(2, Math.max(1, h * .08));
5247
+ const u = {
5248
+ x: wVec.x / w,
5249
+ y: wVec.y / w
5250
+ };
5251
+ const start = {
5252
+ x: q.lowerStart.x - n.x * amp,
5253
+ y: q.lowerStart.y - n.y * amp
5254
+ };
5036
5255
  nodes.push({
5037
5256
  kind: "path",
5038
- d: squigglePath(x, y + h - amp, w, amp),
5257
+ d: squigglePath(start, u, {
5258
+ x: -n.x,
5259
+ y: -n.y
5260
+ }, w, amp),
5039
5261
  paint: {
5040
5262
  stroke: color,
5041
5263
  width: lw,
@@ -5044,13 +5266,9 @@ function markupScene(subtype, quads, style) {
5044
5266
  }
5045
5267
  });
5046
5268
  } else nodes.push({
5047
- kind: "rect",
5048
- rect: {
5049
- x,
5050
- y,
5051
- width: w,
5052
- height: h
5053
- },
5269
+ kind: "poly",
5270
+ points: textQuadRing(q),
5271
+ closed: true,
5054
5272
  paint: {
5055
5273
  fill: color,
5056
5274
  opacity,
@@ -5060,23 +5278,40 @@ function markupScene(subtype, quads, style) {
5060
5278
  }
5061
5279
  return nodes;
5062
5280
  }
5063
- /** A redact mark's regions: per-quad boxes (text marks) or the rect (area). */
5281
+ const rectRing = (r) => [
5282
+ {
5283
+ x: r.x,
5284
+ y: r.y
5285
+ },
5286
+ {
5287
+ x: r.x + r.width,
5288
+ y: r.y
5289
+ },
5290
+ {
5291
+ x: r.x + r.width,
5292
+ y: r.y + r.height
5293
+ },
5294
+ {
5295
+ x: r.x,
5296
+ y: r.y + r.height
5297
+ }
5298
+ ];
5064
5299
  function redactRegions(geom) {
5065
5300
  if (geom.t === "quads") {
5066
5301
  const out = [];
5067
5302
  for (const q of geom.quads) {
5068
- const w = q[1].x - q[0].x;
5069
- const h = q[2].y - q[0].y;
5070
- if (w > 0 && h > 0) out.push({
5071
- x: q[0].x,
5072
- y: q[0].y,
5073
- width: w,
5074
- height: h
5303
+ const bounds = textQuadBounds(q);
5304
+ if (bounds.width > 0 && bounds.height > 0) out.push({
5305
+ ring: textQuadRing(q),
5306
+ bounds
5075
5307
  });
5076
5308
  }
5077
5309
  return out;
5078
5310
  }
5079
- if (geom.t === "rect") return [geom.rect];
5311
+ if (geom.t === "rect") return [{
5312
+ ring: rectRing(geom.rect),
5313
+ bounds: geom.rect
5314
+ }];
5080
5315
  return [];
5081
5316
  }
5082
5317
  /**
@@ -5142,9 +5377,10 @@ function redactScene(item) {
5142
5377
  width: item.style.strokeWidth || 1.5,
5143
5378
  opacity: item.style.opacity
5144
5379
  };
5145
- return regions.map((rect) => ({
5146
- kind: "rect",
5147
- rect,
5380
+ return regions.map((region) => ({
5381
+ kind: "poly",
5382
+ points: region.ring,
5383
+ closed: true,
5148
5384
  paint
5149
5385
  }));
5150
5386
  }
@@ -5154,13 +5390,14 @@ function redactScene(item) {
5154
5390
  fill: item.style.interiorColor,
5155
5391
  opacity: 1
5156
5392
  };
5157
- for (const rect of regions) nodes.push({
5158
- kind: "rect",
5159
- rect,
5393
+ for (const region of regions) nodes.push({
5394
+ kind: "poly",
5395
+ points: region.ring,
5396
+ closed: true,
5160
5397
  paint: fill
5161
5398
  });
5162
5399
  }
5163
- if (item.label) for (const region of regions) nodes.push(...layoutRedactLabel(region, item.label, item.text));
5400
+ if (item.label) for (const region of regions) nodes.push(...layoutRedactLabel(region.bounds, item.label, item.text));
5164
5401
  return nodes;
5165
5402
  }
5166
5403
  /** The full painted scene for one annotation. */
@@ -5284,6 +5521,7 @@ function effAp(m, id, view) {
5284
5521
  function effSource(m, id) {
5285
5522
  const a = m.byId[id];
5286
5523
  if (capsFor(a.subtype).opaqueBody) return a.source;
5524
+ if (m.editing === id && a.geom.t === "text" && a.geom.callout) return "vector";
5287
5525
  const d = m.draft;
5288
5526
  if (d?.g === "handle" && d.id === id) return "vector";
5289
5527
  if ((d?.g === "rotate" || d?.g === "group") && d.ids.includes(id)) return "vector";
@@ -5584,7 +5822,7 @@ function chrome(m, pon, pageBox, knobOffset = 24, view) {
5584
5822
  const style = effStyle(a, view);
5585
5823
  const caps = capsFor(a.subtype);
5586
5824
  const rot = geomRotation(g);
5587
- const obb = caps.rotatable ? obbFromGeom(g, style.strokeWidth, a.style.border) : null;
5825
+ const obb = obbFromGeom(g, style.strokeWidth, a.style.border);
5588
5826
  if (obb && rot !== 0) nodes.push({
5589
5827
  kind: "obb",
5590
5828
  corners: obb.corners,
@@ -5675,6 +5913,6 @@ function creationDraftAnchor(m) {
5675
5913
  };
5676
5914
  }
5677
5915
  //#endregion
5678
- export { DEFAULT_CHROME_GEOM, DRAWN_FLAGS, FLAG_KEYS, KINDS, MIN_DRAG, MITER_LIMIT, NO_ANNOTATION_FLAGS, RECT_HANDLES, ROTATE_KNOB_OFFSET, anchorModeOf, anchorOf, anchoredBox, anchoredGeom, anchoredStrokeWidth, annotContentsEditable, annotInteractive, annotTransformable, applyProps, calloutConnection, calloutLinePoints, canMove, capsFor, caretRectFromTextEnd, centroidOf, chrome, clampRectToBox, clickCreateGeom, cloudyBorderExtent, cloudyPath, computeMoveSnap, contentToPdfPoint, contentToPdfRect, creationDraftAnchor, cursorAt, defaultsFor, expandGroups, fitStampBox, flagsEqual, geomBounds, geomDragHandle, geomHandles, geomHit, geomPdfBounds, geomResetRotation, geomRotateAbout, geomRotation, geomScaleAbout, geomScene, geomTranslate, geomVisualBounds, groupCaps, groupKeyOf, groupMembers, groupResizeAnchor, groupResizeBox, groupResizeFactors, hitTest, initialModel, initialStyle, initialTextStyle, interactive, isRotatableGeom, isSelectable, mergeFlags, normalizeDeg, obbFromGeom, pageItems, pdfToContentPoint, pdfToContentRect, placeRotateKnob, pointInQuad, propsFor, quadIntersectsRect, readProp, rectFromPoints, resolveClickPlacement, rotateDraftDelta, rotateKnob, rotatedAabb, rotatedHandleCursor, scene, selectedItems, selectionAnchor, selectionBounds, selectionBoundsOnPage, selectionCenter, selectionKnob, selectionQuad, shapeRectFor, sharedProps, straightenInkStroke, styleFromProps, textBoxes, textStyleFromProps, transposedAboutCenter, unanchoredGeom, unionRect, update, uprightAnchoredRect, uprightRotation, viewable };
5916
+ export { DEFAULT_CHROME_GEOM, DRAWN_FLAGS, FLAG_KEYS, KINDS, MIN_DRAG, MITER_LIMIT, NO_ANNOTATION_FLAGS, RECT_HANDLES, ROTATE_KNOB_OFFSET, anchorModeOf, anchorOf, anchoredBox, anchoredGeom, anchoredStrokeWidth, annotContentsEditable, annotDeletable, annotInteractive, annotTransformable, applyProps, calloutConnection, calloutLinePoints, canMove, capsFor, caretGeomFromAnchor, caretRectFromAnchor, caretRectFromTextEnd, centroidOf, chrome, clampRectToBox, clickCreateGeom, cloudyBorderExtent, cloudyPath, computeMoveSnap, contentToPdfPoint, contentToPdfRect, creationDraftAnchor, cursorAt, defaultsFor, expandGroups, fitStampBox, flagsEqual, geomBounds, geomDragHandle, geomHandles, geomHit, geomPdfBounds, geomResetRotation, geomRotateAbout, geomRotation, geomScaleAbout, geomScene, geomTranslate, geomVisualBounds, groupCaps, groupKeyOf, groupMembers, groupResizeAnchor, groupResizeBox, groupResizeFactors, hitTest, initialModel, initialStyle, initialTextStyle, interactive, isAttachedLink, isConversationOnly, isRotatableGeom, isSelectable, isSubstrateOnly, linkChildrenOf, linkOf, mergeFlags, normalizeDeg, obbFromGeom, pageItems, pdfToContentPoint, pdfToContentRect, placeRotateKnob, pointInQuad, propsFor, quadIntersectsRect, readProp, rectFromPoints, resolveClickPlacement, rotateDraftDelta, rotateKnob, rotatedAabb, rotatedHandleCursor, scene, selectedItems, selectionAnchor, selectionBounds, selectionBoundsOnPage, selectionCenter, selectionKnob, selectionQuad, shapeRectFor, sharedProps, straightenInkStroke, styleFromProps, textBoxes, textStyleFromProps, transposedAboutCenter, unanchoredGeom, unionRect, update, uprightAnchoredRect, uprightRotation, viewable };
5679
5917
 
5680
5918
  //# sourceMappingURL=index.js.map