@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.
@@ -33,9 +33,16 @@ function mergeOptions(options = {}) {
33
33
  if (merged.note && typeof merged.note === "string") {
34
34
  merged.note = { text: merged.note, position: "top-right" };
35
35
  }
36
- if (merged.arrow === true) {
37
- const from = merged.note && typeof merged.note === "object" && merged.note.position ? merged.note.position : "top-right";
38
- merged.arrow = { from, to: "edge", style: "curved" };
36
+ if (merged.arrow) {
37
+ const config = merged.arrow === true ? {} : merged.arrow;
38
+ merged.arrow = {
39
+ // "note" launches from the annotation, a position name from that side of
40
+ // the element. Normalised for every arrow, not just `arrow: true`, so an
41
+ // explicit `from` is honoured whether or not there is a note.
42
+ from: config.from ?? (merged.note ? "note" : "top-right"),
43
+ to: config.to ?? "edge",
44
+ style: config.style ?? "curved"
45
+ };
39
46
  }
40
47
  if (merged.decorations === true) {
41
48
  merged.decorations = { count: null, types: null };
@@ -180,10 +187,6 @@ function intersectionArea(a, b) {
180
187
  const h = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y);
181
188
  return w > 0 && h > 0 ? w * h : 0;
182
189
  }
183
- function outsideArea(box, bounds) {
184
- if (!bounds) return 0;
185
- return Math.max(0, box.width * box.height - intersectionArea(box, bounds));
186
- }
187
190
  function nearestPointOnRect(rect, point) {
188
191
  return {
189
192
  x: clamp(point.x, rect.x, rect.x + rect.width),
@@ -196,6 +199,17 @@ function distanceToRoundedRect(point, rect, radius = 0) {
196
199
  const cy = clamp(point.y, rect.y + r, rect.y + rect.height - r);
197
200
  return Math.hypot(point.x - cx, point.y - cy) - r;
198
201
  }
202
+ function pointOnBoxToward(box, target) {
203
+ const cx = box.x + box.width / 2;
204
+ const cy = box.y + box.height / 2;
205
+ const dx = target.x - cx;
206
+ const dy = target.y - cy;
207
+ if (dx === 0 && dy === 0) return { x: cx, y: cy };
208
+ const hw = box.width / 2 || 1e-3;
209
+ const hh = box.height / 2 || 1e-3;
210
+ const scale = 1 / Math.max(Math.abs(dx) / hw, Math.abs(dy) / hh);
211
+ return { x: cx + dx * scale, y: cy + dy * scale };
212
+ }
199
213
  function directionFromPosition(position) {
200
214
  return {
201
215
  x: position.includes("right") ? 1 : position.includes("left") ? -1 : 0,
@@ -445,21 +459,14 @@ function isBroken(fraction, breaks) {
445
459
  function createBorderBreaks(perimeter, config, random) {
446
460
  if (!config || perimeter <= 0) return [];
447
461
  const settings = config === true ? {} : config;
448
- const count = Math.max(
449
- 1,
450
- Math.round(settings.count ?? 2 + random() * 3)
451
- );
462
+ const count = Math.max(1, Math.round(settings.count ?? 2 + random() * 3));
452
463
  const min = Math.max(2, settings.min ?? 6);
453
464
  const max = Math.max(min, settings.max ?? 30);
454
465
  const slice = perimeter / count;
455
466
  let budget = perimeter * 0.35;
456
467
  const breaks = [];
457
468
  for (let i = 0; i < count; i++) {
458
- const length = Math.min(
459
- min + random() * (max - min),
460
- slice * 0.7,
461
- budget
462
- );
469
+ const length = Math.min(min + random() * (max - min), slice * 0.7, budget);
463
470
  if (length < 2) break;
464
471
  budget -= length;
465
472
  const start = i * slice + random() * Math.max(0, slice - length);
@@ -559,14 +566,86 @@ function drawBorder(svg, rect, options, seed) {
559
566
  });
560
567
  }
561
568
  }
569
+ function cubicAt(p0, c1, c2, p3, t) {
570
+ const u = 1 - t;
571
+ const a = u * u * u;
572
+ const b = 3 * u * u * t;
573
+ const c = 3 * u * t * t;
574
+ const d = t * t * t;
575
+ return {
576
+ x: a * p0.x + b * c1.x + c * c2.x + d * p3.x,
577
+ y: a * p0.y + b * c1.y + c * c2.y + d * p3.y
578
+ };
579
+ }
580
+ function cubicTangentAt(p0, c1, c2, p3, t) {
581
+ const u = 1 - t;
582
+ const a = 3 * u * u;
583
+ const b = 6 * u * t;
584
+ const c = 3 * t * t;
585
+ return {
586
+ x: a * (c1.x - p0.x) + b * (c2.x - c1.x) + c * (p3.x - c2.x),
587
+ y: a * (c1.y - p0.y) + b * (c2.y - c1.y) + c * (p3.y - c2.y)
588
+ };
589
+ }
590
+ function loopedCurvePoints(start, c1, c2, end, radius, random) {
591
+ const at = (t) => cubicAt(start, c1, c2, end, t);
592
+ const tangentAt = (t) => normalize(cubicTangentAt(start, c1, c2, end, t));
593
+ const knot = 0.46 + random() * 0.12;
594
+ const points = [];
595
+ const lead = Math.max(2, Math.round(knot * 16));
596
+ for (let i = 0; i <= lead; i++) points.push(at(knot * i / lead));
597
+ const pivot = at(knot);
598
+ const heading = tangentAt(knot);
599
+ const normal = { x: -heading.y, y: heading.x };
600
+ const side = random() < 0.5 ? 1 : -1;
601
+ const centre = {
602
+ x: pivot.x + normal.x * radius * side,
603
+ y: pivot.y + normal.y * radius * side
604
+ };
605
+ const from = Math.atan2(pivot.y - centre.y, pivot.x - centre.x);
606
+ const turns = Math.PI * 2 * (0.97 + random() * 0.08);
607
+ const steps = 18;
608
+ for (let i = 1; i <= steps; i++) {
609
+ const progress = i / steps;
610
+ const angle = from + turns * progress * side;
611
+ const drift = radius * 0.55 * progress;
612
+ points.push({
613
+ x: centre.x + Math.cos(angle) * radius + heading.x * drift,
614
+ y: centre.y + Math.sin(angle) * radius + heading.y * drift
615
+ });
616
+ }
617
+ const tail = 14;
618
+ for (let i = 1; i <= tail; i++) {
619
+ points.push(at(knot + (1 - knot) * (i / tail)));
620
+ }
621
+ return points;
622
+ }
562
623
  function normalize(vector) {
563
624
  const length = Math.hypot(vector.x, vector.y);
564
625
  if (!length) return { x: 0, y: 0 };
565
626
  return { x: vector.x / length, y: vector.y / length };
566
627
  }
567
- function arrowStartFromNote(origin, target) {
628
+ var NOTE_CLEARANCE = 10;
629
+ var TIP_GAP = 11;
630
+ function arrowStartFromNote(origin, target, from) {
568
631
  const cx = origin.x + origin.width / 2;
569
632
  const cy = origin.y + origin.height / 2;
633
+ if (from && from !== "note") {
634
+ const point = pointFromPosition(from, origin);
635
+ const away = directionFromPosition(from);
636
+ if (!away.x && !away.y) {
637
+ const edge = pointOnBoxToward(origin, target);
638
+ const outward = normalize({ x: edge.x - cx, y: edge.y - cy });
639
+ return {
640
+ x: edge.x + outward.x * NOTE_CLEARANCE,
641
+ y: edge.y + outward.y * NOTE_CLEARANCE
642
+ };
643
+ }
644
+ return {
645
+ x: point.x + away.x * NOTE_CLEARANCE,
646
+ y: point.y + away.y * NOTE_CLEARANCE
647
+ };
648
+ }
570
649
  const dx = target.x - cx;
571
650
  const dy = target.y - cy;
572
651
  const hw = origin.width / 2 || 1e-3;
@@ -574,15 +653,16 @@ function arrowStartFromNote(origin, target) {
574
653
  const sx = Math.sign(dx) || 1;
575
654
  const sy = Math.sign(dy) || 1;
576
655
  if (Math.abs(dy) / hh >= Math.abs(dx) / hw) {
577
- return { x: cx - sx * hw * 0.72, y: cy + sy * (hh + 7) };
656
+ return { x: cx - sx * (hw + 10), y: cy + sy * (hh + 4) };
578
657
  }
579
- return { x: cx + sx * (hw + 7), y: cy + sy * hh * 0.2 };
658
+ return { x: cx + sx * (hw + 8), y: cy + sy * (hh + 6) };
580
659
  }
581
660
  function arrowEndpoints(rect, arrowOptions, random, origin) {
582
661
  const center = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
662
+ const from = arrowOptions.from || "note";
583
663
  const anchored = Boolean(origin && origin.width > 0 && origin.height > 0);
584
- let start = anchored ? arrowStartFromNote(origin, center) : annotationAnchor(
585
- arrowOptions.from || "top-right",
664
+ let start = anchored ? arrowStartFromNote(origin, center, from) : annotationAnchor(
665
+ from === "note" ? "top-right" : from,
586
666
  rect,
587
667
  38 + random() * 12
588
668
  );
@@ -594,7 +674,7 @@ function arrowEndpoints(rect, arrowOptions, random, origin) {
594
674
  const near = nearestPointOnRect(rect, start);
595
675
  let outward = normalize({ x: start.x - near.x, y: start.y - near.y });
596
676
  if (!outward.x && !outward.y) outward = { x: 0, y: -1 };
597
- const gap = 6 + random() * 3;
677
+ const gap = TIP_GAP + random() * 5;
598
678
  end = { x: near.x + outward.x * gap, y: near.y + outward.y * gap };
599
679
  } else {
600
680
  end = pointFromPosition(toPos, rect);
@@ -607,6 +687,35 @@ function arrowEndpoints(rect, arrowOptions, random, origin) {
607
687
  }
608
688
  return { start, end };
609
689
  }
690
+ function drawArrowHead(svg, tip, angle, style, random, shaftLength = Infinity) {
691
+ const length = Math.min(9 + style.strokeWidth * 1.4, shaftLength * 0.3);
692
+ const spread = 0.46 + random() * 0.1;
693
+ for (const side of [-1, 1]) {
694
+ const armAngle = angle + spread * side;
695
+ const tail = {
696
+ x: tip.x - length * Math.cos(armAngle),
697
+ y: tip.y - length * Math.sin(armAngle)
698
+ };
699
+ appendPath(
700
+ svg,
701
+ roughLine(tail.x, tail.y, tip.x, tip.y, style.roughness * 0.5, random, {
702
+ steps: 2,
703
+ bow: 0.35 * side
704
+ }),
705
+ { ...style, dashed: false }
706
+ );
707
+ }
708
+ }
709
+ function boundsOf(points) {
710
+ const xs = points.map((p) => p.x);
711
+ const ys = points.map((p) => p.y);
712
+ return {
713
+ x: Math.min(...xs),
714
+ y: Math.min(...ys),
715
+ width: Math.max(...xs) - Math.min(...xs),
716
+ height: Math.max(...ys) - Math.min(...ys)
717
+ };
718
+ }
610
719
  function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
611
720
  const random = createRandom(seed + 17);
612
721
  const { start, end } = arrowEndpoints(rect, arrowOptions, random, origin);
@@ -628,13 +737,33 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
628
737
  x: rect.x + rect.width / 2 - mid.x,
629
738
  y: rect.y + rect.height / 2 - mid.y
630
739
  };
631
- const sign = perpendicular.x * towardCenter.x + perpendicular.y * towardCenter.y > 0 ? -1 : 1;
740
+ const lateral = perpendicular.x * towardCenter.x + perpendicular.y * towardCenter.y;
741
+ const sign = Math.abs(lateral) > distance * 0.12 ? lateral > 0 ? -1 : 1 : random() < 0.5 ? -1 : 1;
632
742
  const bow = Math.min(distance * (0.16 + random() * 0.08), 30) * sign;
633
743
  const c1x = start.x + dx * 0.28 + perpendicular.x * bow;
634
744
  const c1y = start.y + dy * 0.28 + perpendicular.y * bow;
635
745
  const c2x = start.x + dx * 0.72 + perpendicular.x * bow * 0.85;
636
746
  const c2y = start.y + dy * 0.72 + perpendicular.y * bow * 0.85;
637
747
  hull.push({ x: c1x, y: c1y }, { x: c2x, y: c2y });
748
+ if (arrowOptions.style === "looped") {
749
+ const radius = clamp(distance * 0.11, 7, 17);
750
+ const points = loopedCurvePoints(
751
+ start,
752
+ { x: c1x, y: c1y },
753
+ { x: c2x, y: c2y },
754
+ end,
755
+ radius,
756
+ random
757
+ );
758
+ shaft = catmullRomPath(points, false);
759
+ hull.push(...points);
760
+ const last = points[points.length - 1];
761
+ const prior = points[points.length - 2] ?? start;
762
+ tipAngle = Math.atan2(last.y - prior.y, last.x - prior.x);
763
+ appendPath(svg, shaft, { ...style, dashed });
764
+ drawArrowHead(svg, end, tipAngle, style, random, distance);
765
+ return { start, end, bounds: boundsOf(hull) };
766
+ }
638
767
  shaft = `M ${fmt(start.x)} ${fmt(start.y)} C ${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(end.x)} ${fmt(end.y)}`;
639
768
  if (style.roughness >= 1.1) {
640
769
  const drift = () => (random() - 0.5) * 1.4;
@@ -649,31 +778,8 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
649
778
  tipAngle = Math.atan2(end.y - c2y, end.x - c2x);
650
779
  }
651
780
  appendPath(svg, shaft, { ...style, dashed });
652
- const headLength = 9 + style.strokeWidth * 1.4;
653
- const spread = 0.46 + random() * 0.1;
654
- for (const side of [-1, 1]) {
655
- const angle = tipAngle + spread * side;
656
- const tail = {
657
- x: end.x - headLength * Math.cos(angle),
658
- y: end.y - headLength * Math.sin(angle)
659
- };
660
- appendPath(
661
- svg,
662
- roughLine(tail.x, tail.y, end.x, end.y, style.roughness * 0.5, random, {
663
- steps: 2,
664
- bow: 0.35 * side
665
- }),
666
- { ...style, dashed: false }
667
- );
668
- }
669
- const xs = hull.map((p) => p.x);
670
- const ys = hull.map((p) => p.y);
671
- const bounds = {
672
- x: Math.min(...xs),
673
- y: Math.min(...ys),
674
- width: Math.max(...xs) - Math.min(...xs),
675
- height: Math.max(...ys) - Math.min(...ys)
676
- };
781
+ drawArrowHead(svg, end, tipAngle, style, random, distance);
782
+ const bounds = boundsOf(hull);
677
783
  return { start, end, bounds };
678
784
  }
679
785
  function drawUnderline(svg, x, y, width, style, random) {
@@ -711,11 +817,38 @@ function drawUnderline(svg, x, y, width, style, random) {
711
817
  }
712
818
  );
713
819
  }
714
- function createNoteText(svg, text, style) {
820
+ var DEFAULT_WORDS_PER_LINE = 2;
821
+ function wrapWords(text, perLine) {
822
+ const lines = [];
823
+ for (const paragraph of String(text).split(/\r?\n/)) {
824
+ const words = paragraph.trim().split(/\s+/).filter(Boolean);
825
+ if (!words.length) continue;
826
+ const size = Number.isFinite(perLine) && perLine >= 1 ? Math.round(perLine) : words.length;
827
+ for (let i = 0; i < words.length; i += size) {
828
+ lines.push(words.slice(i, i + size).join(" "));
829
+ }
830
+ }
831
+ return lines.length ? lines : [String(text)];
832
+ }
833
+ function measureWidth(element, content, fontSize) {
834
+ try {
835
+ const box = element.getBBox();
836
+ if (box && box.width > 0) return box.width;
837
+ } catch {
838
+ }
839
+ try {
840
+ const measured = element.getComputedTextLength();
841
+ if (Number.isFinite(measured) && measured > 0) return measured;
842
+ } catch {
843
+ }
844
+ return content.length * fontSize * 0.45;
845
+ }
846
+ function createNoteText(svg, text, style, options = {}) {
715
847
  const fontSize = style.fontSize ?? NOTE_FONT_SIZE;
848
+ const lines = wrapWords(text, options.wordsPerLine ?? DEFAULT_WORDS_PER_LINE);
849
+ const lineHeight = fontSize * 1.05;
716
850
  const group = document.createElementNS(SVG_NS, "g");
717
851
  const textEl = document.createElementNS(SVG_NS, "text");
718
- textEl.textContent = text;
719
852
  textEl.setAttribute("x", "0");
720
853
  textEl.setAttribute("y", "0");
721
854
  textEl.setAttribute("fill", style.color);
@@ -726,31 +859,43 @@ function createNoteText(svg, text, style) {
726
859
  textEl.setAttribute("letter-spacing", "0.4");
727
860
  textEl.setAttribute("text-anchor", "start");
728
861
  textEl.setAttribute("dominant-baseline", "auto");
862
+ const tspans = lines.map((line, index) => {
863
+ const tspan = document.createElementNS(SVG_NS, "tspan");
864
+ tspan.textContent = line;
865
+ tspan.setAttribute("x", "0");
866
+ tspan.setAttribute("dy", index === 0 ? "0" : String(lineHeight));
867
+ textEl.appendChild(tspan);
868
+ return tspan;
869
+ });
729
870
  group.appendChild(textEl);
730
871
  svg.appendChild(group);
731
- let width = 0;
732
- try {
733
- const box = textEl.getBBox();
734
- if (box && box.width > 0) width = box.width;
735
- } catch {
736
- }
737
- if (!width) {
738
- try {
739
- const measured = textEl.getComputedTextLength();
740
- if (Number.isFinite(measured) && measured > 0) width = measured;
741
- } catch {
742
- }
743
- }
744
- if (!width) width = text.length * fontSize * 0.45;
872
+ const lineWidths = tspans.map(
873
+ (tspan, index) => measureWidth(tspan, lines[index], fontSize)
874
+ );
875
+ const width = Math.max(...lineWidths);
745
876
  const ascent = fontSize * 0.74;
746
877
  const descent = fontSize * 0.26;
747
- return { group, textEl, width, ascent, descent, fontSize };
878
+ const height = (lines.length - 1) * lineHeight + ascent + descent;
879
+ return {
880
+ group,
881
+ textEl,
882
+ tspans,
883
+ lines,
884
+ lineWidths,
885
+ lineHeight,
886
+ width,
887
+ height,
888
+ ascent,
889
+ descent,
890
+ fontSize
891
+ };
748
892
  }
749
893
  function placeNoteText(note, box, style, seed, options = {}) {
750
894
  const random = createRandom(seed + 55);
751
895
  const tilt = options.tilt ?? (random() - 0.5) * 7;
752
896
  note.textEl.setAttribute("x", fmt(box.x));
753
897
  note.textEl.setAttribute("y", fmt(box.y + note.ascent));
898
+ for (const tspan of note.tspans) tspan.setAttribute("x", fmt(box.x));
754
899
  const cx = box.x + box.width / 2;
755
900
  const cy = box.y + box.height / 2;
756
901
  note.group.setAttribute(
@@ -758,11 +903,12 @@ function placeNoteText(note, box, style, seed, options = {}) {
758
903
  `rotate(${tilt.toFixed(2)} ${fmt(cx)} ${fmt(cy)})`
759
904
  );
760
905
  if (options.underline !== false) {
906
+ const lastWidth = note.lineWidths[note.lineWidths.length - 1] ?? box.width;
761
907
  drawUnderline(
762
908
  note.group,
763
909
  box.x + 1,
764
910
  box.y + box.height + 2,
765
- box.width - 2,
911
+ lastWidth - 2,
766
912
  {
767
913
  color: style.color,
768
914
  strokeWidth: style.strokeWidth || 1.4,
@@ -794,25 +940,6 @@ function createOverlaySvg(left, top, width, height) {
794
940
 
795
941
  // src/annotations.js
796
942
  var NOTE_GAP = 14;
797
- function mirror(position, axis) {
798
- if (axis === "x") {
799
- if (position.includes("right")) return position.replace("right", "left");
800
- if (position.includes("left")) return position.replace("left", "right");
801
- return position;
802
- }
803
- if (position.includes("top")) return position.replace("top", "bottom");
804
- if (position.includes("bottom")) return position.replace("bottom", "top");
805
- return position;
806
- }
807
- function candidatePositions(position) {
808
- const candidates = [
809
- position,
810
- mirror(position, "x"),
811
- mirror(position, "y"),
812
- mirror(mirror(position, "x"), "y")
813
- ];
814
- return candidates.filter((value, index) => candidates.indexOf(value) === index);
815
- }
816
943
  function boxForPosition(rect, position, size, gap) {
817
944
  const direction = directionFromPosition(position);
818
945
  const guard = inflateRect(rect, gap);
@@ -831,75 +958,67 @@ function boxForPosition(rect, position, size, gap) {
831
958
  }
832
959
  return { x, y, width: size.width, height: size.height };
833
960
  }
834
- function placeNote(rect, position, size, gap, viewport) {
835
- const guard = inflateRect(rect, gap * 0.7);
836
- let best = null;
837
- candidatePositions(position).forEach((candidate, index) => {
838
- const box = boxForPosition(rect, candidate, size, gap);
839
- const penalty = intersectionArea(box, guard) * 6 + outsideArea(box, viewport) + index * 0.5;
840
- if (!best || penalty < best.penalty) {
841
- best = { box, penalty, position: candidate };
842
- }
843
- });
844
- return best;
845
- }
846
- function clampToViewport(box, guard, viewport) {
847
- if (!viewport || viewport.width <= 0 || viewport.height <= 0) return box;
848
- const shifted = {
849
- ...box,
850
- x: clamp(
851
- box.x,
852
- viewport.x,
853
- Math.max(viewport.x, viewport.x + viewport.width - box.width)
854
- ),
855
- y: clamp(
856
- box.y,
857
- viewport.y,
858
- Math.max(viewport.y, viewport.y + viewport.height - box.height)
859
- )
860
- };
861
- if (intersectionArea(shifted, guard) > intersectionArea(box, guard)) {
862
- return box;
961
+ function limitAgainstElement(box, guard, dx) {
962
+ const level = box.y < guard.y + guard.height && box.y + box.height > guard.y;
963
+ if (!level) return dx;
964
+ if (dx < 0) {
965
+ const room2 = box.x - (guard.x + guard.width);
966
+ return room2 <= 0 ? 0 : -Math.min(-dx, room2);
863
967
  }
864
- return shifted;
968
+ const room = guard.x - (box.x + box.width);
969
+ return room <= 0 ? 0 : Math.min(dx, room);
970
+ }
971
+ function nudgeIntoBand(box, guard, band) {
972
+ if (!band || band.width <= 0) return box;
973
+ const overhangLeft = band.x - box.x;
974
+ const overhangRight = box.x + box.width - (band.x + band.width);
975
+ let dx = 0;
976
+ if (overhangLeft > 0) dx = overhangLeft;
977
+ else if (overhangRight > 0) dx = -overhangRight;
978
+ if (!dx) return box;
979
+ dx = limitAgainstElement(box, guard, dx);
980
+ if (!dx) return box;
981
+ return { ...box, x: box.x + dx };
865
982
  }
866
983
  function renderAnnotation(svg, rect, noteOptions, style, seed = 1, layout = {}) {
867
984
  if (!noteOptions?.text) return null;
868
- const viewport = layout.viewport ?? null;
985
+ const band = layout.band ?? null;
869
986
  const position = isValidPosition(noteOptions.position) ? noteOptions.position : "top-right";
870
987
  const random = createRandom(seed + 9);
871
- const note = createNoteText(svg, noteOptions.text, style);
988
+ const note = createNoteText(svg, noteOptions.text, style, {
989
+ wordsPerLine: noteOptions.wordsPerLine
990
+ });
872
991
  const tilt = (random() - 0.45) * 7;
873
992
  const tiltPad = Math.abs(Math.sin(tilt * Math.PI / 180)) * note.width * 0.5;
874
993
  const size = {
875
994
  width: note.width,
876
- height: note.ascent + note.descent + (noteOptions.underline === false ? 0 : 5)
995
+ height: note.height + (noteOptions.underline === false ? 0 : 5)
877
996
  };
878
997
  const gap = (layout.gap ?? NOTE_GAP) + random() * 6;
879
- const placed = placeNote(
998
+ const placed = boxForPosition(
880
999
  rect,
881
1000
  position,
882
1001
  { width: size.width, height: size.height + tiltPad },
883
- gap,
884
- viewport
1002
+ gap
885
1003
  );
1004
+ const shifted = {
1005
+ ...placed,
1006
+ x: placed.x + (Number(noteOptions.offset?.x) || 0),
1007
+ y: placed.y + (Number(noteOptions.offset?.y) || 0)
1008
+ };
886
1009
  const guard = inflateRect(rect, gap * 0.7);
887
- const outer = clampToViewport(
888
- { ...placed.box, height: size.height + tiltPad },
889
- guard,
890
- viewport
891
- );
1010
+ const outer = nudgeIntoBand(shifted, guard, band);
892
1011
  const box = {
893
1012
  x: outer.x,
894
1013
  y: outer.y + tiltPad / 2,
895
1014
  width: size.width,
896
- height: note.ascent + note.descent
1015
+ height: note.height
897
1016
  };
898
1017
  placeNoteText(note, box, style, seed, {
899
1018
  tilt,
900
1019
  underline: noteOptions.underline !== false
901
1020
  });
902
- return { group: note.group, box: inflateRect(outer, 4), position: placed.position };
1021
+ return { group: note.group, box: inflateRect(outer, 4), position };
903
1022
  }
904
1023
 
905
1024
  // src/decorations.js
@@ -1317,14 +1436,11 @@ function ensureHandwrittenFont() {
1317
1436
 
1318
1437
  // src/doodle.js
1319
1438
  var instances = /* @__PURE__ */ new WeakMap();
1320
- function localViewport(overlayRect, inset = 8) {
1439
+ function localBand(overlayRect, inset = 8) {
1321
1440
  const width = window.innerWidth || document.documentElement.clientWidth || 0;
1322
- const height = window.innerHeight || document.documentElement.clientHeight || 0;
1323
1441
  return {
1324
1442
  x: -overlayRect.x + inset,
1325
- y: -overlayRect.y + inset,
1326
- width: Math.max(0, width - inset * 2),
1327
- height: Math.max(0, height - inset * 2)
1443
+ width: Math.max(0, width - inset * 2)
1328
1444
  };
1329
1445
  }
1330
1446
  var DoodleOverlay = class {
@@ -1423,8 +1539,8 @@ var DoodleOverlay = class {
1423
1539
  };
1424
1540
  const noteGroups = [];
1425
1541
  const noteLayout = {
1426
- viewport: localViewport(overlayRect),
1427
- gap: this.options.arrow ? 32 : 14
1542
+ band: localBand(overlayRect),
1543
+ gap: this.options.arrow ? 40 : 14
1428
1544
  };
1429
1545
  targets.forEach((target, index) => {
1430
1546
  const absolute = absoluteRects[index];
package/dist/index.d.ts CHANGED
@@ -23,17 +23,33 @@ export type DecorationType =
23
23
 
24
24
  export interface NoteOptions {
25
25
  text: string;
26
- /** Preferred side. Flips automatically if it would run off screen. */
26
+ /**
27
+ * Which side of the element the note sits on. Always honoured - a note that
28
+ * would run off screen slides horizontally to stay in view rather than
29
+ * moving to another side.
30
+ */
27
31
  position?: DoodlePosition;
28
32
  /** Underline sized to the measured text. Default `true`. */
29
33
  underline?: boolean;
34
+ /**
35
+ * Nudge the note from where `position` put it, in px. The note is still kept
36
+ * in view from the offset spot, so a large `x` slides back rather than
37
+ * running off screen.
38
+ */
39
+ offset?: { x?: number; y?: number };
30
40
  }
31
41
 
32
42
  export interface ArrowOptions {
33
- from?: DoodlePosition;
43
+ /**
44
+ * Where the arrow leaves. With a `note` this names a side of *the note* -
45
+ * the arrow always departs from the handwriting and travels to the element,
46
+ * however far an `offset` has moved it. `"note"` (the default) picks the side
47
+ * facing the element. Without a note it names a side of the element instead.
48
+ */
49
+ from?: DoodlePosition | "note";
34
50
  /** `"edge"` lands just outside the frame; `"center"` points into it. */
35
51
  to?: DoodlePosition | "edge";
36
- style?: "curved" | "straight" | "dotted";
52
+ style?: "curved" | "straight" | "dotted" | "looped";
37
53
  }
38
54
 
39
55
  export interface DecorationOptions {
@@ -72,8 +88,8 @@ export interface DoodleOptions {
72
88
  /** Lift the pen at random points around the outline. */
73
89
  addBreaks?: boolean | number | BreakOptions;
74
90
  /**
75
- * Font stack for handwritten notes. Point this at your own family — a
76
- * `next/font` CSS variable, for instance — to take over from the built-in one.
91
+ * Font stack for handwritten notes. Point this at your own family - a
92
+ * `next/font` CSS variable, for instance - to take over from the built-in one.
77
93
  */
78
94
  fontFamily?: string | null;
79
95
  /**
@@ -98,5 +114,5 @@ export interface DoodleInstance {
98
114
  */
99
115
  export function doodle(
100
116
  target: string | Element,
101
- options?: DoodleOptions
117
+ options?: DoodleOptions,
102
118
  ): DoodleInstance;