@aznabee/freehand-ui 0.1.2 → 0.1.3

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/README.md CHANGED
@@ -180,11 +180,34 @@ doodle(".cta", {
180
180
 
181
181
  Positions: `top`, `top-right`, `right`, `bottom-right`, `bottom`, `bottom-left`, `left`, `top-left`. Same names apply to `arrow.from`/`arrow.to`.
182
182
 
183
- The position is a preference, not a command. Notes are measured from their real
184
- glyph metrics and laid out so they never sit on top of the element if the
185
- preferred side would run off screen, the note flips to a side that fits. The
186
- underline is drawn to the measured text width; set `underline: false` for plain
187
- handwriting.
183
+ Notes are measured from their real glyph metrics and laid out so they never sit
184
+ on top of the element. The underline is drawn to the measured text width; set
185
+ `underline: false` for plain handwriting.
186
+
187
+ **The side you ask for is the side you get.** When a note runs past the edge of
188
+ the viewport it slides horizontally by exactly the amount it overhangs, so a
189
+ narrowing viewport walks it gradually inward instead of snapping it to the
190
+ opposite side. The only limit is the element itself: a note level with it stops
191
+ once they meet, rather than sliding across it.
192
+
193
+ Vertical placement is fixed relative to the element and does not react to
194
+ scrolling, so a note stays pinned to what it annotates instead of crawling back
195
+ into view as the page moves. A note beside an element that is itself jammed
196
+ against the viewport edge has nowhere to slide, so it may sit partly off screen
197
+ — use a different `position` or an `offset` there.
198
+
199
+ Nudge a note off its computed spot with `offset`:
200
+
201
+ ```javascript
202
+ doodle(".cta", {
203
+ note: { text: "start chat", position: "top", offset: { x: 40, y: -12 } },
204
+ });
205
+ ```
206
+
207
+ The offset moves the note from the spot `position` chose, and staying in view is
208
+ judged from there — so a large offset slides back into the viewport rather than
209
+ carrying the note off screen. Vertical offsets are always applied verbatim,
210
+ since vertical placement never reacts to the viewport.
188
211
 
189
212
  ## Arrows
190
213
 
@@ -193,17 +216,36 @@ doodle(".cta", { arrow: true });
193
216
 
194
217
  doodle(".cta", {
195
218
  arrow: {
196
- from: "top-right",
219
+ from: "top-right", // note | any position name
197
220
  to: "center",
198
- style: "curved", // curved | straight | dotted
221
+ style: "curved", // curved | straight | dotted | looped
199
222
  },
200
223
  });
201
224
  ```
202
225
 
203
- When the element also has a `note`, the arrow ignores `from` and launches from
204
- the note itself. `to: "edge"` (the default) lands the tip just *outside* the
205
- frame; `to: "center"` is the one setting that deliberately points into the
206
- element.
226
+ `looped` ties a curl into the middle of the sweep the hand-drawn flourish that
227
+ doubles back on itself before reaching the tip.
228
+
229
+ `from` decides where the arrow leaves. **With a note it names a side of the
230
+ note** — where the pen lifts off the handwriting — and the arrow then travels to
231
+ the element wherever that is:
232
+
233
+ ```javascript
234
+ doodle(".cta", {
235
+ note: { text: "start chat", position: "top-right" },
236
+ arrow: { from: "bottom-left" }, // leaves the note's bottom-left corner
237
+ });
238
+ ```
239
+
240
+ `"note"` is the default and picks the side of the note facing the element. A
241
+ callout always departs from its note, so moving the note with `offset` takes the
242
+ arrow with it rather than leaving it stranded by the element.
243
+
244
+ Without a note there is no handwriting to leave from, so `from` falls back to
245
+ naming a side of the element itself.
246
+
247
+ `to: "edge"` (the default) lands the tip just *outside* the frame; `to: "center"`
248
+ is the one setting that deliberately points into the element.
207
249
 
208
250
  ## Decorations
209
251
 
@@ -58,9 +58,16 @@ function mergeOptions(options = {}) {
58
58
  if (merged.note && typeof merged.note === "string") {
59
59
  merged.note = { text: merged.note, position: "top-right" };
60
60
  }
61
- if (merged.arrow === true) {
62
- const from = merged.note && typeof merged.note === "object" && merged.note.position ? merged.note.position : "top-right";
63
- merged.arrow = { from, to: "edge", style: "curved" };
61
+ if (merged.arrow) {
62
+ const config = merged.arrow === true ? {} : merged.arrow;
63
+ merged.arrow = {
64
+ // "note" launches from the annotation, a position name from that side of
65
+ // the element. Normalised for every arrow, not just `arrow: true`, so an
66
+ // explicit `from` is honoured whether or not there is a note.
67
+ from: config.from ?? (merged.note ? "note" : "top-right"),
68
+ to: config.to ?? "edge",
69
+ style: config.style ?? "curved"
70
+ };
64
71
  }
65
72
  if (merged.decorations === true) {
66
73
  merged.decorations = { count: null, types: null };
@@ -205,10 +212,6 @@ function intersectionArea(a, b) {
205
212
  const h = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y);
206
213
  return w > 0 && h > 0 ? w * h : 0;
207
214
  }
208
- function outsideArea(box, bounds) {
209
- if (!bounds) return 0;
210
- return Math.max(0, box.width * box.height - intersectionArea(box, bounds));
211
- }
212
215
  function nearestPointOnRect(rect, point) {
213
216
  return {
214
217
  x: clamp(point.x, rect.x, rect.x + rect.width),
@@ -221,6 +224,17 @@ function distanceToRoundedRect(point, rect, radius = 0) {
221
224
  const cy = clamp(point.y, rect.y + r, rect.y + rect.height - r);
222
225
  return Math.hypot(point.x - cx, point.y - cy) - r;
223
226
  }
227
+ function pointOnBoxToward(box, target) {
228
+ const cx = box.x + box.width / 2;
229
+ const cy = box.y + box.height / 2;
230
+ const dx = target.x - cx;
231
+ const dy = target.y - cy;
232
+ if (dx === 0 && dy === 0) return { x: cx, y: cy };
233
+ const hw = box.width / 2 || 1e-3;
234
+ const hh = box.height / 2 || 1e-3;
235
+ const scale = 1 / Math.max(Math.abs(dx) / hw, Math.abs(dy) / hh);
236
+ return { x: cx + dx * scale, y: cy + dy * scale };
237
+ }
224
238
  function directionFromPosition(position) {
225
239
  return {
226
240
  x: position.includes("right") ? 1 : position.includes("left") ? -1 : 0,
@@ -584,14 +598,86 @@ function drawBorder(svg, rect, options, seed) {
584
598
  });
585
599
  }
586
600
  }
601
+ function cubicAt(p0, c1, c2, p3, t) {
602
+ const u = 1 - t;
603
+ const a = u * u * u;
604
+ const b = 3 * u * u * t;
605
+ const c = 3 * u * t * t;
606
+ const d = t * t * t;
607
+ return {
608
+ x: a * p0.x + b * c1.x + c * c2.x + d * p3.x,
609
+ y: a * p0.y + b * c1.y + c * c2.y + d * p3.y
610
+ };
611
+ }
612
+ function cubicTangentAt(p0, c1, c2, p3, t) {
613
+ const u = 1 - t;
614
+ const a = 3 * u * u;
615
+ const b = 6 * u * t;
616
+ const c = 3 * t * t;
617
+ return {
618
+ x: a * (c1.x - p0.x) + b * (c2.x - c1.x) + c * (p3.x - c2.x),
619
+ y: a * (c1.y - p0.y) + b * (c2.y - c1.y) + c * (p3.y - c2.y)
620
+ };
621
+ }
622
+ function loopedCurvePoints(start, c1, c2, end, radius, random) {
623
+ const at = (t) => cubicAt(start, c1, c2, end, t);
624
+ const tangentAt = (t) => normalize(cubicTangentAt(start, c1, c2, end, t));
625
+ const knot = 0.46 + random() * 0.12;
626
+ const points = [];
627
+ const lead = Math.max(2, Math.round(knot * 16));
628
+ for (let i = 0; i <= lead; i++) points.push(at(knot * i / lead));
629
+ const pivot = at(knot);
630
+ const heading = tangentAt(knot);
631
+ const normal = { x: -heading.y, y: heading.x };
632
+ const side = random() < 0.5 ? 1 : -1;
633
+ const centre = {
634
+ x: pivot.x + normal.x * radius * side,
635
+ y: pivot.y + normal.y * radius * side
636
+ };
637
+ const from = Math.atan2(pivot.y - centre.y, pivot.x - centre.x);
638
+ const turns = Math.PI * 2 * (0.97 + random() * 0.08);
639
+ const steps = 18;
640
+ for (let i = 1; i <= steps; i++) {
641
+ const progress = i / steps;
642
+ const angle = from + turns * progress * side;
643
+ const drift = radius * 0.55 * progress;
644
+ points.push({
645
+ x: centre.x + Math.cos(angle) * radius + heading.x * drift,
646
+ y: centre.y + Math.sin(angle) * radius + heading.y * drift
647
+ });
648
+ }
649
+ const tail = 14;
650
+ for (let i = 1; i <= tail; i++) {
651
+ points.push(at(knot + (1 - knot) * (i / tail)));
652
+ }
653
+ return points;
654
+ }
587
655
  function normalize(vector) {
588
656
  const length = Math.hypot(vector.x, vector.y);
589
657
  if (!length) return { x: 0, y: 0 };
590
658
  return { x: vector.x / length, y: vector.y / length };
591
659
  }
592
- function arrowStartFromNote(origin, target) {
660
+ var NOTE_CLEARANCE = 10;
661
+ var TIP_GAP = 11;
662
+ function arrowStartFromNote(origin, target, from) {
593
663
  const cx = origin.x + origin.width / 2;
594
664
  const cy = origin.y + origin.height / 2;
665
+ if (from && from !== "note") {
666
+ const point = pointFromPosition(from, origin);
667
+ const away = directionFromPosition(from);
668
+ if (!away.x && !away.y) {
669
+ const edge = pointOnBoxToward(origin, target);
670
+ const outward = normalize({ x: edge.x - cx, y: edge.y - cy });
671
+ return {
672
+ x: edge.x + outward.x * NOTE_CLEARANCE,
673
+ y: edge.y + outward.y * NOTE_CLEARANCE
674
+ };
675
+ }
676
+ return {
677
+ x: point.x + away.x * NOTE_CLEARANCE,
678
+ y: point.y + away.y * NOTE_CLEARANCE
679
+ };
680
+ }
595
681
  const dx = target.x - cx;
596
682
  const dy = target.y - cy;
597
683
  const hw = origin.width / 2 || 1e-3;
@@ -599,15 +685,16 @@ function arrowStartFromNote(origin, target) {
599
685
  const sx = Math.sign(dx) || 1;
600
686
  const sy = Math.sign(dy) || 1;
601
687
  if (Math.abs(dy) / hh >= Math.abs(dx) / hw) {
602
- return { x: cx - sx * hw * 0.72, y: cy + sy * (hh + 7) };
688
+ return { x: cx - sx * (hw + 10), y: cy + sy * (hh + 4) };
603
689
  }
604
- return { x: cx + sx * (hw + 7), y: cy + sy * hh * 0.2 };
690
+ return { x: cx + sx * (hw + 8), y: cy + sy * (hh + 6) };
605
691
  }
606
692
  function arrowEndpoints(rect, arrowOptions, random, origin) {
607
693
  const center = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
694
+ const from = arrowOptions.from || "note";
608
695
  const anchored = Boolean(origin && origin.width > 0 && origin.height > 0);
609
- let start = anchored ? arrowStartFromNote(origin, center) : annotationAnchor(
610
- arrowOptions.from || "top-right",
696
+ let start = anchored ? arrowStartFromNote(origin, center, from) : annotationAnchor(
697
+ from === "note" ? "top-right" : from,
611
698
  rect,
612
699
  38 + random() * 12
613
700
  );
@@ -619,7 +706,7 @@ function arrowEndpoints(rect, arrowOptions, random, origin) {
619
706
  const near = nearestPointOnRect(rect, start);
620
707
  let outward = normalize({ x: start.x - near.x, y: start.y - near.y });
621
708
  if (!outward.x && !outward.y) outward = { x: 0, y: -1 };
622
- const gap = 6 + random() * 3;
709
+ const gap = TIP_GAP + random() * 5;
623
710
  end = { x: near.x + outward.x * gap, y: near.y + outward.y * gap };
624
711
  } else {
625
712
  end = pointFromPosition(toPos, rect);
@@ -632,6 +719,35 @@ function arrowEndpoints(rect, arrowOptions, random, origin) {
632
719
  }
633
720
  return { start, end };
634
721
  }
722
+ function drawArrowHead(svg, tip, angle, style, random, shaftLength = Infinity) {
723
+ const length = Math.min(9 + style.strokeWidth * 1.4, shaftLength * 0.3);
724
+ const spread = 0.46 + random() * 0.1;
725
+ for (const side of [-1, 1]) {
726
+ const armAngle = angle + spread * side;
727
+ const tail = {
728
+ x: tip.x - length * Math.cos(armAngle),
729
+ y: tip.y - length * Math.sin(armAngle)
730
+ };
731
+ appendPath(
732
+ svg,
733
+ roughLine(tail.x, tail.y, tip.x, tip.y, style.roughness * 0.5, random, {
734
+ steps: 2,
735
+ bow: 0.35 * side
736
+ }),
737
+ { ...style, dashed: false }
738
+ );
739
+ }
740
+ }
741
+ function boundsOf(points) {
742
+ const xs = points.map((p) => p.x);
743
+ const ys = points.map((p) => p.y);
744
+ return {
745
+ x: Math.min(...xs),
746
+ y: Math.min(...ys),
747
+ width: Math.max(...xs) - Math.min(...xs),
748
+ height: Math.max(...ys) - Math.min(...ys)
749
+ };
750
+ }
635
751
  function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
636
752
  const random = createRandom(seed + 17);
637
753
  const { start, end } = arrowEndpoints(rect, arrowOptions, random, origin);
@@ -653,13 +769,33 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
653
769
  x: rect.x + rect.width / 2 - mid.x,
654
770
  y: rect.y + rect.height / 2 - mid.y
655
771
  };
656
- const sign = perpendicular.x * towardCenter.x + perpendicular.y * towardCenter.y > 0 ? -1 : 1;
772
+ const lateral = perpendicular.x * towardCenter.x + perpendicular.y * towardCenter.y;
773
+ const sign = Math.abs(lateral) > distance * 0.12 ? lateral > 0 ? -1 : 1 : random() < 0.5 ? -1 : 1;
657
774
  const bow = Math.min(distance * (0.16 + random() * 0.08), 30) * sign;
658
775
  const c1x = start.x + dx * 0.28 + perpendicular.x * bow;
659
776
  const c1y = start.y + dy * 0.28 + perpendicular.y * bow;
660
777
  const c2x = start.x + dx * 0.72 + perpendicular.x * bow * 0.85;
661
778
  const c2y = start.y + dy * 0.72 + perpendicular.y * bow * 0.85;
662
779
  hull.push({ x: c1x, y: c1y }, { x: c2x, y: c2y });
780
+ if (arrowOptions.style === "looped") {
781
+ const radius = clamp(distance * 0.11, 7, 17);
782
+ const points = loopedCurvePoints(
783
+ start,
784
+ { x: c1x, y: c1y },
785
+ { x: c2x, y: c2y },
786
+ end,
787
+ radius,
788
+ random
789
+ );
790
+ shaft = catmullRomPath(points, false);
791
+ hull.push(...points);
792
+ const last = points[points.length - 1];
793
+ const prior = points[points.length - 2] ?? start;
794
+ tipAngle = Math.atan2(last.y - prior.y, last.x - prior.x);
795
+ appendPath(svg, shaft, { ...style, dashed });
796
+ drawArrowHead(svg, end, tipAngle, style, random, distance);
797
+ return { start, end, bounds: boundsOf(hull) };
798
+ }
663
799
  shaft = `M ${fmt(start.x)} ${fmt(start.y)} C ${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(end.x)} ${fmt(end.y)}`;
664
800
  if (style.roughness >= 1.1) {
665
801
  const drift = () => (random() - 0.5) * 1.4;
@@ -674,31 +810,8 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
674
810
  tipAngle = Math.atan2(end.y - c2y, end.x - c2x);
675
811
  }
676
812
  appendPath(svg, shaft, { ...style, dashed });
677
- const headLength = 9 + style.strokeWidth * 1.4;
678
- const spread = 0.46 + random() * 0.1;
679
- for (const side of [-1, 1]) {
680
- const angle = tipAngle + spread * side;
681
- const tail = {
682
- x: end.x - headLength * Math.cos(angle),
683
- y: end.y - headLength * Math.sin(angle)
684
- };
685
- appendPath(
686
- svg,
687
- roughLine(tail.x, tail.y, end.x, end.y, style.roughness * 0.5, random, {
688
- steps: 2,
689
- bow: 0.35 * side
690
- }),
691
- { ...style, dashed: false }
692
- );
693
- }
694
- const xs = hull.map((p) => p.x);
695
- const ys = hull.map((p) => p.y);
696
- const bounds = {
697
- x: Math.min(...xs),
698
- y: Math.min(...ys),
699
- width: Math.max(...xs) - Math.min(...xs),
700
- height: Math.max(...ys) - Math.min(...ys)
701
- };
813
+ drawArrowHead(svg, end, tipAngle, style, random, distance);
814
+ const bounds = boundsOf(hull);
702
815
  return { start, end, bounds };
703
816
  }
704
817
  function drawUnderline(svg, x, y, width, style, random) {
@@ -736,11 +849,38 @@ function drawUnderline(svg, x, y, width, style, random) {
736
849
  }
737
850
  );
738
851
  }
739
- function createNoteText(svg, text, style) {
852
+ var DEFAULT_WORDS_PER_LINE = 2;
853
+ function wrapWords(text, perLine) {
854
+ const lines = [];
855
+ for (const paragraph of String(text).split(/\r?\n/)) {
856
+ const words = paragraph.trim().split(/\s+/).filter(Boolean);
857
+ if (!words.length) continue;
858
+ const size = Number.isFinite(perLine) && perLine >= 1 ? Math.round(perLine) : words.length;
859
+ for (let i = 0; i < words.length; i += size) {
860
+ lines.push(words.slice(i, i + size).join(" "));
861
+ }
862
+ }
863
+ return lines.length ? lines : [String(text)];
864
+ }
865
+ function measureWidth(element, content, fontSize) {
866
+ try {
867
+ const box = element.getBBox();
868
+ if (box && box.width > 0) return box.width;
869
+ } catch {
870
+ }
871
+ try {
872
+ const measured = element.getComputedTextLength();
873
+ if (Number.isFinite(measured) && measured > 0) return measured;
874
+ } catch {
875
+ }
876
+ return content.length * fontSize * 0.45;
877
+ }
878
+ function createNoteText(svg, text, style, options = {}) {
740
879
  const fontSize = style.fontSize ?? NOTE_FONT_SIZE;
880
+ const lines = wrapWords(text, options.wordsPerLine ?? DEFAULT_WORDS_PER_LINE);
881
+ const lineHeight = fontSize * 1.05;
741
882
  const group = document.createElementNS(SVG_NS, "g");
742
883
  const textEl = document.createElementNS(SVG_NS, "text");
743
- textEl.textContent = text;
744
884
  textEl.setAttribute("x", "0");
745
885
  textEl.setAttribute("y", "0");
746
886
  textEl.setAttribute("fill", style.color);
@@ -751,31 +891,43 @@ function createNoteText(svg, text, style) {
751
891
  textEl.setAttribute("letter-spacing", "0.4");
752
892
  textEl.setAttribute("text-anchor", "start");
753
893
  textEl.setAttribute("dominant-baseline", "auto");
894
+ const tspans = lines.map((line, index) => {
895
+ const tspan = document.createElementNS(SVG_NS, "tspan");
896
+ tspan.textContent = line;
897
+ tspan.setAttribute("x", "0");
898
+ tspan.setAttribute("dy", index === 0 ? "0" : String(lineHeight));
899
+ textEl.appendChild(tspan);
900
+ return tspan;
901
+ });
754
902
  group.appendChild(textEl);
755
903
  svg.appendChild(group);
756
- let width = 0;
757
- try {
758
- const box = textEl.getBBox();
759
- if (box && box.width > 0) width = box.width;
760
- } catch {
761
- }
762
- if (!width) {
763
- try {
764
- const measured = textEl.getComputedTextLength();
765
- if (Number.isFinite(measured) && measured > 0) width = measured;
766
- } catch {
767
- }
768
- }
769
- if (!width) width = text.length * fontSize * 0.45;
904
+ const lineWidths = tspans.map(
905
+ (tspan, index) => measureWidth(tspan, lines[index], fontSize)
906
+ );
907
+ const width = Math.max(...lineWidths);
770
908
  const ascent = fontSize * 0.74;
771
909
  const descent = fontSize * 0.26;
772
- return { group, textEl, width, ascent, descent, fontSize };
910
+ const height = (lines.length - 1) * lineHeight + ascent + descent;
911
+ return {
912
+ group,
913
+ textEl,
914
+ tspans,
915
+ lines,
916
+ lineWidths,
917
+ lineHeight,
918
+ width,
919
+ height,
920
+ ascent,
921
+ descent,
922
+ fontSize
923
+ };
773
924
  }
774
925
  function placeNoteText(note, box, style, seed, options = {}) {
775
926
  const random = createRandom(seed + 55);
776
927
  const tilt = options.tilt ?? (random() - 0.5) * 7;
777
928
  note.textEl.setAttribute("x", fmt(box.x));
778
929
  note.textEl.setAttribute("y", fmt(box.y + note.ascent));
930
+ for (const tspan of note.tspans) tspan.setAttribute("x", fmt(box.x));
779
931
  const cx = box.x + box.width / 2;
780
932
  const cy = box.y + box.height / 2;
781
933
  note.group.setAttribute(
@@ -783,11 +935,12 @@ function placeNoteText(note, box, style, seed, options = {}) {
783
935
  `rotate(${tilt.toFixed(2)} ${fmt(cx)} ${fmt(cy)})`
784
936
  );
785
937
  if (options.underline !== false) {
938
+ const lastWidth = note.lineWidths[note.lineWidths.length - 1] ?? box.width;
786
939
  drawUnderline(
787
940
  note.group,
788
941
  box.x + 1,
789
942
  box.y + box.height + 2,
790
- box.width - 2,
943
+ lastWidth - 2,
791
944
  {
792
945
  color: style.color,
793
946
  strokeWidth: style.strokeWidth || 1.4,
@@ -819,25 +972,6 @@ function createOverlaySvg(left, top, width, height) {
819
972
 
820
973
  // src/annotations.js
821
974
  var NOTE_GAP = 14;
822
- function mirror(position, axis) {
823
- if (axis === "x") {
824
- if (position.includes("right")) return position.replace("right", "left");
825
- if (position.includes("left")) return position.replace("left", "right");
826
- return position;
827
- }
828
- if (position.includes("top")) return position.replace("top", "bottom");
829
- if (position.includes("bottom")) return position.replace("bottom", "top");
830
- return position;
831
- }
832
- function candidatePositions(position) {
833
- const candidates = [
834
- position,
835
- mirror(position, "x"),
836
- mirror(position, "y"),
837
- mirror(mirror(position, "x"), "y")
838
- ];
839
- return candidates.filter((value, index) => candidates.indexOf(value) === index);
840
- }
841
975
  function boxForPosition(rect, position, size, gap) {
842
976
  const direction = directionFromPosition(position);
843
977
  const guard = inflateRect(rect, gap);
@@ -856,75 +990,67 @@ function boxForPosition(rect, position, size, gap) {
856
990
  }
857
991
  return { x, y, width: size.width, height: size.height };
858
992
  }
859
- function placeNote(rect, position, size, gap, viewport) {
860
- const guard = inflateRect(rect, gap * 0.7);
861
- let best = null;
862
- candidatePositions(position).forEach((candidate, index) => {
863
- const box = boxForPosition(rect, candidate, size, gap);
864
- const penalty = intersectionArea(box, guard) * 6 + outsideArea(box, viewport) + index * 0.5;
865
- if (!best || penalty < best.penalty) {
866
- best = { box, penalty, position: candidate };
867
- }
868
- });
869
- return best;
870
- }
871
- function clampToViewport(box, guard, viewport) {
872
- if (!viewport || viewport.width <= 0 || viewport.height <= 0) return box;
873
- const shifted = {
874
- ...box,
875
- x: clamp(
876
- box.x,
877
- viewport.x,
878
- Math.max(viewport.x, viewport.x + viewport.width - box.width)
879
- ),
880
- y: clamp(
881
- box.y,
882
- viewport.y,
883
- Math.max(viewport.y, viewport.y + viewport.height - box.height)
884
- )
885
- };
886
- if (intersectionArea(shifted, guard) > intersectionArea(box, guard)) {
887
- return box;
993
+ function limitAgainstElement(box, guard, dx) {
994
+ const level = box.y < guard.y + guard.height && box.y + box.height > guard.y;
995
+ if (!level) return dx;
996
+ if (dx < 0) {
997
+ const room2 = box.x - (guard.x + guard.width);
998
+ return room2 <= 0 ? 0 : -Math.min(-dx, room2);
888
999
  }
889
- return shifted;
1000
+ const room = guard.x - (box.x + box.width);
1001
+ return room <= 0 ? 0 : Math.min(dx, room);
1002
+ }
1003
+ function nudgeIntoBand(box, guard, band) {
1004
+ if (!band || band.width <= 0) return box;
1005
+ const overhangLeft = band.x - box.x;
1006
+ const overhangRight = box.x + box.width - (band.x + band.width);
1007
+ let dx = 0;
1008
+ if (overhangLeft > 0) dx = overhangLeft;
1009
+ else if (overhangRight > 0) dx = -overhangRight;
1010
+ if (!dx) return box;
1011
+ dx = limitAgainstElement(box, guard, dx);
1012
+ if (!dx) return box;
1013
+ return { ...box, x: box.x + dx };
890
1014
  }
891
1015
  function renderAnnotation(svg, rect, noteOptions, style, seed = 1, layout = {}) {
892
1016
  if (!noteOptions?.text) return null;
893
- const viewport = layout.viewport ?? null;
1017
+ const band = layout.band ?? null;
894
1018
  const position = isValidPosition(noteOptions.position) ? noteOptions.position : "top-right";
895
1019
  const random = createRandom(seed + 9);
896
- const note = createNoteText(svg, noteOptions.text, style);
1020
+ const note = createNoteText(svg, noteOptions.text, style, {
1021
+ wordsPerLine: noteOptions.wordsPerLine
1022
+ });
897
1023
  const tilt = (random() - 0.45) * 7;
898
1024
  const tiltPad = Math.abs(Math.sin(tilt * Math.PI / 180)) * note.width * 0.5;
899
1025
  const size = {
900
1026
  width: note.width,
901
- height: note.ascent + note.descent + (noteOptions.underline === false ? 0 : 5)
1027
+ height: note.height + (noteOptions.underline === false ? 0 : 5)
902
1028
  };
903
1029
  const gap = (layout.gap ?? NOTE_GAP) + random() * 6;
904
- const placed = placeNote(
1030
+ const placed = boxForPosition(
905
1031
  rect,
906
1032
  position,
907
1033
  { width: size.width, height: size.height + tiltPad },
908
- gap,
909
- viewport
1034
+ gap
910
1035
  );
1036
+ const shifted = {
1037
+ ...placed,
1038
+ x: placed.x + (Number(noteOptions.offset?.x) || 0),
1039
+ y: placed.y + (Number(noteOptions.offset?.y) || 0)
1040
+ };
911
1041
  const guard = inflateRect(rect, gap * 0.7);
912
- const outer = clampToViewport(
913
- { ...placed.box, height: size.height + tiltPad },
914
- guard,
915
- viewport
916
- );
1042
+ const outer = nudgeIntoBand(shifted, guard, band);
917
1043
  const box = {
918
1044
  x: outer.x,
919
1045
  y: outer.y + tiltPad / 2,
920
1046
  width: size.width,
921
- height: note.ascent + note.descent
1047
+ height: note.height
922
1048
  };
923
1049
  placeNoteText(note, box, style, seed, {
924
1050
  tilt,
925
1051
  underline: noteOptions.underline !== false
926
1052
  });
927
- return { group: note.group, box: inflateRect(outer, 4), position: placed.position };
1053
+ return { group: note.group, box: inflateRect(outer, 4), position };
928
1054
  }
929
1055
 
930
1056
  // src/decorations.js
@@ -1342,14 +1468,11 @@ function ensureHandwrittenFont() {
1342
1468
 
1343
1469
  // src/doodle.js
1344
1470
  var instances = /* @__PURE__ */ new WeakMap();
1345
- function localViewport(overlayRect, inset = 8) {
1471
+ function localBand(overlayRect, inset = 8) {
1346
1472
  const width = window.innerWidth || document.documentElement.clientWidth || 0;
1347
- const height = window.innerHeight || document.documentElement.clientHeight || 0;
1348
1473
  return {
1349
1474
  x: -overlayRect.x + inset,
1350
- y: -overlayRect.y + inset,
1351
- width: Math.max(0, width - inset * 2),
1352
- height: Math.max(0, height - inset * 2)
1475
+ width: Math.max(0, width - inset * 2)
1353
1476
  };
1354
1477
  }
1355
1478
  var DoodleOverlay = class {
@@ -1448,8 +1571,8 @@ var DoodleOverlay = class {
1448
1571
  };
1449
1572
  const noteGroups = [];
1450
1573
  const noteLayout = {
1451
- viewport: localViewport(overlayRect),
1452
- gap: this.options.arrow ? 32 : 14
1574
+ band: localBand(overlayRect),
1575
+ gap: this.options.arrow ? 40 : 14
1453
1576
  };
1454
1577
  targets.forEach((target, index) => {
1455
1578
  const absolute = absoluteRects[index];