@aznabee/freehand-ui 0.1.2 → 0.1.4

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.
@@ -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,
@@ -470,21 +484,14 @@ function isBroken(fraction, breaks) {
470
484
  function createBorderBreaks(perimeter, config, random) {
471
485
  if (!config || perimeter <= 0) return [];
472
486
  const settings = config === true ? {} : config;
473
- const count = Math.max(
474
- 1,
475
- Math.round(settings.count ?? 2 + random() * 3)
476
- );
487
+ const count = Math.max(1, Math.round(settings.count ?? 2 + random() * 3));
477
488
  const min = Math.max(2, settings.min ?? 6);
478
489
  const max = Math.max(min, settings.max ?? 30);
479
490
  const slice = perimeter / count;
480
491
  let budget = perimeter * 0.35;
481
492
  const breaks = [];
482
493
  for (let i = 0; i < count; i++) {
483
- const length = Math.min(
484
- min + random() * (max - min),
485
- slice * 0.7,
486
- budget
487
- );
494
+ const length = Math.min(min + random() * (max - min), slice * 0.7, budget);
488
495
  if (length < 2) break;
489
496
  budget -= length;
490
497
  const start = i * slice + random() * Math.max(0, slice - length);
@@ -584,14 +591,86 @@ function drawBorder(svg, rect, options, seed) {
584
591
  });
585
592
  }
586
593
  }
594
+ function cubicAt(p0, c1, c2, p3, t) {
595
+ const u = 1 - t;
596
+ const a = u * u * u;
597
+ const b = 3 * u * u * t;
598
+ const c = 3 * u * t * t;
599
+ const d = t * t * t;
600
+ return {
601
+ x: a * p0.x + b * c1.x + c * c2.x + d * p3.x,
602
+ y: a * p0.y + b * c1.y + c * c2.y + d * p3.y
603
+ };
604
+ }
605
+ function cubicTangentAt(p0, c1, c2, p3, t) {
606
+ const u = 1 - t;
607
+ const a = 3 * u * u;
608
+ const b = 6 * u * t;
609
+ const c = 3 * t * t;
610
+ return {
611
+ x: a * (c1.x - p0.x) + b * (c2.x - c1.x) + c * (p3.x - c2.x),
612
+ y: a * (c1.y - p0.y) + b * (c2.y - c1.y) + c * (p3.y - c2.y)
613
+ };
614
+ }
615
+ function loopedCurvePoints(start, c1, c2, end, radius, random) {
616
+ const at = (t) => cubicAt(start, c1, c2, end, t);
617
+ const tangentAt = (t) => normalize(cubicTangentAt(start, c1, c2, end, t));
618
+ const knot = 0.46 + random() * 0.12;
619
+ const points = [];
620
+ const lead = Math.max(2, Math.round(knot * 16));
621
+ for (let i = 0; i <= lead; i++) points.push(at(knot * i / lead));
622
+ const pivot = at(knot);
623
+ const heading = tangentAt(knot);
624
+ const normal = { x: -heading.y, y: heading.x };
625
+ const side = random() < 0.5 ? 1 : -1;
626
+ const centre = {
627
+ x: pivot.x + normal.x * radius * side,
628
+ y: pivot.y + normal.y * radius * side
629
+ };
630
+ const from = Math.atan2(pivot.y - centre.y, pivot.x - centre.x);
631
+ const turns = Math.PI * 2 * (0.97 + random() * 0.08);
632
+ const steps = 18;
633
+ for (let i = 1; i <= steps; i++) {
634
+ const progress = i / steps;
635
+ const angle = from + turns * progress * side;
636
+ const drift = radius * 0.55 * progress;
637
+ points.push({
638
+ x: centre.x + Math.cos(angle) * radius + heading.x * drift,
639
+ y: centre.y + Math.sin(angle) * radius + heading.y * drift
640
+ });
641
+ }
642
+ const tail = 14;
643
+ for (let i = 1; i <= tail; i++) {
644
+ points.push(at(knot + (1 - knot) * (i / tail)));
645
+ }
646
+ return points;
647
+ }
587
648
  function normalize(vector) {
588
649
  const length = Math.hypot(vector.x, vector.y);
589
650
  if (!length) return { x: 0, y: 0 };
590
651
  return { x: vector.x / length, y: vector.y / length };
591
652
  }
