@aznabee/freehand-ui 0.1.1 → 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.
@@ -29,14 +29,19 @@ var DEFAULT_OPTIONS = {
29
29
  color: "#ffffff",
30
30
  strokeWidth: 1.5,
31
31
  roughness: 1.5,
32
- padding: 8,
32
+ // The frame traces the element's own edge. Raise this to stand it off.
33
+ padding: 0,
33
34
  radius: null,
34
35
  opacity: 0.9,
35
36
  children: null,
36
37
  note: null,
37
38
  arrow: false,
38
39
  decorations: false,
39
- addBreaks: false
40
+ addBreaks: false,
41
+ /** Override the handwriting stack, e.g. a next/font CSS variable. */
42
+ fontFamily: null,
43
+ /** Fetch Caveat when the page has not provided it. */
44
+ autoLoadFont: true
40
45
  };
41
46
  function resolveElement(target) {
42
47
  if (!target) return null;
@@ -53,9 +58,16 @@ function mergeOptions(options = {}) {
53
58
  if (merged.note && typeof merged.note === "string") {
54
59
  merged.note = { text: merged.note, position: "top-right" };
55
60
  }
56
- if (merged.arrow === true) {
57
- const from = merged.note && typeof merged.note === "object" && merged.note.position ? merged.note.position : "top-right";
58
- 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
+ };
59
71
  }
60
72
  if (merged.decorations === true) {
61
73
  merged.decorations = { count: null, types: null };
@@ -200,10 +212,6 @@ function intersectionArea(a, b) {
200
212
  const h = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y);
201
213
  return w > 0 && h > 0 ? w * h : 0;
202
214
  }
203
- function outsideArea(box, bounds) {
204
- if (!bounds) return 0;
205
- return Math.max(0, box.width * box.height - intersectionArea(box, bounds));
206
- }
207
215
  function nearestPointOnRect(rect, point) {
208
216
  return {
209
217
  x: clamp(point.x, rect.x, rect.x + rect.width),
@@ -216,6 +224,17 @@ function distanceToRoundedRect(point, rect, radius = 0) {
216
224
  const cy = clamp(point.y, rect.y + r, rect.y + rect.height - r);
217
225
  return Math.hypot(point.x - cx, point.y - cy) - r;
218
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
+ }
219
238
  function directionFromPosition(position) {
220
239
  return {
221
240
  x: position.includes("right") ? 1 : position.includes("left") ? -1 : 0,
@@ -262,7 +281,7 @@ function annotationAnchor(position, rect, offset = 12) {
262
281
 
263
282
  // src/renderer.js
264
283
  var SVG_NS = "http://www.w3.org/2000/svg";
265
- var HANDWRITTEN_FONT = '"Caveat", "Kalam", "Patrick Hand", cursive';
284
+ var HANDWRITTEN_FONT = '"Caveat", "Kalam", "Patrick Hand", "Bradley Hand", "Segoe Script", "Comic Sans MS", cursive';
266
285
  var NOTE_FONT_SIZE = 19;
267
286
  function clearSvg(svg) {
268
287
  while (svg.firstChild) {
@@ -579,14 +598,86 @@ function drawBorder(svg, rect, options, seed) {
579
598
  });
580
599
  }
581
600
  }
601
+ function cubicAt(p0, c1, c2, p3, t) {
602
+ const u = 1 - t;
603
+ const a = u * u * u;
604
+ const b = 3 * u * u * t;
605
+ const c = 3 * u * t * t;
606
+ const d = t * t * t;
607
+ return {
608
+ x: a * p0.x + b * c1.x + c * c2.x + d * p3.x,
609
+ y: a * p0.y + b * c1.y + c * c2.y + d * p3.y
610
+ };
611
+ }
612
+ function cubicTangentAt(p0, c1, c2, p3, t) {
613
+ const u = 1 - t;
614
+ const a = 3 * u * u;
615
+ const b = 6 * u * t;
616
+ const c = 3 * t * t;
617
+ return {
618
+ x: a * (c1.x - p0.x) + b * (c2.x - c1.x) + c * (p3.x - c2.x),
619
+ y: a * (c1.y - p0.y) + b * (c2.y - c1.y) + c * (p3.y - c2.y)
620
+ };
621
+ }
622
+ function loopedCurvePoints(start, c1, c2, end, radius, random) {
623
+ const at = (t) => cubicAt(start, c1, c2, end, t);
624
+ const tangentAt = (t) => normalize(cubicTangentAt(start, c1, c2, end, t));
625
+ const knot = 0.46 + random() * 0.12;
626
+ const points = [];
627
+ const lead = Math.max(2, Math.round(knot * 16));
628
+ for (let i = 0; i <= lead; i++) points.push(at(knot * i / lead));
629
+ const pivot = at(knot);
630
+ const heading = tangentAt(knot);
631
+ const normal = { x: -heading.y, y: heading.x };
632
+ const side = random() < 0.5 ? 1 : -1;
633
+ const centre = {
634
+ x: pivot.x + normal.x * radius * side,
635
+ y: pivot.y + normal.y * radius * side
636
+ };
637
+ const from = Math.atan2(pivot.y - centre.y, pivot.x - centre.x);
638
+ const turns = Math.PI * 2 * (0.97 + random() * 0.08);
639
+ const steps = 18;
640
+ for (let i = 1; i <= steps; i++) {
641
+ const progress = i / steps;
642
+ const angle = from + turns * progress * side;
643
+ const drift = radius * 0.55 * progress;
644
+ points.push({
645
+ x: centre.x + Math.cos(angle) * radius + heading.x * drift,
646
+ y: centre.y + Math.sin(angle) * radius + heading.y * drift
647
+ });
648
+ }
649
+ const tail = 14;
650
+ for (let i = 1; i <= tail; i++) {
651
+ points.push(at(knot + (1 - knot) * (i / tail)));
652
+ }
653
+ return points;
654
+ }
582
655
  function normalize(vector) {
583
656
  const length = Math.hypot(vector.x, vector.y);
584
657
  if (!length) return { x: 0, y: 0 };
585
658
  return { x: vector.x / length, y: vector.y / length };
586
659
  }
587
- function arrowStartFromNote(origin, target) {
660
+ var NOTE_CLEARANCE = 10;
661
+ var TIP_GAP = 11;
662
+ function arrowStartFromNote(origin, target, from) {
588
663
  const cx = origin.x + origin.width / 2;
589
664
  const cy = origin.y + origin.height / 2;
665
+ if (from && from !== "note") {
666
+ const point = pointFromPosition(from, origin);
667
+ const away = directionFromPosition(from);
668
+ if (!away.x && !away.y) {
669
+ const edge = pointOnBoxToward(origin, target);
670
+ const outward = normalize({ x: edge.x - cx, y: edge.y - cy });
671
+ return {
672
+ x: edge.x + outward.x * NOTE_CLEARANCE,
673
+ y: edge.y + outward.y * NOTE_CLEARANCE
674
+ };
675
+ }
676
+ return {
677
+ x: point.x + away.x * NOTE_CLEARANCE,
678
+ y: point.y + away.y * NOTE_CLEARANCE
679
+ };
680
+ }
590
681
  const dx = target.x - cx;
591
682
  const dy = target.y - cy;
592
683
  const hw = origin.width / 2 || 1e-3;
@@ -594,15 +685,16 @@ function arrowStartFromNote(origin, target) {
594
685
  const sx = Math.sign(dx) || 1;
595
686
  const sy = Math.sign(dy) || 1;
596
687
  if (Math.abs(dy) / hh >= Math.abs(dx) / hw) {
597
- return { x: cx - sx * hw * 0.72, y: cy + sy * (hh + 7) };
688
+ return { x: cx - sx * (hw + 10), y: cy + sy * (hh + 4) };
598
689
  }
599
- return { x: cx + sx * (hw + 7), y: cy + sy * hh * 0.2 };
690
+ return { x: cx + sx * (hw + 8), y: cy + sy * (hh + 6) };
600
691
  }
601
692
  function arrowEndpoints(rect, arrowOptions, random, origin) {
602
693
  const center = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
694
+ const from = arrowOptions.from || "note";
603
695
  const anchored = Boolean(origin && origin.width > 0 && origin.height > 0);
604
- let start = anchored ? arrowStartFromNote(origin, center) : annotationAnchor(
605
- arrowOptions.from || "top-right",
696
+ let start = anchored ? arrowStartFromNote(origin, center, from) : annotationAnchor(
697
+ from === "note" ? "top-right" : from,
606
698
  rect,
607
699
  38 + random() * 12
608
700
  );
@@ -614,7 +706,7 @@ function arrowEndpoints(rect, arrowOptions, random, origin) {
614
706
  const near = nearestPointOnRect(rect, start);
615
707
  let outward = normalize({ x: start.x - near.x, y: start.y - near.y });
616
708
  if (!outward.x && !outward.y) outward = { x: 0, y: -1 };
617
- const gap = 6 + random() * 3;
709
+ const gap = TIP_GAP + random() * 5;
618
710
  end = { x: near.x + outward.x * gap, y: near.y + outward.y * gap };
619
711
  } else {
620
712
  end = pointFromPosition(toPos, rect);
@@ -627,6 +719,35 @@ function arrowEndpoints(rect, arrowOptions, random, origin) {
627
719
  }
628
720
  return { start, end };
629
721
  }
722
+ function drawArrowHead(svg, tip, angle, style, random, shaftLength = Infinity) {
723
+ const length = Math.min(9 + style.strokeWidth * 1.4, shaftLength * 0.3);
724
+ const spread = 0.46 + random() * 0.1;
725
+ for (const side of [-1, 1]) {
726
+ const armAngle = angle + spread * side;
727
+ const tail = {
728
+ x: tip.x - length * Math.cos(armAngle),
729
+ y: tip.y - length * Math.sin(armAngle)
730
+ };
731
+ appendPath(
732
+ svg,
733
+ roughLine(tail.x, tail.y, tip.x, tip.y, style.roughness * 0.5, random, {
734
+ steps: 2,
735
+ bow: 0.35 * side
736
+ }),
737
+ { ...style, dashed: false }
738
+ );
739
+ }
740
+ }
741
+ function boundsOf(points) {
742
+ const xs = points.map((p) => p.x);
743
+ const ys = points.map((p) => p.y);
744
+ return {
745
+ x: Math.min(...xs),
746
+ y: Math.min(...ys),
747
+ width: Math.max(...xs) - Math.min(...xs),
748
+ height: Math.max(...ys) - Math.min(...ys)
749
+ };
750
+ }
630
751
  function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
631
752
  const random = createRandom(seed + 17);
632
753
  const { start, end } = arrowEndpoints(rect, arrowOptions, random, origin);
@@ -648,13 +769,33 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
648
769
  x: rect.x + rect.width / 2 - mid.x,
649
770
  y: rect.y + rect.height / 2 - mid.y
650
771
  };
651
- const sign = perpendicular.x * towardCenter.x + perpendicular.y * towardCenter.y > 0 ? -1 : 1;
772
+ const lateral = perpendicular.x * towardCenter.x + perpendicular.y * towardCenter.y;
773
+ const sign = Math.abs(lateral) > distance * 0.12 ? lateral > 0 ? -1 : 1 : random() < 0.5 ? -1 : 1;
652
774
  const bow = Math.min(distance * (0.16 + random() * 0.08), 30) * sign;
653
775
  const c1x = start.x + dx * 0.28 + perpendicular.x * bow;
654
776
  const c1y = start.y + dy * 0.28 + perpendicular.y * bow;
655
777
  const c2x = start.x + dx * 0.72 + perpendicular.x * bow * 0.85;
656
778
  const c2y = start.y + dy * 0.72 + perpendicular.y * bow * 0.85;
657
779
  hull.push({ x: c1x, y: c1y }, { x: c2x, y: c2y });
780
+ if (arrowOptions.style === "looped") {
781
+ const radius = clamp(distance * 0.11, 7, 17);
782
+ const points = loopedCurvePoints(
783
+ start,
784
+ { x: c1x, y: c1y },
785
+ { x: c2x, y: c2y },
786
+ end,
787
+ radius,
788
+ random
789
+ );
790
+ shaft = catmullRomPath(points, false);
791
+ hull.push(...points);
792
+ const last = points[points.length - 1];
793
+ const prior = points[points.length - 2] ?? start;
794
+ tipAngle = Math.atan2(last.y - prior.y, last.x - prior.x);
795
+ appendPath(svg, shaft, { ...style, dashed });
796
+ drawArrowHead(svg, end, tipAngle, style, random, distance);
797
+ return { start, end, bounds: boundsOf(hull) };
798
+ }
658
799
  shaft = `M ${fmt(start.x)} ${fmt(start.y)} C ${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(end.x)} ${fmt(end.y)}`;
659
800
  if (style.roughness >= 1.1) {
660
801
  const drift = () => (random() - 0.5) * 1.4;
@@ -669,31 +810,8 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
669
810
  tipAngle = Math.atan2(end.y - c2y, end.x - c2x);
670
811
  }
671
812
  appendPath(svg, shaft, { ...style, dashed });
672
- const headLength = 9 + style.strokeWidth * 1.4;
673
- const spread = 0.46 + random() * 0.1;
674
- for (const side of [-1, 1]) {
675
- const angle = tipAngle + spread * side;
676
- const tail = {
677
- x: end.x - headLength * Math.cos(angle),
678
- y: end.y - headLength * Math.sin(angle)
679
- };
680
- appendPath(
681
- svg,
682
- roughLine(tail.x, tail.y, end.x, end.y, style.roughness * 0.5, random, {
683
- steps: 2,
684
- bow: 0.35 * side
685
- }),
686
- { ...style, dashed: false }
687
- );
688
- }
689
- const xs = hull.map((p) => p.x);
690
- const ys = hull.map((p) => p.y);
691
- const bounds = {
692
- x: Math.min(...xs),
693
- y: Math.min(...ys),
694
- width: Math.max(...xs) - Math.min(...xs),
695
- height: Math.max(...ys) - Math.min(...ys)
696
- };
813
+ drawArrowHead(svg, end, tipAngle, style, random, distance);
814
+ const bounds = boundsOf(hull);
697
815
  return { start, end, bounds };
698
816
  }
699
817
  function drawUnderline(svg, x, y, width, style, random) {
@@ -731,46 +849,85 @@ function drawUnderline(svg, x, y, width, style, random) {
731
849
  }
732
850
  );
733
851
  }
734
- function createNoteText(svg, text, style) {
852
+ var DEFAULT_WORDS_PER_LINE = 2;
853
+ function wrapWords(text, perLine) {
854
+ const lines = [];
855
+ for (const paragraph of String(text).split(/\r?\n/)) {
856
+ const words = paragraph.trim().split(/\s+/).filter(Boolean);
857
+ if (!words.length) continue;
858
+ const size = Number.isFinite(perLine) && perLine >= 1 ? Math.round(perLine) : words.length;
859
+ for (let i = 0; i < words.length; i += size) {
860
+ lines.push(words.slice(i, i + size).join(" "));
861
+ }
862
+ }
863
+ return lines.length ? lines : [String(text)];
864
+ }
865
+ function measureWidth(element, content, fontSize) {
866
+ try {
867
+ const box = element.getBBox();
868
+ if (box && box.width > 0) return box.width;
869
+ } catch {
870
+ }
871
+ try {
872
+ const measured = element.getComputedTextLength();
873
+ if (Number.isFinite(measured) && measured > 0) return measured;
874
+ } catch {
875
+ }
876
+ return content.length * fontSize * 0.45;
877
+ }
878
+ function createNoteText(svg, text, style, options = {}) {
735
879
  const fontSize = style.fontSize ?? NOTE_FONT_SIZE;
880
+ const lines = wrapWords(text, options.wordsPerLine ?? DEFAULT_WORDS_PER_LINE);
881
+ const lineHeight = fontSize * 1.05;
736
882
  const group = document.createElementNS(SVG_NS, "g");
737
883
  const textEl = document.createElementNS(SVG_NS, "text");
738
- textEl.textContent = text;
739
884
  textEl.setAttribute("x", "0");
740
885
  textEl.setAttribute("y", "0");
741
886
  textEl.setAttribute("fill", style.color);
742
887
  textEl.setAttribute("opacity", String(style.opacity));
743
888
  textEl.setAttribute("font-size", String(fontSize));
744
- textEl.setAttribute("font-family", HANDWRITTEN_FONT);
889
+ textEl.setAttribute("font-family", style.fontFamily || HANDWRITTEN_FONT);
745
890
  textEl.setAttribute("font-weight", "600");
746
891
  textEl.setAttribute("letter-spacing", "0.4");
747
892
  textEl.setAttribute("text-anchor", "start");
748
893
  textEl.setAttribute("dominant-baseline", "auto");
894
+ const tspans = lines.map((line, index) => {
895
+ const tspan = document.createElementNS(SVG_NS, "tspan");
896
+ tspan.textContent = line;
897
+ tspan.setAttribute("x", "0");
898
+ tspan.setAttribute("dy", index === 0 ? "0" : String(lineHeight));
899
+ textEl.appendChild(tspan);
900
+ return tspan;
901
+ });
749
902
  group.appendChild(textEl);
750
903
  svg.appendChild(group);
751
- let width = 0;
752
- try {
753
- const box = textEl.getBBox();
754
- if (box && box.width > 0) width = box.width;
755
- } catch {
756
- }
757
- if (!width) {
758
- try {
759
- const measured = textEl.getComputedTextLength();
760
- if (Number.isFinite(measured) && measured > 0) width = measured;
761
- } catch {
762
- }
763
- }
764
- if (!width) width = text.length * fontSize * 0.45;
904
+ const lineWidths = tspans.map(
905
+ (tspan, index) => measureWidth(tspan, lines[index], fontSize)
906
+ );
907
+ const width = Math.max(...lineWidths);
765
908
  const ascent = fontSize * 0.74;
766
909
  const descent = fontSize * 0.26;
767
- return { group, textEl, width, ascent, descent, fontSize };
910
+ const height = (lines.length - 1) * lineHeight + ascent + descent;
911
+ return {
912
+ group,
913
+ textEl,
914
+ tspans,
915
+ lines,
916
+ lineWidths,
917
+ lineHeight,
918
+ width,
919
+ height,
920
+ ascent,
921
+ descent,
922
+ fontSize
923
+ };
768
924
  }
769
925
  function placeNoteText(note, box, style, seed, options = {}) {
770
926
  const random = createRandom(seed + 55);
771
927
  const tilt = options.tilt ?? (random() - 0.5) * 7;
772
928
  note.textEl.setAttribute("x", fmt(box.x));
773
929
  note.textEl.setAttribute("y", fmt(box.y + note.ascent));
930
+ for (const tspan of note.tspans) tspan.setAttribute("x", fmt(box.x));
774
931
  const cx = box.x + box.width / 2;
775
932
  const cy = box.y + box.height / 2;
776
933
  note.group.setAttribute(
@@ -778,11 +935,12 @@ function placeNoteText(note, box, style, seed, options = {}) {
778
935
  `rotate(${tilt.toFixed(2)} ${fmt(cx)} ${fmt(cy)})`
779
936
  );
780
937
  if (options.underline !== false) {
938
+ const lastWidth = note.lineWidths[note.lineWidths.length - 1] ?? box.width;
781
939
  drawUnderline(
782
940
  note.group,
783
941
  box.x + 1,
784
942
  box.y + box.height + 2,
785
- box.width - 2,
943
+ lastWidth - 2,
786
944
  {
787
945
  color: style.color,
788
946
  strokeWidth: style.strokeWidth || 1.4,
@@ -814,25 +972,6 @@ function createOverlaySvg(left, top, width, height) {
814
972
 
815
973
  // src/annotations.js
816
974
  var NOTE_GAP = 14;
817
- function mirror(position, axis) {
818
- if (axis === "x") {
819
- if (position.includes("right")) return position.replace("right", "left");
820
- if (position.includes("left")) return position.replace("left", "right");
821
- return position;
822
- }
823
- if (position.includes("top")) return position.replace("top", "bottom");
824
- if (position.includes("bottom")) return position.replace("bottom", "top");
825
- return position;
826
- }
827
- function candidatePositions(position) {
828
- const candidates = [
829
- position,
830
- mirror(position, "x"),
831
- mirror(position, "y"),
832
- mirror(mirror(position, "x"), "y")
833
- ];
834
- return candidates.filter((value, index) => candidates.indexOf(value) === index);
835
- }
836
975
  function boxForPosition(rect, position, size, gap) {
837
976
  const direction = directionFromPosition(position);
838
977
  const guard = inflateRect(rect, gap);
@@ -851,75 +990,67 @@ function boxForPosition(rect, position, size, gap) {
851
990
  }
852
991
  return { x, y, width: size.width, height: size.height };
853
992
  }
854
- function placeNote(rect, position, size, gap, viewport) {
855
- const guard = inflateRect(rect, gap * 0.7);
856
- let best = null;
857
- candidatePositions(position).forEach((candidate, index) => {
858
- const box = boxForPosition(rect, candidate, size, gap);
859
- const penalty = intersectionArea(box, guard) * 6 + outsideArea(box, viewport) + index * 0.5;
860
- if (!best || penalty < best.penalty) {
861
- best = { box, penalty, position: candidate };
862
- }
863
- });
864
- return best;
865
- }
866
- function clampToViewport(box, guard, viewport) {
867
- if (!viewport || viewport.width <= 0 || viewport.height <= 0) return box;
868
- const shifted = {
869
- ...box,
870
- x: clamp(
871
- box.x,
872
- viewport.x,
873
- Math.max(viewport.x, viewport.x + viewport.width - box.width)
874
- ),
875
- y: clamp(
876
- box.y,
877
- viewport.y,
878
- Math.max(viewport.y, viewport.y + viewport.height - box.height)
879
- )
880
- };
881
- if (intersectionArea(shifted, guard) > intersectionArea(box, guard)) {
882
- return box;
993
+ function limitAgainstElement(box, guard, dx) {
994
+ const level = box.y < guard.y + guard.height && box.y + box.height > guard.y;
995
+ if (!level) return dx;
996
+ if (dx < 0) {
997
+ const room2 = box.x - (guard.x + guard.width);
998
+ return room2 <= 0 ? 0 : -Math.min(-dx, room2);
883
999
  }
884
- return shifted;
1000
+ const room = guard.x - (box.x + box.width);
1001
+ return room <= 0 ? 0 : Math.min(dx, room);
1002
+ }
1003
+ function nudgeIntoBand(box, guard, band) {
1004
+ if (!band || band.width <= 0) return box;
1005
+ const overhangLeft = band.x - box.x;
1006
+ const overhangRight = box.x + box.width - (band.x + band.width);
1007
+ let dx = 0;
1008
+ if (overhangLeft > 0) dx = overhangLeft;
1009
+ else if (overhangRight > 0) dx = -overhangRight;
1010
+ if (!dx) return box;
1011
+ dx = limitAgainstElement(box, guard, dx);
1012
+ if (!dx) return box;
1013
+ return { ...box, x: box.x + dx };
885
1014
  }
886
1015
  function renderAnnotation(svg, rect, noteOptions, style, seed = 1, layout = {}) {
887
1016
  if (!noteOptions?.text) return null;
888
- const viewport = layout.viewport ?? null;
1017
+ const band = layout.band ?? null;
889
1018
  const position = isValidPosition(noteOptions.position) ? noteOptions.position : "top-right";
890
1019
  const random = createRandom(seed + 9);
891
- const note = createNoteText(svg, noteOptions.text, style);
1020
+ const note = createNoteText(svg, noteOptions.text, style, {
1021
+ wordsPerLine: noteOptions.wordsPerLine
1022
+ });
892
1023
  const tilt = (random() - 0.45) * 7;
893
1024
  const tiltPad = Math.abs(Math.sin(tilt * Math.PI / 180)) * note.width * 0.5;
894
1025
  const size = {
895
1026
  width: note.width,
896
- height: note.ascent + note.descent + (noteOptions.underline === false ? 0 : 5)
1027
+ height: note.height + (noteOptions.underline === false ? 0 : 5)
897
1028
  };
898
1029
  const gap = (layout.gap ?? NOTE_GAP) + random() * 6;
899
- const placed = placeNote(
1030
+ const placed = boxForPosition(
900
1031
  rect,
901
1032
  position,
902
1033
  { width: size.width, height: size.height + tiltPad },
903
- gap,
904
- viewport
1034
+ gap
905
1035
  );
1036
+ const shifted = {
1037
+ ...placed,
1038
+ x: placed.x + (Number(noteOptions.offset?.x) || 0),
1039
+ y: placed.y + (Number(noteOptions.offset?.y) || 0)
1040
+ };
906
1041
  const guard = inflateRect(rect, gap * 0.7);
907
- const outer = clampToViewport(
908
- { ...placed.box, height: size.height + tiltPad },
909
- guard,
910
- viewport
911
- );
1042
+ const outer = nudgeIntoBand(shifted, guard, band);
912
1043
  const box = {
913
1044
  x: outer.x,
914
1045
  y: outer.y + tiltPad / 2,
915
1046
  width: size.width,
916
- height: note.ascent + note.descent
1047
+ height: note.height
917
1048
  };
918
1049
  placeNoteText(note, box, style, seed, {
919
1050
  tilt,
920
1051
  underline: noteOptions.underline !== false
921
1052
  });
922
- return { group: note.group, box: inflateRect(outer, 4), position: placed.position };
1053
+ return { group: note.group, box: inflateRect(outer, 4), position };
923
1054
  }
924
1055
 
925
1056
  // src/decorations.js
@@ -1314,16 +1445,34 @@ function renderDecorations(svg, rect, decorationOptions, style, seed, context =
1314
1445
  }
1315
1446
  }
1316
1447
 
1448
+ // src/fonts.js
1449
+ var FONT_NAME = "Caveat";
1450
+ var FONT_HREF = "https://fonts.googleapis.com/css2?family=Caveat:wght@400;600&display=swap";
1451
+ var MARKER = "data-freehand-font";
1452
+ var requested = false;
1453
+ function ensureHandwrittenFont() {
1454
+ if (requested) return;
1455
+ if (typeof document === "undefined" || !document.head) return;
1456
+ requested = true;
1457
+ try {
1458
+ if (document.fonts?.check?.(`600 19px "${FONT_NAME}"`)) return;
1459
+ } catch {
1460
+ }
1461
+ if (document.querySelector(`link[${MARKER}]`)) return;
1462
+ const link = document.createElement("link");
1463
+ link.rel = "stylesheet";
1464
+ link.href = FONT_HREF;
1465
+ link.setAttribute(MARKER, "");
1466
+ document.head.appendChild(link);
1467
+ }
1468
+
1317
1469
  // src/doodle.js
1318
1470
  var instances = /* @__PURE__ */ new WeakMap();
1319
- function localViewport(overlayRect, inset = 8) {
1471
+ function localBand(overlayRect, inset = 8) {
1320
1472
  const width = window.innerWidth || document.documentElement.clientWidth || 0;
1321
- const height = window.innerHeight || document.documentElement.clientHeight || 0;
1322
1473
  return {
1323
1474
  x: -overlayRect.x + inset,
1324
- y: -overlayRect.y + inset,
1325
- width: Math.max(0, width - inset * 2),
1326
- height: Math.max(0, height - inset * 2)
1475
+ width: Math.max(0, width - inset * 2)
1327
1476
  };
1328
1477
  }
1329
1478
  var DoodleOverlay = class {
@@ -1365,7 +1514,12 @@ var DoodleOverlay = class {
1365
1514
  }
1366
1515
  window.addEventListener("resize", this.onResize, { passive: true });
1367
1516
  this.scrollTargets.push({ target: window, type: "resize" });
1368
- document.fonts?.ready?.then(() => this.scheduleUpdate()).catch(() => {
1517
+ if (this.options.note && this.options.autoLoadFont && !this.options.fontFamily) {
1518
+ ensureHandwrittenFont();
1519
+ }
1520
+ this.onFontsLoaded = () => this.scheduleUpdate();
1521
+ document.fonts?.addEventListener?.("loadingdone", this.onFontsLoaded);
1522
+ document.fonts?.ready?.then(this.onFontsLoaded).catch(() => {
1369
1523
  });
1370
1524
  this.update();
1371
1525
  }
@@ -1408,11 +1562,17 @@ var DoodleOverlay = class {
1408
1562
  this.svg.style.width = `${overlayRect.width}px`;
1409
1563
  this.svg.style.height = `${overlayRect.height}px`;
1410
1564
  const strokeStyle = { color, strokeWidth, roughness, opacity };
1411
- const noteStyle = { color, opacity, strokeWidth, roughness };
1565
+ const noteStyle = {
1566
+ color,
1567
+ opacity,
1568
+ strokeWidth,
1569
+ roughness,
1570
+ fontFamily: this.options.fontFamily
1571
+ };
1412
1572
  const noteGroups = [];
1413
1573
  const noteLayout = {
1414
- viewport: localViewport(overlayRect),
1415
- gap: this.options.arrow ? 32 : 14
1574
+ band: localBand(overlayRect),
1575
+ gap: this.options.arrow ? 40 : 14
1416
1576
  };
1417
1577
  targets.forEach((target, index) => {
1418
1578
  const absolute = absoluteRects[index];
@@ -1483,6 +1643,10 @@ var DoodleOverlay = class {
1483
1643
  this.mutationObserver.disconnect();
1484
1644
  this.mutationObserver = null;
1485
1645
  }
1646
+ if (this.onFontsLoaded) {
1647
+ document.fonts?.removeEventListener?.("loadingdone", this.onFontsLoaded);
1648
+ this.onFontsLoaded = null;
1649
+ }
1486
1650
  for (const { target, type } of this.scrollTargets) {
1487
1651
  target.removeEventListener(type, type === "scroll" ? this.onScroll : this.onResize);
1488
1652
  }