@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.
@@ -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,
@@ -559,14 +573,86 @@ function drawBorder(svg, rect, options, seed) {
559
573
  });
560
574
  }
561
575
  }
576
+ function cubicAt(p0, c1, c2, p3, t) {
577
+ const u = 1 - t;
578
+ const a = u * u * u;
579
+ const b = 3 * u * u * t;
580
+ const c = 3 * u * t * t;
581
+ const d = t * t * t;
582
+ return {
583
+ x: a * p0.x + b * c1.x + c * c2.x + d * p3.x,
584
+ y: a * p0.y + b * c1.y + c * c2.y + d * p3.y
585
+ };
586
+ }
587
+ function cubicTangentAt(p0, c1, c2, p3, t) {
588
+ const u = 1 - t;
589
+ const a = 3 * u * u;
590
+ const b = 6 * u * t;
591
+ const c = 3 * t * t;
592
+ return {
593
+ x: a * (c1.x - p0.x) + b * (c2.x - c1.x) + c * (p3.x - c2.x),
594
+ y: a * (c1.y - p0.y) + b * (c2.y - c1.y) + c * (p3.y - c2.y)
595
+ };
596
+ }
597
+ function loopedCurvePoints(start, c1, c2, end, radius, random) {
598
+ const at = (t) => cubicAt(start, c1, c2, end, t);
599
+ const tangentAt = (t) => normalize(cubicTangentAt(start, c1, c2, end, t));
600
+ const knot = 0.46 + random() * 0.12;
601
+ const points = [];
602
+ const lead = Math.max(2, Math.round(knot * 16));
603
+ for (let i = 0; i <= lead; i++) points.push(at(knot * i / lead));
604
+ const pivot = at(knot);
605
+ const heading = tangentAt(knot);
606
+ const normal = { x: -heading.y, y: heading.x };
607
+ const side = random() < 0.5 ? 1 : -1;
608
+ const centre = {
609
+ x: pivot.x + normal.x * radius * side,
610
+ y: pivot.y + normal.y * radius * side
611
+ };
612
+ const from = Math.atan2(pivot.y - centre.y, pivot.x - centre.x);
613
+ const turns = Math.PI * 2 * (0.97 + random() * 0.08);
614
+ const steps = 18;
615
+ for (let i = 1; i <= steps; i++) {
616
+ const progress = i / steps;
617
+ const angle = from + turns * progress * side;
618
+ const drift = radius * 0.55 * progress;
619
+ points.push({
620
+ x: centre.x + Math.cos(angle) * radius + heading.x * drift,
621
+ y: centre.y + Math.sin(angle) * radius + heading.y * drift
622
+ });
623
+ }
624
+ const tail = 14;
625
+ for (let i = 1; i <= tail; i++) {
626
+ points.push(at(knot + (1 - knot) * (i / tail)));
627
+ }
628
+ return points;
629
+ }
562
630
  function normalize(vector) {
563
631
  const length = Math.hypot(vector.x, vector.y);
564
632
  if (!length) return { x: 0, y: 0 };
565
633
  return { x: vector.x / length, y: vector.y / length };
566
634
  }