592
- function arrowStartFromNote(origin, target) {
653
+ var NOTE_CLEARANCE = 10;
654
+ var TIP_GAP = 11;
655
+ function arrowStartFromNote(origin, target, from) {
593
656
  const cx = origin.x + origin.width / 2;
594
657
  const cy = origin.y + origin.height / 2;
658
+ if (from && from !== "note") {
659
+ const point = pointFromPosition(from, origin);
660
+ const away = directionFromPosition(from);
661
+ if (!away.x && !away.y) {
662
+ const edge = pointOnBoxToward(origin, target);
663
+ const outward = normalize({ x: edge.x - cx, y: edge.y - cy });
664
+ return {
665
+ x: edge.x + outward.x * NOTE_CLEARANCE,
666
+ y: edge.y + outward.y * NOTE_CLEARANCE
667
+ };
668
+ }
669
+ return {
670
+ x: point.x + away.x * NOTE_CLEARANCE,
671
+ y: point.y + away.y * NOTE_CLEARANCE
672
+ };
673
+ }
595
674
  const dx = target.x - cx;
596
675
  const dy = target.y - cy;
597
676
  const hw = origin.width / 2 || 1e-3;
@@ -599,15 +678,16 @@ function arrowStartFromNote(origin, target) {
599
678
  const sx = Math.sign(dx) || 1;
600
679
  const sy = Math.sign(dy) || 1;
601
680
  if (Math.abs(dy) / hh >= Math.abs(dx) / hw) {
602
- return { x: cx - sx * hw * 0.72, y: cy + sy * (hh + 7) };
681
+ return { x: cx - sx * (hw + 10), y: cy + sy * (hh + 4) };
603
682
  }
604
- return { x: cx + sx * (hw + 7), y: cy + sy * hh * 0.2 };
683
+ return { x: cx + sx * (hw + 8), y: cy + sy * (hh + 6) };
605
684
  }
606
685
  function arrowEndpoints(rect, arrowOptions, random, origin) {
607
686
  const center = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
687
+ const from = arrowOptions.from || "note";
608
688
  const anchored = Boolean(origin && origin.width > 0 && origin.height > 0);
609
- let start = anchored ? arrowStartFromNote(origin, center) : annotationAnchor(
610
- arrowOptions.from || "top-right",
689
+ let start = anchored ? arrowStartFromNote(origin, center, from) : annotationAnchor(
690
+ from === "note" ? "top-right" : from,
611
691
  rect,
612
692
  38 + random() * 12
613
693
  );
@@ -619,7 +699,7 @@ function arrowEndpoints(rect, arrowOptions, random, origin) {
619
699
  const near = nearestPointOnRect(rect, start);
620
700
  let outward = normalize({ x: start.x - near.x, y: start.y - near.y });
621
701
  if (!outward.x && !outward.y) outward = { x: 0, y: -1 };
622
- const gap = 6 + random() * 3;
702
+ const gap = TIP_GAP + random() * 5;
623
703
  end = { x: near.x + outward.x * gap, y: near.y + outward.y * gap };
624
704
  } else {
625
705
  end = pointFromPosition(toPos, rect);
@@ -632,6 +712,35 @@ function arrowEndpoints(rect, arrowOptions, random, origin) {
632
712
  }
633
713
  return { start, end };
634
714
  }
715
+ function drawArrowHead(svg, tip, angle, style, random, shaftLength = Infinity) {
716
+ const length = Math.min(9 + style.strokeWidth * 1.4, shaftLength * 0.3);
717
+ const spread = 0.46 + random() * 0.1;
718
+ for (const side of [-1, 1]) {
719
+ const armAngle = angle + spread * side;
720
+ const tail = {
721
+ x: tip.x - length * Math.cos(armAngle),
722
+ y: tip.y - length * Math.sin(armAngle)
723
+ };
724
+ appendPath(
725
+ svg,
726
+ roughLine(tail.x, tail.y, tip.x, tip.y, style.roughness * 0.5, random, {
727
+ steps: 2,
728
+ bow: 0.35 * side
729
+ }),
730
+ { ...style, dashed: false }
731
+ );
732
+ }
733
+ }
734
+ function boundsOf(points) {
735
+ const xs = points.map((p) => p.x);
736
+ const ys = points.map((p) => p.y);
737
+ return {
738
+ x: Math.min(...xs),
739
+ y: Math.min(...ys),
740
+ width: Math.max(...xs) - Math.min(...xs),
741
+ height: Math.max(...ys) - Math.min(...ys)
742
+ };
743
+ }
635
744
  function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
636
745
  const random = createRandom(seed + 17);
637
746
  const { start, end } = arrowEndpoints(rect, arrowOptions, random, origin);
@@ -653,13 +762,33 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
653
762
  x: rect.x + rect.width / 2 - mid.x,
654
763
  y: rect.y + rect.height / 2 - mid.y
655
764
  };
656
- const sign = perpendicular.x * towardCenter.x + perpendicular.y * towardCenter.y > 0 ? -1 : 1;
765
+ const lateral = perpendicular.x * towardCenter.x + perpendicular.y * towardCenter.y;
766
+ const sign = Math.abs(lateral) > distance * 0.12 ? lateral > 0 ? -1 : 1 : random() < 0.5 ? -1 : 1;
657
767
  const bow = Math.min(distance * (0.16 + random() * 0.08), 30) * sign;
658
768
  const c1x = start.x + dx * 0.28 + perpendicular.x * bow;
659
769
  const c1y = start.y + dy * 0.28 + perpendicular.y * bow;
660
770
  const c2x = start.x + dx * 0.72 + perpendicular.x * bow * 0.85;
661
771
  const c2y = start.y + dy * 0.72 + perpendicular.y * bow * 0.85;
662
772
  hull.push({ x: c1x, y: c1y }, { x: c2x, y: c2y });
773
+ if (arrowOptions.style === "looped") {
774
+ const radius = clamp(distance * 0.11, 7, 17);
775
+ const points = loopedCurvePoints(
776
+ start,
777
+ { x: c1x, y: c1y },
778
+ { x: c2x, y: c2y },
779
+ end,
780
+ radius,
781
+ random
782
+ );
783
+ shaft = catmullRomPath(points, false);
784
+ hull.push(...points);
785
+ const last = points[points.length - 1];
786
+ const prior = points[points.length - 2] ?? start;
787
+ tipAngle = Math.atan2(last.y - prior.y, last.x - prior.x);
788
+ appendPath(svg, shaft, { ...style, dashed });
789
+ drawArrowHead(svg, end, tipAngle, style, random, distance);
790
+ return { start, end, bounds: boundsOf(hull) };
791
+ }
663
792
  shaft = `M ${fmt(start.x)} ${fmt(start.y)} C ${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(end.x)} ${fmt(end.y)}`;
664
793
  if (style.roughness >= 1.1) {
665
794
  const drift = () => (random() - 0.5) * 1.4;
@@ -674,31 +803,8 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
674
803
  tipAngle = Math.atan2(end.y - c2y, end.x - c2x);
675
804
  }
676
805
  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
- };
806
+ drawArrowHead(svg, end, tipAngle, style, random, distance);
807
+ const bounds = boundsOf(hull);
702
808
  return { start, end, bounds };
703
809
  }
704
810
  function drawUnderline(svg, x, y, width, style, random) {
@@ -736,11 +842,38 @@ function drawUnderline(svg, x, y, width, style, random) {
736
842
  }
737
843
  );
738
844
  }
