@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.
package/dist/react.cjs CHANGED
@@ -63,9 +63,16 @@ function mergeOptions(options = {}) {
63
63
  if (merged.note && typeof merged.note === "string") {
64
64
  merged.note = { text: merged.note, position: "top-right" };
65
65
  }
66
- if (merged.arrow === true) {
67
- const from = merged.note && typeof merged.note === "object" && merged.note.position ? merged.note.position : "top-right";
68
- merged.arrow = { from, to: "edge", style: "curved" };
66
+ if (merged.arrow) {
67
+ const config = merged.arrow === true ? {} : merged.arrow;
68
+ merged.arrow = {
69
+ // "note" launches from the annotation, a position name from that side of
70
+ // the element. Normalised for every arrow, not just `arrow: true`, so an
71
+ // explicit `from` is honoured whether or not there is a note.
72
+ from: config.from ?? (merged.note ? "note" : "top-right"),
73
+ to: config.to ?? "edge",
74
+ style: config.style ?? "curved"
75
+ };
69
76
  }
70
77
  if (merged.decorations === true) {
71
78
  merged.decorations = { count: null, types: null };
@@ -210,10 +217,6 @@ function intersectionArea(a, b) {
210
217
  const h = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y);
211
218
  return w > 0 && h > 0 ? w * h : 0;
212
219
  }
213
- function outsideArea(box, bounds) {
214
- if (!bounds) return 0;
215
- return Math.max(0, box.width * box.height - intersectionArea(box, bounds));
216
- }
217
220
  function nearestPointOnRect(rect, point) {
218
221
  return {
219
222
  x: clamp(point.x, rect.x, rect.x + rect.width),
@@ -226,6 +229,17 @@ function distanceToRoundedRect(point, rect, radius = 0) {
226
229
  const cy = clamp(point.y, rect.y + r, rect.y + rect.height - r);
227
230
  return Math.hypot(point.x - cx, point.y - cy) - r;
228
231
  }
232
+ function pointOnBoxToward(box, target) {
233
+ const cx = box.x + box.width / 2;
234
+ const cy = box.y + box.height / 2;
235
+ const dx = target.x - cx;
236
+ const dy = target.y - cy;
237
+ if (dx === 0 && dy === 0) return { x: cx, y: cy };
238
+ const hw = box.width / 2 || 1e-3;
239
+ const hh = box.height / 2 || 1e-3;
240
+ const scale = 1 / Math.max(Math.abs(dx) / hw, Math.abs(dy) / hh);
241
+ return { x: cx + dx * scale, y: cy + dy * scale };
242
+ }
229
243
  function directionFromPosition(position) {
230
244
  return {
231
245
  x: position.includes("right") ? 1 : position.includes("left") ? -1 : 0,
@@ -475,21 +489,14 @@ function isBroken(fraction, breaks) {
475
489
  function createBorderBreaks(perimeter, config, random) {
476
490
  if (!config || perimeter <= 0) return [];
477
491
  const settings = config === true ? {} : config;
478
- const count = Math.max(
479
- 1,
480
- Math.round(settings.count ?? 2 + random() * 3)
481
- );
492
+ const count = Math.max(1, Math.round(settings.count ?? 2 + random() * 3));
482
493
  const min = Math.max(2, settings.min ?? 6);
483
494
  const max = Math.max(min, settings.max ?? 30);
484
495
  const slice = perimeter / count;
485
496
  let budget = perimeter * 0.35;
486
497
  const breaks = [];
487
498
  for (let i = 0; i < count; i++) {
488
- const length = Math.min(
489
- min + random() * (max - min),
490
- slice * 0.7,
491
- budget
492
- );
499
+ const length = Math.min(min + random() * (max - min), slice * 0.7, budget);
493
500
  if (length < 2) break;
494
501
  budget -= length;
495
502
  const start = i * slice + random() * Math.max(0, slice - length);
@@ -589,14 +596,86 @@ function drawBorder(svg, rect, options, seed) {
589
596
  });
590
597
  }
591
598
  }
599
+ function cubicAt(p0, c1, c2, p3, t) {
600
+ const u = 1 - t;
601
+ const a = u * u * u;
602
+ const b = 3 * u * u * t;
603
+ const c = 3 * u * t * t;
604
+ const d = t * t * t;
605
+ return {
606
+ x: a * p0.x + b * c1.x + c * c2.x + d * p3.x,
607
+ y: a * p0.y + b * c1.y + c * c2.y + d * p3.y
608
+ };
609
+ }
610
+ function cubicTangentAt(p0, c1, c2, p3, t) {
611
+ const u = 1 - t;
612
+ const a = 3 * u * u;
613
+ const b = 6 * u * t;
614
+ const c = 3 * t * t;
615
+ return {
616
+ x: a * (c1.x - p0.x) + b * (c2.x - c1.x) + c * (p3.x - c2.x),
617
+ y: a * (c1.y - p0.y) + b * (c2.y - c1.y) + c * (p3.y - c2.y)
618
+ };
619
+ }
620
+ function loopedCurvePoints(start, c1, c2, end, radius, random) {
621
+ const at = (t) => cubicAt(start, c1, c2, end, t);
622
+ const tangentAt = (t) => normalize(cubicTangentAt(start, c1, c2, end, t));
623
+ const knot = 0.46 + random() * 0.12;
624
+ const points = [];
625
+ const lead = Math.max(2, Math.round(knot * 16));
626
+ for (let i = 0; i <= lead; i++) points.push(at(knot * i / lead));
627
+ const pivot = at(knot);
628
+ const heading = tangentAt(knot);
629
+ const normal = { x: -heading.y, y: heading.x };
630
+ const side = random() < 0.5 ? 1 : -1;
631
+ const centre = {
632
+ x: pivot.x + normal.x * radius * side,
633
+ y: pivot.y + normal.y * radius * side
634
+ };
635
+ const from = Math.atan2(pivot.y - centre.y, pivot.x - centre.x);
636
+ const turns = Math.PI * 2 * (0.97 + random() * 0.08);
637
+ const steps = 18;
638
+ for (let i = 1; i <= steps; i++) {
639
+ const progress = i / steps;
640
+ const angle = from + turns * progress * side;
641
+ const drift = radius * 0.55 * progress;
642
+ points.push({
643
+ x: centre.x + Math.cos(angle) * radius + heading.x * drift,
644
+ y: centre.y + Math.sin(angle) * radius + heading.y * drift
645
+ });
646
+ }
647
+ const tail = 14;
648
+ for (let i = 1; i <= tail; i++) {
649
+ points.push(at(knot + (1 - knot) * (i / tail)));
650
+ }
651
+ return points;
652
+ }
592
653
  function normalize(vector) {
593
654
  const length = Math.hypot(vector.x, vector.y);
594
655
  if (!length) return { x: 0, y: 0 };
595
656
  return { x: vector.x / length, y: vector.y / length };
596
657
  }
597
- function arrowStartFromNote(origin, target) {
658
+ var NOTE_CLEARANCE = 10;
659
+ var TIP_GAP = 11;
660
+ function arrowStartFromNote(origin, target, from) {
598
661
  const cx = origin.x + origin.width / 2;
599
662
  const cy = origin.y + origin.height / 2;
663
+ if (from && from !== "note") {
664
+ const point = pointFromPosition(from, origin);
665
+ const away = directionFromPosition(from);
666
+ if (!away.x && !away.y) {
667
+ const edge = pointOnBoxToward(origin, target);
668
+ const outward = normalize({ x: edge.x - cx, y: edge.y - cy });
669
+ return {
670
+ x: edge.x + outward.x * NOTE_CLEARANCE,
671
+ y: edge.y + outward.y * NOTE_CLEARANCE
672
+ };
673
+ }
674
+ return {
675
+ x: point.x + away.x * NOTE_CLEARANCE,
676
+ y: point.y + away.y * NOTE_CLEARANCE
677
+ };
678
+ }
600
679
  const dx = target.x - cx;
601
680
  const dy = target.y - cy;
602
681
  const hw = origin.width / 2 || 1e-3;
@@ -604,15 +683,16 @@ function arrowStartFromNote(origin, target) {
604
683
  const sx = Math.sign(dx) || 1;
605
684
  const sy = Math.sign(dy) || 1;
606
685
  if (Math.abs(dy) / hh >= Math.abs(dx) / hw) {
607
- return { x: cx - sx * hw * 0.72, y: cy + sy * (hh + 7) };
686
+ return { x: cx - sx * (hw + 10), y: cy + sy * (hh + 4) };
608
687
  }
609
- return { x: cx + sx * (hw + 7), y: cy + sy * hh * 0.2 };
688
+ return { x: cx + sx * (hw + 8), y: cy + sy * (hh + 6) };
610
689
  }
611
690
  function arrowEndpoints(rect, arrowOptions, random, origin) {
612
691
  const center = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
692
+ const from = arrowOptions.from || "note";
613
693
  const anchored = Boolean(origin && origin.width > 0 && origin.height > 0);
614
- let start = anchored ? arrowStartFromNote(origin, center) : annotationAnchor(
615
- arrowOptions.from || "top-right",
694
+ let start = anchored ? arrowStartFromNote(origin, center, from) : annotationAnchor(
695
+ from === "note" ? "top-right" : from,
616
696
  rect,
617
697
  38 + random() * 12
618
698
  );
@@ -624,7 +704,7 @@ function arrowEndpoints(rect, arrowOptions, random, origin) {
624
704
  const near = nearestPointOnRect(rect, start);
625
705
  let outward = normalize({ x: start.x - near.x, y: start.y - near.y });
626
706
  if (!outward.x && !outward.y) outward = { x: 0, y: -1 };
627
- const gap = 6 + random() * 3;
707
+ const gap = TIP_GAP + random() * 5;
628
708
  end = { x: near.x + outward.x * gap, y: near.y + outward.y * gap };
629
709
  } else {
630
710
  end = pointFromPosition(toPos, rect);
@@ -637,6 +717,35 @@ function arrowEndpoints(rect, arrowOptions, random, origin) {
637
717
  }
638
718
  return { start, end };
639
719
  }
720
+ function drawArrowHead(svg, tip, angle, style, random, shaftLength = Infinity) {
721
+ const length = Math.min(9 + style.strokeWidth * 1.4, shaftLength * 0.3);
722
+ const spread = 0.46 + random() * 0.1;
723
+ for (const side of [-1, 1]) {
724
+ const armAngle = angle + spread * side;
725
+ const tail = {
726
+ x: tip.x - length * Math.cos(armAngle),
727
+ y: tip.y - length * Math.sin(armAngle)
728
+ };
729
+ appendPath(
730
+ svg,
731
+ roughLine(tail.x, tail.y, tip.x, tip.y, style.roughness * 0.5, random, {
732
+ steps: 2,
733
+ bow: 0.35 * side
734
+ }),
735
+ { ...style, dashed: false }
736
+ );
737
+ }
738
+ }
739
+ function boundsOf(points) {
740
+ const xs = points.map((p) => p.x);
741
+ const ys = points.map((p) => p.y);
742
+ return {
743
+ x: Math.min(...xs),
744
+ y: Math.min(...ys),
745
+ width: Math.max(...xs) - Math.min(...xs),
746
+ height: Math.max(...ys) - Math.min(...ys)
747
+ };
748
+ }
640
749
  function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
641
750
  const random = createRandom(seed + 17);
642
751
  const { start, end } = arrowEndpoints(rect, arrowOptions, random, origin);
@@ -658,13 +767,33 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
658
767
  x: rect.x + rect.width / 2 - mid.x,
659
768
  y: rect.y + rect.height / 2 - mid.y
660
769
  };
661
- const sign = perpendicular.x * towardCenter.x + perpendicular.y * towardCenter.y > 0 ? -1 : 1;
770
+ const lateral = perpendicular.x * towardCenter.x + perpendicular.y * towardCenter.y;
771
+ const sign = Math.abs(lateral) > distance * 0.12 ? lateral > 0 ? -1 : 1 : random() < 0.5 ? -1 : 1;
662
772
  const bow = Math.min(distance * (0.16 + random() * 0.08), 30) * sign;
663
773
  const c1x = start.x + dx * 0.28 + perpendicular.x * bow;
664
774
  const c1y = start.y + dy * 0.28 + perpendicular.y * bow;
665
775
  const c2x = start.x + dx * 0.72 + perpendicular.x * bow * 0.85;
666
776
  const c2y = start.y + dy * 0.72 + perpendicular.y * bow * 0.85;
667
777
  hull.push({ x: c1x, y: c1y }, { x: c2x, y: c2y });
778
+ if (arrowOptions.style === "looped") {
779
+ const radius = clamp(distance * 0.11, 7, 17);
780
+ const points = loopedCurvePoints(
781
+ start,
782
+ { x: c1x, y: c1y },
783
+ { x: c2x, y: c2y },
784
+ end,
785
+ radius,
786
+ random
787
+ );
788
+ shaft = catmullRomPath(points, false);
789
+ hull.push(...points);
790
+ const last = points[points.length - 1];
791
+ const prior = points[points.length - 2] ?? start;
792
+ tipAngle = Math.atan2(last.y - prior.y, last.x - prior.x);
793
+ appendPath(svg, shaft, { ...style, dashed });
794
+ drawArrowHead(svg, end, tipAngle, style, random, distance);
795
+ return { start, end, bounds: boundsOf(hull) };
796
+ }
668
797
  shaft = `M ${fmt(start.x)} ${fmt(start.y)} C ${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(end.x)} ${fmt(end.y)}`;
669
798
  if (style.roughness >= 1.1) {
670
799
  const drift = () => (random() - 0.5) * 1.4;
@@ -679,31 +808,8 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
679
808
  tipAngle = Math.atan2(end.y - c2y, end.x - c2x);
680
809
  }
681
810
  appendPath(svg, shaft, { ...style, dashed });
682
- const headLength = 9 + style.strokeWidth * 1.4;
683
- const spread = 0.46 + random() * 0.1;
684
- for (const side of [-1, 1]) {
685
- const angle = tipAngle + spread * side;
686
- const tail = {
687
- x: end.x - headLength * Math.cos(angle),
688
- y: end.y - headLength * Math.sin(angle)
689
- };
690
- appendPath(
691
- svg,
692
- roughLine(tail.x, tail.y, end.x, end.y, style.roughness * 0.5, random, {
693
- steps: 2,
694
- bow: 0.35 * side
695
- }),
696
- { ...style, dashed: false }
697
- );
698
- }
699
- const xs = hull.map((p) => p.x);
700
- const ys = hull.map((p) => p.y);
701
- const bounds = {
702
- x: Math.min(...xs),
703
- y: Math.min(...ys),
704
- width: Math.max(...xs) - Math.min(...xs),
705
- height: Math.max(...ys) - Math.min(...ys)
706
- };
811
+ drawArrowHead(svg, end, tipAngle, style, random, distance);
812
+ const bounds = boundsOf(hull);
707
813
  return { start, end, bounds };
708
814
  }
709
815
  function drawUnderline(svg, x, y, width, style, random) {
@@ -741,11 +847,38 @@ function drawUnderline(svg, x, y, width, style, random) {
741
847
  }
742
848
  );
743
849
  }
744
- function createNoteText(svg, text, style) {
850
+ var DEFAULT_WORDS_PER_LINE = 2;
851
+ function wrapWords(text, perLine) {
852
+ const lines = [];
853
+ for (const paragraph of String(text).split(/\r?\n/)) {
854
+ const words = paragraph.trim().split(/\s+/).filter(Boolean);
855
+ if (!words.length) continue;
856
+ const size = Number.isFinite(perLine) && perLine >= 1 ? Math.round(perLine) : words.length;
857
+ for (let i = 0; i < words.length; i += size) {
858
+ lines.push(words.slice(i, i + size).join(" "));
859
+ }
860
+ }
861
+ return lines.length ? lines : [String(text)];
862
+ }
863
+ function measureWidth(element, content, fontSize) {
864
+ try {
865
+ const box = element.getBBox();
866
+ if (box && box.width > 0) return box.width;
867
+ } catch {
868
+ }
869
+ try {
870
+ const measured = element.getComputedTextLength();
871
+ if (Number.isFinite(measured) && measured > 0) return measured;
872
+ } catch {
873
+ }
874
+ return content.length * fontSize * 0.45;
875
+ }
876
+ function createNoteText(svg, text, style, options = {}) {
745
877
  const fontSize = style.fontSize ?? NOTE_FONT_SIZE;
878
+ const lines = wrapWords(text, options.wordsPerLine ?? DEFAULT_WORDS_PER_LINE);
879
+ const lineHeight = fontSize * 1.05;
746
880
  const group = document.createElementNS(SVG_NS, "g");
747
881
  const textEl = document.createElementNS(SVG_NS, "text");
748
- textEl.textContent = text;
749
882
  textEl.setAttribute("x", "0");
750
883
  textEl.setAttribute("y", "0");
751
884
  textEl.setAttribute("fill", style.color);
@@ -756,31 +889,43 @@ function createNoteText(svg, text, style) {
756
889
  textEl.setAttribute("letter-spacing", "0.4");
757
890
  textEl.setAttribute("text-anchor", "start");
758
891
  textEl.setAttribute("dominant-baseline", "auto");
892
+ const tspans = lines.map((line, index) => {
893
+ const tspan = document.createElementNS(SVG_NS, "tspan");
894
+ tspan.textContent = line;
895
+ tspan.setAttribute("x", "0");
896
+ tspan.setAttribute("dy", index === 0 ? "0" : String(lineHeight));
897
+ textEl.appendChild(tspan);
898
+ return tspan;
899
+ });
759
900
  group.appendChild(textEl);
760
901
  svg.appendChild(group);
761
- let width = 0;
762
- try {
763
- const box = textEl.getBBox();
764
- if (box && box.width > 0) width = box.width;
765
- } catch {
766
- }
767
- if (!width) {
768
- try {
769
- const measured = textEl.getComputedTextLength();
770
- if (Number.isFinite(measured) && measured > 0) width = measured;
771
- } catch {
772
- }
773
- }
774
- if (!width) width = text.length * fontSize * 0.45;
902
+ const lineWidths = tspans.map(
903
+ (tspan, index) => measureWidth(tspan, lines[index], fontSize)
904
+ );
905
+ const width = Math.max(...lineWidths);
775
906
  const ascent = fontSize * 0.74;
776
907
  const descent = fontSize * 0.26;
777
- return { group, textEl, width, ascent, descent, fontSize };
908
+ const height = (lines.length - 1) * lineHeight + ascent + descent;
909
+ return {
910
+ group,
911
+ textEl,
912
+ tspans,
913
+ lines,
914
+ lineWidths,
915
+ lineHeight,
916
+ width,
917
+ height,
918
+ ascent,
919
+ descent,
920
+ fontSize
921
+ };
778
922
  }
779
923
  function placeNoteText(note, box, style, seed, options = {}) {
780
924
  const random = createRandom(seed + 55);
781
925
  const tilt = options.tilt ?? (random() - 0.5) * 7;
782
926
  note.textEl.setAttribute("x", fmt(box.x));
783
927
  note.textEl.setAttribute("y", fmt(box.y + note.ascent));
928
+ for (const tspan of note.tspans) tspan.setAttribute("x", fmt(box.x));
784
929
  const cx = box.x + box.width / 2;
785
930
  const cy = box.y + box.height / 2;
786
931
  note.group.setAttribute(
@@ -788,11 +933,12 @@ function placeNoteText(note, box, style, seed, options = {}) {
788
933
  `rotate(${tilt.toFixed(2)} ${fmt(cx)} ${fmt(cy)})`
789
934
  );
790
935
  if (options.underline !== false) {
936
+ const lastWidth = note.lineWidths[note.lineWidths.length - 1] ?? box.width;
791
937
  drawUnderline(
792
938
  note.group,
793
939
  box.x + 1,
794
940
  box.y + box.height + 2,
795
- box.width - 2,
941
+ lastWidth - 2,
796
942
  {
797
943
  color: style.color,
798
944
  strokeWidth: style.strokeWidth || 1.4,
@@ -824,25 +970,6 @@ function createOverlaySvg(left, top, width, height) {
824
970
 
825
971
  // src/annotations.js
826
972
  var NOTE_GAP = 14;
827
- function mirror(position, axis) {
828
- if (axis === "x") {
829
- if (position.includes("right")) return position.replace("right", "left");
830
- if (position.includes("left")) return position.replace("left", "right");
831
- return position;
832
- }
833
- if (position.includes("top")) return position.replace("top", "bottom");
834
- if (position.includes("bottom")) return position.replace("bottom", "top");
835
- return position;
836
- }
837
- function candidatePositions(position) {
838
- const candidates = [
839
- position,
840
- mirror(position, "x"),
841
- mirror(position, "y"),
842
- mirror(mirror(position, "x"), "y")
843
- ];
844
- return candidates.filter((value, index) => candidates.indexOf(value) === index);
845
- }
846
973
  function boxForPosition(rect, position, size, gap) {
847
974
  const direction = directionFromPosition(position);
848
975
  const guard = inflateRect(rect, gap);
@@ -861,75 +988,67 @@ function boxForPosition(rect, position, size, gap) {
861
988
  }
862
989
  return { x, y, width: size.width, height: size.height };
863
990
  }
864
- function placeNote(rect, position, size, gap, viewport) {
865
- const guard = inflateRect(rect, gap * 0.7);
866
- let best = null;
867
- candidatePositions(position).forEach((candidate, index) => {
868
- const box = boxForPosition(rect, candidate, size, gap);
869
- const penalty = intersectionArea(box, guard) * 6 + outsideArea(box, viewport) + index * 0.5;
870
- if (!best || penalty < best.penalty) {
871
- best = { box, penalty, position: candidate };
872
- }
873
- });
874
- return best;
875
- }
876
- function clampToViewport(box, guard, viewport) {
877
- if (!viewport || viewport.width <= 0 || viewport.height <= 0) return box;
878
- const shifted = {
879
- ...box,
880
- x: clamp(
881
- box.x,
882
- viewport.x,
883
- Math.max(viewport.x, viewport.x + viewport.width - box.width)
884
- ),
885
- y: clamp(
886
- box.y,
887
- viewport.y,
888
- Math.max(viewport.y, viewport.y + viewport.height - box.height)
889
- )
890
- };
891
- if (intersectionArea(shifted, guard) > intersectionArea(box, guard)) {
892
- return box;
991
+ function limitAgainstElement(box, guard, dx) {
992
+ const level = box.y < guard.y + guard.height && box.y + box.height > guard.y;
993
+ if (!level) return dx;
994
+ if (dx < 0) {
995
+ const room2 = box.x - (guard.x + guard.width);
996
+ return room2 <= 0 ? 0 : -Math.min(-dx, room2);
893
997
  }
894
- return shifted;
998
+ const room = guard.x - (box.x + box.width);
999
+ return room <= 0 ? 0 : Math.min(dx, room);
1000
+ }
1001
+ function nudgeIntoBand(box, guard, band) {
1002
+ if (!band || band.width <= 0) return box;
1003
+ const overhangLeft = band.x - box.x;
1004
+ const overhangRight = box.x + box.width - (band.x + band.width);
1005
+ let dx = 0;
1006
+ if (overhangLeft > 0) dx = overhangLeft;
1007
+ else if (overhangRight > 0) dx = -overhangRight;
1008
+ if (!dx) return box;
1009
+ dx = limitAgainstElement(box, guard, dx);
1010
+ if (!dx) return box;
1011
+ return { ...box, x: box.x + dx };
895
1012
  }
896
1013
  function renderAnnotation(svg, rect, noteOptions, style, seed = 1, layout = {}) {
897
1014
  if (!noteOptions?.text) return null;
898
- const viewport = layout.viewport ?? null;
1015
+ const band = layout.band ?? null;
899
1016
  const position = isValidPosition(noteOptions.position) ? noteOptions.position : "top-right";
900
1017
  const random = createRandom(seed + 9);
901
- const note = createNoteText(svg, noteOptions.text, style);
1018
+ const note = createNoteText(svg, noteOptions.text, style, {
1019
+ wordsPerLine: noteOptions.wordsPerLine
1020
+ });
902
1021
  const tilt = (random() - 0.45) * 7;
903
1022
  const tiltPad = Math.abs(Math.sin(tilt * Math.PI / 180)) * note.width * 0.5;
904
1023
  const size = {
905
1024
  width: note.width,
906
- height: note.ascent + note.descent + (noteOptions.underline === false ? 0 : 5)
1025
+ height: note.height + (noteOptions.underline === false ? 0 : 5)
907
1026
  };
908
1027
  const gap = (layout.gap ?? NOTE_GAP) + random() * 6;
909
- const placed = placeNote(
1028
+ const placed = boxForPosition(
910
1029
  rect,
911
1030
  position,
912
1031
  { width: size.width, height: size.height + tiltPad },
913
- gap,
914
- viewport
1032
+ gap
915
1033
  );
1034
+ const shifted = {
1035
+ ...placed,
1036
+ x: placed.x + (Number(noteOptions.offset?.x) || 0),
1037
+ y: placed.y + (Number(noteOptions.offset?.y) || 0)
1038
+ };
916
1039
  const guard = inflateRect(rect, gap * 0.7);
917
- const outer = clampToViewport(
918
- { ...placed.box, height: size.height + tiltPad },
919
- guard,
920
- viewport
921
- );
1040
+ const outer = nudgeIntoBand(shifted, guard, band);
922
1041
  const box = {
923
1042
  x: outer.x,
924
1043
  y: outer.y + tiltPad / 2,
925
1044
  width: size.width,
926
- height: note.ascent + note.descent
1045
+ height: note.height
927
1046
  };
928
1047
  placeNoteText(note, box, style, seed, {
929
1048
  tilt,
930
1049
  underline: noteOptions.underline !== false
931
1050
  });
932
- return { group: note.group, box: inflateRect(outer, 4), position: placed.position };
1051
+ return { group: note.group, box: inflateRect(outer, 4), position };
933
1052
  }
934
1053
 
935
1054
  // src/decorations.js
@@ -1347,14 +1466,11 @@ function ensureHandwrittenFont() {
1347
1466
 
1348
1467
  // src/doodle.js
1349
1468
  var instances = /* @__PURE__ */ new WeakMap();
1350
- function localViewport(overlayRect, inset = 8) {
1469
+ function localBand(overlayRect, inset = 8) {
1351
1470
  const width = window.innerWidth || document.documentElement.clientWidth || 0;
1352
- const height = window.innerHeight || document.documentElement.clientHeight || 0;
1353
1471
  return {
1354
1472
  x: -overlayRect.x + inset,
1355
- y: -overlayRect.y + inset,
1356
- width: Math.max(0, width - inset * 2),
1357
- height: Math.max(0, height - inset * 2)
1473
+ width: Math.max(0, width - inset * 2)
1358
1474
  };
1359
1475
  }
1360
1476
  var DoodleOverlay = class {
@@ -1453,8 +1569,8 @@ var DoodleOverlay = class {
1453
1569
  };
1454
1570
  const noteGroups = [];
1455
1571
  const noteLayout = {
1456
- viewport: localViewport(overlayRect),
1457
- gap: this.options.arrow ? 32 : 14
1572
+ band: localBand(overlayRect),
1573
+ gap: this.options.arrow ? 40 : 14
1458
1574
  };
1459
1575
  targets.forEach((target, index) => {
1460
1576
  const absolute = absoluteRects[index];
@@ -1672,7 +1788,7 @@ var Doodle = (0, import_react.forwardRef)(function Doodle2(props, forwardedRef)
1672
1788
  ref: mergeRefs(innerRef, forwardedRef),
1673
1789
  className,
1674
1790
  // inline-flex shrink-wraps the child so the doodle hugs the component
1675
- // rather than a full-width block. Flex specifically — inline-block would
1791
+ // rather than a full-width block. Flex specifically - inline-block would
1676
1792
  // add baseline leading under an inline-level child like a <button>,
1677
1793
  // leaving the frame taller than what it wraps. Override via `style`/`as`.
1678
1794
  style: { display: "inline-flex", ...style }
package/dist/react.d.ts CHANGED
@@ -24,7 +24,7 @@ export interface DoodleOwnProps extends DoodleOptionProps {
24
24
  children?: ReactNode;
25
25
  /** Wrapper element to render. Default `"span"`. */
26
26
  as?: ElementType;
27
- /** Skip drawing entirely — useful for reduced-motion or feature flags. */
27
+ /** Skip drawing entirely - useful for reduced-motion or feature flags. */
28
28
  disabled?: boolean;
29
29
  /**
30
30
  * Selector for decorating descendants instead of the wrapper as a whole.
@@ -52,7 +52,7 @@ export type DoodleProps<T extends ElementType = "span"> = DoodleOwnProps &
52
52
  * decorated individually unless you opt in with `childSelector`.
53
53
  */
54
54
  declare const Doodle: <T extends ElementType = "span">(
55
- props: DoodleProps<T>
55
+ props: DoodleProps<T>,
56
56
  ) => JSX.Element;
57
57
 
58
58
  export default Doodle;
@@ -62,5 +62,5 @@ export { Doodle };
62
62
  export function useDoodle(
63
63
  ref: RefObject<Element | null>,
64
64
  options?: DoodleOptions,
65
- enabled?: boolean
65
+ enabled?: boolean,
66
66
  ): void;