567
- function arrowStartFromNote(origin, target) {
635
+ var NOTE_CLEARANCE = 10;
636
+ var TIP_GAP = 11;
637
+ function arrowStartFromNote(origin, target, from) {
568
638
  const cx = origin.x + origin.width / 2;
569
639
  const cy = origin.y + origin.height / 2;
640
+ if (from && from !== "note") {
641
+ const point = pointFromPosition(from, origin);
642
+ const away = directionFromPosition(from);
643
+ if (!away.x && !away.y) {
644
+ const edge = pointOnBoxToward(origin, target);
645
+ const outward = normalize({ x: edge.x - cx, y: edge.y - cy });
646
+ return {
647
+ x: edge.x + outward.x * NOTE_CLEARANCE,
648
+ y: edge.y + outward.y * NOTE_CLEARANCE
649
+ };
650
+ }
651
+ return {
652
+ x: point.x + away.x * NOTE_CLEARANCE,
653
+ y: point.y + away.y * NOTE_CLEARANCE
654
+ };
655
+ }
570
656
  const dx = target.x - cx;
571
657
  const dy = target.y - cy;
572
658
  const hw = origin.width / 2 || 1e-3;
@@ -574,15 +660,16 @@ function arrowStartFromNote(origin, target) {
574
660
  const sx = Math.sign(dx) || 1;
575
661
  const sy = Math.sign(dy) || 1;
576
662
  if (Math.abs(dy) / hh >= Math.abs(dx) / hw) {
577
- return { x: cx - sx * hw * 0.72, y: cy + sy * (hh + 7) };
663
+ return { x: cx - sx * (hw + 10), y: cy + sy * (hh + 4) };
578
664
  }
579
- return { x: cx + sx * (hw + 7), y: cy + sy * hh * 0.2 };
665
+ return { x: cx + sx * (hw + 8), y: cy + sy * (hh + 6) };
580
666
  }
581
667
  function arrowEndpoints(rect, arrowOptions, random, origin) {
582
668
  const center = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
669
+ const from = arrowOptions.from || "note";
583
670
  const anchored = Boolean(origin && origin.width > 0 && origin.height > 0);
584
- let start = anchored ? arrowStartFromNote(origin, center) : annotationAnchor(
585
- arrowOptions.from || "top-right",
671
+ let start = anchored ? arrowStartFromNote(origin, center, from) : annotationAnchor(
672
+ from === "note" ? "top-right" : from,
586
673
  rect,
587
674
  38 + random() * 12
588
675
  );
@@ -594,7 +681,7 @@ function arrowEndpoints(rect, arrowOptions, random, origin) {
594
681
  const near = nearestPointOnRect(rect, start);
595
682
  let outward = normalize({ x: start.x - near.x, y: start.y - near.y });
596
683
  if (!outward.x && !outward.y) outward = { x: 0, y: -1 };
597
- const gap = 6 + random() * 3;
684
+ const gap = TIP_GAP + random() * 5;
598
685
  end = { x: near.x + outward.x * gap, y: near.y + outward.y * gap };
599
686
  } else {
600
687
  end = pointFromPosition(toPos, rect);
@@ -607,6 +694,35 @@ function arrowEndpoints(rect, arrowOptions, random, origin) {
607
694
  }
608
695
  return { start, end };
609
696
  }
697
+ function drawArrowHead(svg, tip, angle, style, random, shaftLength = Infinity) {
698
+ const length = Math.min(9 + style.strokeWidth * 1.4, shaftLength * 0.3);
699
+ const spread = 0.46 + random() * 0.1;
700
+ for (const side of [-1, 1]) {
701
+ const armAngle = angle + spread * side;
702
+ const tail = {
703
+ x: tip.x - length * Math.cos(armAngle),
704
+ y: tip.y - length * Math.sin(armAngle)
705
+ };
706
+ appendPath(
707
+ svg,
708
+ roughLine(tail.x, tail.y, tip.x, tip.y, style.roughness * 0.5, random, {
709
+ steps: 2,
710
+ bow: 0.35 * side
711
+ }),
712
+ { ...style, dashed: false }
713
+ );
714
+ }
715
+ }
716
+ function boundsOf(points) {
717
+ const xs = points.map((p) => p.x);
718
+ const ys = points.map((p) => p.y);
719
+ return {
720
+ x: Math.min(...xs),
721
+ y: Math.min(...ys),
722
+ width: Math.max(...xs) - Math.min(...xs),
723
+ height: Math.max(...ys) - Math.min(...ys)
724
+ };
725
+ }
610
726
  function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
611
727
  const random = createRandom(seed + 17);
612
728
  const { start, end } = arrowEndpoints(rect, arrowOptions, random, origin);
@@ -628,13 +744,33 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
628
744
  x: rect.x + rect.width / 2 - mid.x,
629
745
  y: rect.y + rect.height / 2 - mid.y
630
746
  };
631
- const sign = perpendicular.x * towardCenter.x + perpendicular.y * towardCenter.y > 0 ? -1 : 1;
747
+ const lateral = perpendicular.x * towardCenter.x + perpendicular.y * towardCenter.y;
748
+ const sign = Math.abs(lateral) > distance * 0.12 ? lateral > 0 ? -1 : 1 : random() < 0.5 ? -1 : 1;
632
749
  const bow = Math.min(distance * (0.16 + random() * 0.08), 30) * sign;
633
750
  const c1x = start.x + dx * 0.28 + perpendicular.x * bow;
634
751
  const c1y = start.y + dy * 0.28 + perpendicular.y * bow;
635
752
  const c2x = start.x + dx * 0.72 + perpendicular.x * bow * 0.85;
636
753
  const c2y = start.y + dy * 0.72 + perpendicular.y * bow * 0.85;
637
754
  hull.push({ x: c1x, y: c1y }, { x: c2x, y: c2y });
755
+ if (arrowOptions.style === "looped") {
756
+ const radius = clamp(distance * 0.11, 7, 17);
757
+ const points = loopedCurvePoints(
758
+ start,
759
+ { x: c1x, y: c1y },
760
+ { x: c2x, y: c2y },
761
+ end,
762
+ radius,
763
+ random
764
+ );
765
+ shaft = catmullRomPath(points, false);
766
+ hull.push(...points);
767
+ const last = points[points.length - 1];
768
+ const prior = points[points.length - 2] ?? start;
769
+ tipAngle = Math.atan2(last.y - prior.y, last.x - prior.x);
770
+ appendPath(svg, shaft, { ...style, dashed });
771
+ drawArrowHead(svg, end, tipAngle, style, random, distance);
772
+ return { start, end, bounds: boundsOf(hull) };
773
+ }
638
774
  shaft = `M ${fmt(start.x)} ${fmt(start.y)} C ${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(end.x)} ${fmt(end.y)}`;
639
775
  if (style.roughness >= 1.1) {
640
776
  const drift = () => (random() - 0.5) * 1.4;
@@ -649,31 +785,8 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
649
785
  tipAngle = Math.atan2(end.y - c2y, end.x - c2x);
650
786
  }
651
787
  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
- };
788
+ drawArrowHead(svg, end, tipAngle, style, random, distance);
789
+ const bounds = boundsOf(hull);
677
790
  return { start, end, bounds };
678
791
  }
679
792
  function drawUnderline(svg, x, y, width, style, random) {
@@ -711,11 +824,38 @@ function drawUnderline(svg, x, y, width, style, random) {
711
824
  }
712
825
  );
713
826
  }