739
- function createNoteText(svg, text, style) {
845
+ var DEFAULT_WORDS_PER_LINE = 2;
846
+ function wrapWords(text, perLine) {
847
+ const lines = [];
848
+ for (const paragraph of String(text).split(/\r?\n/)) {
849
+ const words = paragraph.trim().split(/\s+/).filter(Boolean);
850
+ if (!words.length) continue;
851
+ const size = Number.isFinite(perLine) && perLine >= 1 ? Math.round(perLine) : words.length;
852
+ for (let i = 0; i < words.length; i += size) {
853
+ lines.push(words.slice(i, i + size).join(" "));
854
+ }
855
+ }
856
+ return lines.length ? lines : [String(text)];
857
+ }
858
+ function measureWidth(element, content, fontSize) {
859
+ try {
860
+ const box = element.getBBox();
861
+ if (box && box.width > 0) return box.width;
862
+ } catch {
863
+ }
864
+ try {
865
+ const measured = element.getComputedTextLength();
866
+ if (Number.isFinite(measured) && measured > 0) return measured;
867
+ } catch {
868
+ }
869
+ return content.length * fontSize * 0.45;
870
+ }
871
+ function createNoteText(svg, text, style, options = {}) {
740
872
  const fontSize = style.fontSize ?? NOTE_FONT_SIZE;
873
+ const lines = wrapWords(text, options.wordsPerLine ?? DEFAULT_WORDS_PER_LINE);
874
+ const lineHeight = fontSize * 1.05;
741
875
  const group = document.createElementNS(SVG_NS, "g");
742
876
  const textEl = document.createElementNS(SVG_NS, "text");
743
- textEl.textContent = text;
744
877
  textEl.setAttribute("x", "0");
745
878
  textEl.setAttribute("y", "0");
746
879
  textEl.setAttribute("fill", style.color);
@@ -751,31 +884,43 @@ function createNoteText(svg, text, style) {
751
884
  textEl.setAttribute("letter-spacing", "0.4");
752
885
  textEl.setAttribute("text-anchor", "start");
753
886
  textEl.setAttribute("dominant-baseline", "auto");
887
+ const tspans = lines.map((line, index) => {
888
+ const tspan = document.createElementNS(SVG_NS, "tspan");
889
+ tspan.textContent = line;
890
+ tspan.setAttribute("x", "0");
891
+ tspan.setAttribute("dy", index === 0 ? "0" : String(lineHeight));
892
+ textEl.appendChild(tspan);
893
+ return tspan;
894
+ });
754
895
  group.appendChild(textEl);
755
896
  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;
897
+ const lineWidths = tspans.map(
898
+ (tspan, index) => measureWidth(tspan, lines[index], fontSize)
899
+ );
900
+ const width = Math.max(...lineWidths);
770
901
  const ascent = fontSize * 0.74;
771
902
  const descent = fontSize * 0.26;
772
- return { group, textEl, width, ascent, descent, fontSize };
903
+ const height = (lines.length - 1) * lineHeight + ascent + descent;
904
+ return {
905
+ group,
906
+ textEl,
907
+ tspans,
908
+ lines,
909
+ lineWidths,
910
+ lineHeight,
911
+ width,
912
+ height,
913
+ ascent,
914
+ descent,
915
+ fontSize
916
+ };
773
917
  }
774
918
  function placeNoteText(note, box, style, seed, options = {}) {
775
919
  const random = createRandom(seed + 55);
776
920
  const tilt = options.tilt ?? (random() - 0.5) * 7;
777
921
  note.textEl.setAttribute("x", fmt(box.x));
778
922
  note.textEl.setAttribute("y", fmt(box.y + note.ascent));
923
+ for (const tspan of note.tspans) tspan.setAttribute("x", fmt(box.x));
779
924
  const cx = box.x + box.width / 2;
780
925
  const cy = box.y + box.height / 2;
781
926
  note.group.setAttribute(
@@ -783,11 +928,12 @@ function placeNoteText(note, box, style, seed, options = {}) {
783
928
  `rotate(${tilt.toFixed(2)} ${fmt(cx)} ${fmt(cy)})`
784
929
  );
785
930
  if (options.underline !== false) {
931
+ const lastWidth = note.lineWidths[note.lineWidths.length - 1] ?? box.width;
786
932
  drawUnderline(
787
933
  note.group,
788
934
  box.x + 1,
789
935
  box.y + box.height + 2,
790
- box.width - 2,
936
+ lastWidth - 2,
791
937
  {
792
938
  color: style.color,
793
939
  strokeWidth: style.strokeWidth || 1.4,
@@ -819,25 +965,6 @@ function createOverlaySvg(left, top, width, height) {
819
965
 
820
966
  // src/annotations.js
821
967
  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
968
  function boxForPosition(rect, position, size, gap) {
842
969
  const direction = directionFromPosition(position);
843
970
  const guard = inflateRect(rect, gap);
@@ -856,75 +983,67 @@ function boxForPosition(rect, position, size, gap) {
856
983
  }
857
984
  return { x, y, width: size.width, height: size.height };
858
985
  }
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;
986
+ function limitAgainstElement(box, guard, dx) {
987
+ const level = box.y < guard.y + guard.height && box.y + box.height > guard.y;
988
+ if (!level) return dx;
989
+ if (dx < 0) {
990
+ const room2 = box.x - (guard.x + guard.width);
991
+ return room2 <= 0 ? 0 : -Math.min(-dx, room2);
888
992
  }
889
- return shifted;
993
+ const room = guard.x - (box.x + box.width);
994
+ return room <= 0 ? 0 : Math.min(dx, room);
995
+ }
996
+ function nudgeIntoBand(box, guard, band) {
997
+ if (!band || band.width <= 0) return box;
998
+ const overhangLeft = band.x - box.x;
999
+ const overhangRight = box.x + box.width - (band.x + band.width);
1000
+ let dx = 0;
1001
+ if (overhangLeft > 0) dx = overhangLeft;
1002
+ else if (overhangRight > 0) dx = -overhangRight;
1003
+ if (!dx) return box;
1004
+ dx = limitAgainstElement(box, guard, dx);
1005
+ if (!dx) return box;
1006
+ return { ...box, x: box.x + dx };
890
1007
  }
891
1008
  function renderAnnotation(svg, rect, noteOptions, style, seed = 1, layout = {}) {
892
1009
  if (!noteOptions?.text) return null;
893
- const viewport = layout.viewport ?? null;
1010
+ const band = layout.band ?? null;
894
1011
  const position = isValidPosition(noteOptions.position) ? noteOptions.position : "top-right";
895
1012
  const random = createRandom(seed + 9);
896
- const note = createNoteText(svg, noteOptions.text, style);
1013
+ const note = createNoteText(svg, noteOptions.text, style, {
1014
+ wordsPerLine: noteOptions.wordsPerLine
1015
+ });
897
1016
  const tilt = (random() - 0.45) * 7;
898
1017
  const tiltPad = Math.abs(Math.sin(tilt * Math.PI / 180)) * note.width * 0.5;
899
1018
  const size = {
900
1019
  width: note.width,
901
- height: note.ascent + note.descent + (noteOptions.underline === false ? 0 : 5)
1020
+ height: note.height + (noteOptions.underline === false ? 0 : 5)
902
1021
  };
903
1022
  const gap = (layout.gap ?? NOTE_GAP) + random() * 6;
904
- const placed = placeNote(
1023
+ const placed = boxForPosition(
905
1024
  rect,
906
1025
  position,
907
1026
  { width: size.width, height: size.height + tiltPad },
908
- gap,
909
- viewport
1027
+ gap
910
1028
  );
1029
+ const shifted = {
1030
+ ...placed,
1031
+ x: placed.x + (Number(noteOptions.offset?.x) || 0),
1032
+ y: placed.y + (Number(noteOptions.offset?.y) || 0)
1033
+ };
911
1034
  const guard = inflateRect(rect, gap * 0.7);
912
- const outer = clampToViewport(
913
- { ...placed.box, height: size.height + tiltPad },
914
- guard,
915
- viewport
916
- );
1035
+ const outer = nudgeIntoBand(shifted, guard, band);
917
1036
  const box = {
918
1037
  x: outer.x,
919
1038
  y: outer.y + tiltPad / 2,
920
1039
  width: size.width,
921
- height: note.ascent + note.descent
1040
+ height: note.height
922
1041
  };
923
1042
  placeNoteText(note, box, style, seed, {
924
1043
  tilt,
925
1044
  underline: noteOptions.underline !== false
926
1045
  });
927
- return { group: note.group, box: inflateRect(outer, 4), position: placed.position };
1046
+ return { group: note.group, box: inflateRect(outer, 4), position };
928
1047
  }
929
1048
 
930
1049
  // src/decorations.js
@@ -1342,14 +1461,11 @@ function ensureHandwrittenFont() {
1342
1461
 
1343
1462
  // src/doodle.js
1344
1463
  var instances = /* @__PURE__ */ new WeakMap();
1345
- function localViewport(overlayRect, inset = 8) {
1464
+ function localBand(overlayRect, inset = 8) {
1346
1465
  const width = window.innerWidth || document.documentElement.clientWidth || 0;
1347
- const height = window.innerHeight || document.documentElement.clientHeight || 0;
1348
1466
  return {
1349
1467
  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)
1468
+ width: Math.max(0, width - inset * 2)
1353
1469
  };
1354
1470
  }
1355
1471
  var DoodleOverlay = class {
@@ -1448,8 +1564,8 @@ var DoodleOverlay = class {
1448
1564
  };
1449
1565
  const noteGroups = [];
1450
1566
  const noteLayout = {
1451
- viewport: localViewport(overlayRect),
1452
- gap: this.options.arrow ? 32 : 14
1567
+ band: localBand(overlayRect),
1568
+ gap: this.options.arrow ? 40 : 14
1453
1569
  };
1454
1570
  targets.forEach((target, index) => {
1455
1571
  const absolute = absoluteRects[index];