714
- function createNoteText(svg, text, style) {
827
+ var DEFAULT_WORDS_PER_LINE = 2;
828
+ function wrapWords(text, perLine) {
829
+ const lines = [];
830
+ for (const paragraph of String(text).split(/\r?\n/)) {
831
+ const words = paragraph.trim().split(/\s+/).filter(Boolean);
832
+ if (!words.length) continue;
833
+ const size = Number.isFinite(perLine) && perLine >= 1 ? Math.round(perLine) : words.length;
834
+ for (let i = 0; i < words.length; i += size) {
835
+ lines.push(words.slice(i, i + size).join(" "));
836
+ }
837
+ }
838
+ return lines.length ? lines : [String(text)];
839
+ }
840
+ function measureWidth(element, content, fontSize) {
841
+ try {
842
+ const box = element.getBBox();
843
+ if (box && box.width > 0) return box.width;
844
+ } catch {
845
+ }
846
+ try {
847
+ const measured = element.getComputedTextLength();
848
+ if (Number.isFinite(measured) && measured > 0) return measured;
849
+ } catch {
850
+ }
851
+ return content.length * fontSize * 0.45;
852
+ }
853
+ function createNoteText(svg, text, style, options = {}) {
715
854
  const fontSize = style.fontSize ?? NOTE_FONT_SIZE;
855
+ const lines = wrapWords(text, options.wordsPerLine ?? DEFAULT_WORDS_PER_LINE);
856
+ const lineHeight = fontSize * 1.05;
716
857
  const group = document.createElementNS(SVG_NS, "g");
717
858
  const textEl = document.createElementNS(SVG_NS, "text");
718
- textEl.textContent = text;
719
859
  textEl.setAttribute("x", "0");
720
860
  textEl.setAttribute("y", "0");
721
861
  textEl.setAttribute("fill", style.color);
@@ -726,31 +866,43 @@ function createNoteText(svg, text, style) {
726
866
  textEl.setAttribute("letter-spacing", "0.4");
727
867
  textEl.setAttribute("text-anchor", "start");
728
868
  textEl.setAttribute("dominant-baseline", "auto");
869
+ const tspans = lines.map((line, index) => {
870
+ const tspan = document.createElementNS(SVG_NS, "tspan");
871
+ tspan.textContent = line;
872
+ tspan.setAttribute("x", "0");
873
+ tspan.setAttribute("dy", index === 0 ? "0" : String(lineHeight));
874
+ textEl.appendChild(tspan);
875
+ return tspan;
876
+ });
729
877
  group.appendChild(textEl);
730
878
  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;
879
+ const lineWidths = tspans.map(
880
+ (tspan, index) => measureWidth(tspan, lines[index], fontSize)
881
+ );
882
+ const width = Math.max(...lineWidths);
745
883
  const ascent = fontSize * 0.74;
746
884
  const descent = fontSize * 0.26;
747
- return { group, textEl, width, ascent, descent, fontSize };
885
+ const height = (lines.length - 1) * lineHeight + ascent + descent;
886
+ return {
887
+ group,
888
+ textEl,
889
+ tspans,
890
+ lines,
891
+ lineWidths,
892
+ lineHeight,
893
+ width,
894
+ height,
895
+ ascent,
896
+ descent,
897
+ fontSize
898
+ };
748
899
  }
749
900
  function placeNoteText(note, box, style, seed, options = {}) {
750
901
  const random = createRandom(seed + 55);
751
902
  const tilt = options.tilt ?? (random() - 0.5) * 7;
752
903
  note.textEl.setAttribute("x", fmt(box.x));
753
904
  note.textEl.setAttribute("y", fmt(box.y + note.ascent));
905
+ for (const tspan of note.tspans) tspan.setAttribute("x", fmt(box.x));
754
906
  const cx = box.x + box.width / 2;
755
907
  const cy = box.y + box.height / 2;
756
908
  note.group.setAttribute(
@@ -758,11 +910,12 @@ function placeNoteText(note, box, style, seed, options = {}) {
758
910
  `rotate(${tilt.toFixed(2)} ${fmt(cx)} ${fmt(cy)})`
759
911
  );
760
912
  if (options.underline !== false) {
913
+ const lastWidth = note.lineWidths[note.lineWidths.length - 1] ?? box.width;
761
914
  drawUnderline(
762
915
  note.group,
763
916
  box.x + 1,
764
917
  box.y + box.height + 2,
765
- box.width - 2,
918
+ lastWidth - 2,
766
919
  {
767
920
  color: style.color,
768
921
  strokeWidth: style.strokeWidth || 1.4,
@@ -794,25 +947,6 @@ function createOverlaySvg(left, top, width, height) {
794
947
 
795
948
  // src/annotations.js
796
949
  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
950
  function boxForPosition(rect, position, size, gap) {
817
951
  const direction = directionFromPosition(position);
818
952
  const guard = inflateRect(rect, gap);
@@ -831,75 +965,67 @@ function boxForPosition(rect, position, size, gap) {
831
965
  }
832
966
  return { x, y, width: size.width, height: size.height };
833
967
  }
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;
968
+ function limitAgainstElement(box, guard, dx) {
969
+ const level = box.y < guard.y + guard.height && box.y + box.height > guard.y;
970
+ if (!level) return dx;
971
+ if (dx < 0) {
972
+ const room2 = box.x - (guard.x + guard.width);
973
+ return room2 <= 0 ? 0 : -Math.min(-dx, room2);
863
974
  }
864
- return shifted;
975
+ const room = guard.x - (box.x + box.width);
976
+ return room <= 0 ? 0 : Math.min(dx, room);
977
+ }
978
+ function nudgeIntoBand(box, guard, band) {
979
+ if (!band || band.width <= 0) return box;
980
+ const overhangLeft = band.x - box.x;
981
+ const overhangRight = box.x + box.width - (band.x + band.width);
982
+ let dx = 0;
983
+ if (overhangLeft > 0) dx = overhangLeft;
984
+ else if (overhangRight > 0) dx = -overhangRight;
985
+ if (!dx) return box;
986
+ dx = limitAgainstElement(box, guard, dx);
987
+ if (!dx) return box;
988
+ return { ...box, x: box.x + dx };
865
989
  }
866
990
  function renderAnnotation(svg, rect, noteOptions, style, seed = 1, layout = {}) {
867
991
  if (!noteOptions?.text) return null;
868
- const viewport = layout.viewport ?? null;
992
+ const band = layout.band ?? null;
869
993
  const position = isValidPosition(noteOptions.position) ? noteOptions.position : "top-right";
870
994
  const random = createRandom(seed + 9);
871
- const note = createNoteText(svg, noteOptions.text, style);
995
+ const note = createNoteText(svg, noteOptions.text, style, {
996
+ wordsPerLine: noteOptions.wordsPerLine
997
+ });
872
998
  const tilt = (random() - 0.45) * 7;
873
999
  const tiltPad = Math.abs(Math.sin(tilt * Math.PI / 180)) * note.width * 0.5;
874
1000
  const size = {
875
1001
  width: note.width,
876
- height: note.ascent + note.descent + (noteOptions.underline === false ? 0 : 5)
1002
+ height: note.height + (noteOptions.underline === false ? 0 : 5)
877
1003
  };
878
1004
  const gap = (layout.gap ?? NOTE_GAP) + random() * 6;
879
- const placed = placeNote(
1005
+ const placed = boxForPosition(
880
1006
  rect,
881
1007
  position,
882
1008
  { width: size.width, height: size.height + tiltPad },
883
- gap,
884
- viewport
1009
+ gap
885
1010
  );
1011
+ const shifted = {
1012
+ ...placed,
1013
+ x: placed.x + (Number(noteOptions.offset?.x) || 0),
1014
+ y: placed.y + (Number(noteOptions.offset?.y) || 0)
1015
+ };
886
1016
  const guard = inflateRect(rect, gap * 0.7);
887
- const outer = clampToViewport(
888
- { ...placed.box, height: size.height + tiltPad },
889
- guard,
890
- viewport
891
- );
1017
+ const outer = nudgeIntoBand(shifted, guard, band);
892
1018
  const box = {
893
1019
  x: outer.x,
894
1020
  y: outer.y + tiltPad / 2,
895
1021
  width: size.width,
896
- height: note.ascent + note.descent
1022
+ height: note.height
897
1023
  };
898
1024
  placeNoteText(note, box, style, seed, {
899
1025
  tilt,
900
1026
  underline: noteOptions.underline !== false
901
1027
  });
902
- return { group: note.group, box: inflateRect(outer, 4), position: placed.position };
1028
+ return { group: note.group, box: inflateRect(outer, 4), position };
903
1029
  }
904
1030
 
905
1031
  // src/decorations.js
@@ -1317,14 +1443,11 @@ function ensureHandwrittenFont() {
1317
1443
 
1318
1444
  // src/doodle.js
1319
1445
  var instances = /* @__PURE__ */ new WeakMap();
1320
- function localViewport(overlayRect, inset = 8) {
1446
+ function localBand(overlayRect, inset = 8) {
1321
1447
  const width = window.innerWidth || document.documentElement.clientWidth || 0;
1322
- const height = window.innerHeight || document.documentElement.clientHeight || 0;
1323
1448
  return {
1324
1449
  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)
1450
+ width: Math.max(0, width - inset * 2)
1328
1451
  };
1329
1452
  }
1330
1453
  var DoodleOverlay = class {
@@ -1423,8 +1546,8 @@ var DoodleOverlay = class {
1423
1546
  };
1424
1547
  const noteGroups = [];
1425
1548
  const noteLayout = {
1426
- viewport: localViewport(overlayRect),
1427
- gap: this.options.arrow ? 32 : 14
1549
+ band: localBand(overlayRect),
1550
+ gap: this.options.arrow ? 40 : 14
1428
1551
  };
1429
1552
  targets.forEach((target, index) => {
1430
1553
  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 {