@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.
package/dist/react.cjs CHANGED
@@ -34,14 +34,19 @@ var DEFAULT_OPTIONS = {
34
34
  color: "#ffffff",
35
35
  strokeWidth: 1.5,
36
36
  roughness: 1.5,
37
- padding: 8,
37
+ // The frame traces the element's own edge. Raise this to stand it off.
38
+ padding: 0,
38
39
  radius: null,
39
40
  opacity: 0.9,
40
41
  children: null,
41
42
  note: null,
42
43
  arrow: false,
43
44
  decorations: false,
44
- addBreaks: false
45
+ addBreaks: false,
46
+ /** Override the handwriting stack, e.g. a next/font CSS variable. */
47
+ fontFamily: null,
48
+ /** Fetch Caveat when the page has not provided it. */
49
+ autoLoadFont: true
45
50
  };
46
51
  function resolveElement(target) {
47
52
  if (!target) return null;
@@ -58,9 +63,16 @@ function mergeOptions(options = {}) {
58
63
  if (merged.note && typeof merged.note === "string") {
59
64
  merged.note = { text: merged.note, position: "top-right" };
60
65
  }
61
- if (merged.arrow === true) {
62
- const from = merged.note && typeof merged.note === "object" && merged.note.position ? merged.note.position : "top-right";
63
- merged.arrow = { from, to: "edge", style: "curved" };
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
+ };
64
76
  }
65
77
  if (merged.decorations === true) {
66
78
  merged.decorations = { count: null, types: null };
@@ -205,10 +217,6 @@ function intersectionArea(a, b) {
205
217
  const h = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y);
206
218
  return w > 0 && h > 0 ? w * h : 0;
207
219
  }
208
- function outsideArea(box, bounds) {
209
- if (!bounds) return 0;
210
- return Math.max(0, box.width * box.height - intersectionArea(box, bounds));
211
- }
212
220
  function nearestPointOnRect(rect, point) {
213
221
  return {
214
222
  x: clamp(point.x, rect.x, rect.x + rect.width),
@@ -221,6 +229,17 @@ function distanceToRoundedRect(point, rect, radius = 0) {
221
229
  const cy = clamp(point.y, rect.y + r, rect.y + rect.height - r);
222
230
  return Math.hypot(point.x - cx, point.y - cy) - r;
223
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
+ }
224
243
  function directionFromPosition(position) {
225
244
  return {
226
245
  x: position.includes("right") ? 1 : position.includes("left") ? -1 : 0,
@@ -267,7 +286,7 @@ function annotationAnchor(position, rect, offset = 12) {
267
286
 
268
287
  // src/renderer.js
269
288
  var SVG_NS = "http://www.w3.org/2000/svg";
270
- var HANDWRITTEN_FONT = '"Caveat", "Kalam", "Patrick Hand", cursive';
289
+ var HANDWRITTEN_FONT = '"Caveat", "Kalam", "Patrick Hand", "Bradley Hand", "Segoe Script", "Comic Sans MS", cursive';
271
290
  var NOTE_FONT_SIZE = 19;
272
291
  function clearSvg(svg) {
273
292
  while (svg.firstChild) {
@@ -584,14 +603,86 @@ function drawBorder(svg, rect, options, seed) {
584
603
  });
585
604
  }
586
605
  }
606
+ function cubicAt(p0, c1, c2, p3, t) {
607
+ const u = 1 - t;
608
+ const a = u * u * u;
609
+ const b = 3 * u * u * t;
610
+ const c = 3 * u * t * t;
611
+ const d = t * t * t;
612
+ return {
613
+ x: a * p0.x + b * c1.x + c * c2.x + d * p3.x,
614
+ y: a * p0.y + b * c1.y + c * c2.y + d * p3.y
615
+ };
616
+ }
617
+ function cubicTangentAt(p0, c1, c2, p3, t) {
618
+ const u = 1 - t;
619
+ const a = 3 * u * u;
620
+ const b = 6 * u * t;
621
+ const c = 3 * t * t;
622
+ return {
623
+ x: a * (c1.x - p0.x) + b * (c2.x - c1.x) + c * (p3.x - c2.x),
624
+ y: a * (c1.y - p0.y) + b * (c2.y - c1.y) + c * (p3.y - c2.y)
625
+ };
626
+ }
627
+ function loopedCurvePoints(start, c1, c2, end, radius, random) {
628
+ const at = (t) => cubicAt(start, c1, c2, end, t);
629
+ const tangentAt = (t) => normalize(cubicTangentAt(start, c1, c2, end, t));
630
+ const knot = 0.46 + random() * 0.12;
631
+ const points = [];
632
+ const lead = Math.max(2, Math.round(knot * 16));
633
+ for (let i = 0; i <= lead; i++) points.push(at(knot * i / lead));
634
+ const pivot = at(knot);
635
+ const heading = tangentAt(knot);
636
+ const normal = { x: -heading.y, y: heading.x };
637
+ const side = random() < 0.5 ? 1 : -1;
638
+ const centre = {
639
+ x: pivot.x + normal.x * radius * side,
640
+ y: pivot.y + normal.y * radius * side
641
+ };
642
+ const from = Math.atan2(pivot.y - centre.y, pivot.x - centre.x);
643
+ const turns = Math.PI * 2 * (0.97 + random() * 0.08);
644
+ const steps = 18;
645
+ for (let i = 1; i <= steps; i++) {
646
+ const progress = i / steps;
647
+ const angle = from + turns * progress * side;
648
+ const drift = radius * 0.55 * progress;
649
+ points.push({
650
+ x: centre.x + Math.cos(angle) * radius + heading.x * drift,
651
+ y: centre.y + Math.sin(angle) * radius + heading.y * drift
652
+ });
653
+ }
654
+ const tail = 14;
655
+ for (let i = 1; i <= tail; i++) {
656
+ points.push(at(knot + (1 - knot) * (i / tail)));
657
+ }
658
+ return points;
659
+ }
587
660
  function normalize(vector) {
588
661
  const length = Math.hypot(vector.x, vector.y);
589
662
  if (!length) return { x: 0, y: 0 };
590
663
  return { x: vector.x / length, y: vector.y / length };
591
664
  }
592
- function arrowStartFromNote(origin, target) {
665
+ var NOTE_CLEARANCE = 10;
666
+ var TIP_GAP = 11;
667
+ function arrowStartFromNote(origin, target, from) {
593
668
  const cx = origin.x + origin.width / 2;
594
669
  const cy = origin.y + origin.height / 2;
670
+ if (from && from !== "note") {
671
+ const point = pointFromPosition(from, origin);
672
+ const away = directionFromPosition(from);
673
+ if (!away.x && !away.y) {
674
+ const edge = pointOnBoxToward(origin, target);
675
+ const outward = normalize({ x: edge.x - cx, y: edge.y - cy });
676
+ return {
677
+ x: edge.x + outward.x * NOTE_CLEARANCE,
678
+ y: edge.y + outward.y * NOTE_CLEARANCE
679
+ };
680
+ }
681
+ return {
682
+ x: point.x + away.x * NOTE_CLEARANCE,
683
+ y: point.y + away.y * NOTE_CLEARANCE
684
+ };
685
+ }
595
686
  const dx = target.x - cx;
596
687
  const dy = target.y - cy;
597
688
  const hw = origin.width / 2 || 1e-3;
@@ -599,15 +690,16 @@ function arrowStartFromNote(origin, target) {
599
690
  const sx = Math.sign(dx) || 1;
600
691
  const sy = Math.sign(dy) || 1;
601
692
  if (Math.abs(dy) / hh >= Math.abs(dx) / hw) {
602
- return { x: cx - sx * hw * 0.72, y: cy + sy * (hh + 7) };
693
+ return { x: cx - sx * (hw + 10), y: cy + sy * (hh + 4) };
603
694
  }
604
- return { x: cx + sx * (hw + 7), y: cy + sy * hh * 0.2 };
695
+ return { x: cx + sx * (hw + 8), y: cy + sy * (hh + 6) };
605
696
  }
606
697
  function arrowEndpoints(rect, arrowOptions, random, origin) {
607
698
  const center = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
699
+ const from = arrowOptions.from || "note";
608
700
  const anchored = Boolean(origin && origin.width > 0 && origin.height > 0);
609
- let start = anchored ? arrowStartFromNote(origin, center) : annotationAnchor(
610
- arrowOptions.from || "top-right",
701
+ let start = anchored ? arrowStartFromNote(origin, center, from) : annotationAnchor(
702
+ from === "note" ? "top-right" : from,
611
703
  rect,
612
704
  38 + random() * 12
613
705
  );
@@ -619,7 +711,7 @@ function arrowEndpoints(rect, arrowOptions, random, origin) {
619
711
  const near = nearestPointOnRect(rect, start);
620
712
  let outward = normalize({ x: start.x - near.x, y: start.y - near.y });
621
713
  if (!outward.x && !outward.y) outward = { x: 0, y: -1 };
622
- const gap = 6 + random() * 3;
714
+ const gap = TIP_GAP + random() * 5;
623
715
  end = { x: near.x + outward.x * gap, y: near.y + outward.y * gap };
624
716
  } else {
625
717
  end = pointFromPosition(toPos, rect);
@@ -632,6 +724,35 @@ function arrowEndpoints(rect, arrowOptions, random, origin) {
632
724
  }
633
725
  return { start, end };
634
726
  }
727
+ function drawArrowHead(svg, tip, angle, style, random, shaftLength = Infinity) {
728
+ const length = Math.min(9 + style.strokeWidth * 1.4, shaftLength * 0.3);
729
+ const spread = 0.46 + random() * 0.1;
730
+ for (const side of [-1, 1]) {
731
+ const armAngle = angle + spread * side;
732
+ const tail = {
733
+ x: tip.x - length * Math.cos(armAngle),
734
+ y: tip.y - length * Math.sin(armAngle)
735
+ };
736
+ appendPath(
737
+ svg,
738
+ roughLine(tail.x, tail.y, tip.x, tip.y, style.roughness * 0.5, random, {
739
+ steps: 2,
740
+ bow: 0.35 * side
741
+ }),
742
+ { ...style, dashed: false }
743
+ );
744
+ }
745
+ }
746
+ function boundsOf(points) {
747
+ const xs = points.map((p) => p.x);
748
+ const ys = points.map((p) => p.y);
749
+ return {
750
+ x: Math.min(...xs),
751
+ y: Math.min(...ys),
752
+ width: Math.max(...xs) - Math.min(...xs),
753
+ height: Math.max(...ys) - Math.min(...ys)
754
+ };
755
+ }
635
756
  function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
636
757
  const random = createRandom(seed + 17);
637
758
  const { start, end } = arrowEndpoints(rect, arrowOptions, random, origin);
@@ -653,13 +774,33 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
653
774
  x: rect.x + rect.width / 2 - mid.x,
654
775
  y: rect.y + rect.height / 2 - mid.y
655
776
  };
656
- const sign = perpendicular.x * towardCenter.x + perpendicular.y * towardCenter.y > 0 ? -1 : 1;
777
+ const lateral = perpendicular.x * towardCenter.x + perpendicular.y * towardCenter.y;
778
+ const sign = Math.abs(lateral) > distance * 0.12 ? lateral > 0 ? -1 : 1 : random() < 0.5 ? -1 : 1;
657
779
  const bow = Math.min(distance * (0.16 + random() * 0.08), 30) * sign;
658
780
  const c1x = start.x + dx * 0.28 + perpendicular.x * bow;
659
781
  const c1y = start.y + dy * 0.28 + perpendicular.y * bow;
660
782
  const c2x = start.x + dx * 0.72 + perpendicular.x * bow * 0.85;
661
783
  const c2y = start.y + dy * 0.72 + perpendicular.y * bow * 0.85;
662
784
  hull.push({ x: c1x, y: c1y }, { x: c2x, y: c2y });
785
+ if (arrowOptions.style === "looped") {
786
+ const radius = clamp(distance * 0.11, 7, 17);
787
+ const points = loopedCurvePoints(
788
+ start,
789
+ { x: c1x, y: c1y },
790
+ { x: c2x, y: c2y },
791
+ end,
792
+ radius,
793
+ random
794
+ );
795
+ shaft = catmullRomPath(points, false);
796
+ hull.push(...points);
797
+ const last = points[points.length - 1];
798
+ const prior = points[points.length - 2] ?? start;
799
+ tipAngle = Math.atan2(last.y - prior.y, last.x - prior.x);
800
+ appendPath(svg, shaft, { ...style, dashed });
801
+ drawArrowHead(svg, end, tipAngle, style, random, distance);
802
+ return { start, end, bounds: boundsOf(hull) };
803
+ }
663
804
  shaft = `M ${fmt(start.x)} ${fmt(start.y)} C ${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(end.x)} ${fmt(end.y)}`;
664
805
  if (style.roughness >= 1.1) {
665
806
  const drift = () => (random() - 0.5) * 1.4;
@@ -674,31 +815,8 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
674
815
  tipAngle = Math.atan2(end.y - c2y, end.x - c2x);
675
816
  }
676
817
  appendPath(svg, shaft, { ...style, dashed });
677
- const headLength = 9 + style.strokeWidth * 1.4;
678
- const spread = 0.46 + random() * 0.1;
679
- for (const side of [-1, 1]) {
680
- const angle = tipAngle + spread * side;
681
- const tail = {
682
- x: end.x - headLength * Math.cos(angle),
683
- y: end.y - headLength * Math.sin(angle)
684
- };
685
- appendPath(
686
- svg,
687
- roughLine(tail.x, tail.y, end.x, end.y, style.roughness * 0.5, random, {
688
- steps: 2,
689
- bow: 0.35 * side
690
- }),
691
- { ...style, dashed: false }
692
- );
693
- }
694
- const xs = hull.map((p) => p.x);
695
- const ys = hull.map((p) => p.y);
696
- const bounds = {
697
- x: Math.min(...xs),
698
- y: Math.min(...ys),
699
- width: Math.max(...xs) - Math.min(...xs),
700
- height: Math.max(...ys) - Math.min(...ys)
701
- };
818
+ drawArrowHead(svg, end, tipAngle, style, random, distance);
819
+ const bounds = boundsOf(hull);
702
820
  return { start, end, bounds };
703
821
  }
704
822
  function drawUnderline(svg, x, y, width, style, random) {
@@ -736,46 +854,85 @@ function drawUnderline(svg, x, y, width, style, random) {
736
854
  }
737
855
  );
738
856
  }
739
- function createNoteText(svg, text, style) {
857
+ var DEFAULT_WORDS_PER_LINE = 2;
858
+ function wrapWords(text, perLine) {
859
+ const lines = [];
860
+ for (const paragraph of String(text).split(/\r?\n/)) {
861
+ const words = paragraph.trim().split(/\s+/).filter(Boolean);
862
+ if (!words.length) continue;
863
+ const size = Number.isFinite(perLine) && perLine >= 1 ? Math.round(perLine) : words.length;
864
+ for (let i = 0; i < words.length; i += size) {
865
+ lines.push(words.slice(i, i + size).join(" "));
866
+ }
867
+ }
868
+ return lines.length ? lines : [String(text)];
869
+ }
870
+ function measureWidth(element, content, fontSize) {
871
+ try {
872
+ const box = element.getBBox();
873
+ if (box && box.width > 0) return box.width;
874
+ } catch {
875
+ }
876
+ try {
877
+ const measured = element.getComputedTextLength();
878
+ if (Number.isFinite(measured) && measured > 0) return measured;
879
+ } catch {
880
+ }
881
+ return content.length * fontSize * 0.45;
882
+ }
883
+ function createNoteText(svg, text, style, options = {}) {
740
884
  const fontSize = style.fontSize ?? NOTE_FONT_SIZE;
885
+ const lines = wrapWords(text, options.wordsPerLine ?? DEFAULT_WORDS_PER_LINE);
886
+ const lineHeight = fontSize * 1.05;
741
887
  const group = document.createElementNS(SVG_NS, "g");
742
888
  const textEl = document.createElementNS(SVG_NS, "text");
743
- textEl.textContent = text;
744
889
  textEl.setAttribute("x", "0");
745
890
  textEl.setAttribute("y", "0");
746
891
  textEl.setAttribute("fill", style.color);
747
892
  textEl.setAttribute("opacity", String(style.opacity));
748
893
  textEl.setAttribute("font-size", String(fontSize));
749
- textEl.setAttribute("font-family", HANDWRITTEN_FONT);
894
+ textEl.setAttribute("font-family", style.fontFamily || HANDWRITTEN_FONT);
750
895
  textEl.setAttribute("font-weight", "600");
751
896
  textEl.setAttribute("letter-spacing", "0.4");
752
897
  textEl.setAttribute("text-anchor", "start");
753
898
  textEl.setAttribute("dominant-baseline", "auto");
899
+ const tspans = lines.map((line, index) => {
900
+ const tspan = document.createElementNS(SVG_NS, "tspan");
901
+ tspan.textContent = line;
902
+ tspan.setAttribute("x", "0");
903
+ tspan.setAttribute("dy", index === 0 ? "0" : String(lineHeight));
904
+ textEl.appendChild(tspan);
905
+ return tspan;
906
+ });
754
907
  group.appendChild(textEl);
755
908
  svg.appendChild(group);
756
- let width = 0;
757
- try {
758
- const box = textEl.getBBox();
759
- if (box && box.width > 0) width = box.width;
760
- } catch {
761
- }
762
- if (!width) {
763
- try {
764
- const measured = textEl.getComputedTextLength();
765
- if (Number.isFinite(measured) && measured > 0) width = measured;
766
- } catch {
767
- }
768
- }
769
- if (!width) width = text.length * fontSize * 0.45;
909
+ const lineWidths = tspans.map(
910
+ (tspan, index) => measureWidth(tspan, lines[index], fontSize)
911
+ );
912
+ const width = Math.max(...lineWidths);
770
913
  const ascent = fontSize * 0.74;
771
914
  const descent = fontSize * 0.26;
772
- return { group, textEl, width, ascent, descent, fontSize };
915
+ const height = (lines.length - 1) * lineHeight + ascent + descent;
916
+ return {
917
+ group,
918
+ textEl,
919
+ tspans,
920
+ lines,
921
+ lineWidths,
922
+ lineHeight,
923
+ width,
924
+ height,
925
+ ascent,
926
+ descent,
927
+ fontSize
928
+ };
773
929
  }
774
930
  function placeNoteText(note, box, style, seed, options = {}) {
775
931
  const random = createRandom(seed + 55);
776
932
  const tilt = options.tilt ?? (random() - 0.5) * 7;
777
933
  note.textEl.setAttribute("x", fmt(box.x));
778
934
  note.textEl.setAttribute("y", fmt(box.y + note.ascent));
935
+ for (const tspan of note.tspans) tspan.setAttribute("x", fmt(box.x));
779
936
  const cx = box.x + box.width / 2;
780
937
  const cy = box.y + box.height / 2;
781
938
  note.group.setAttribute(
@@ -783,11 +940,12 @@ function placeNoteText(note, box, style, seed, options = {}) {
783
940
  `rotate(${tilt.toFixed(2)} ${fmt(cx)} ${fmt(cy)})`
784
941
  );
785
942
  if (options.underline !== false) {
943
+ const lastWidth = note.lineWidths[note.lineWidths.length - 1] ?? box.width;
786
944
  drawUnderline(
787
945
  note.group,
788
946
  box.x + 1,
789
947
  box.y + box.height + 2,
790
- box.width - 2,
948
+ lastWidth - 2,
791
949
  {
792
950
  color: style.color,
793
951
  strokeWidth: style.strokeWidth || 1.4,
@@ -819,25 +977,6 @@ function createOverlaySvg(left, top, width, height) {
819
977
 
820
978
  // src/annotations.js
821
979
  var NOTE_GAP = 14;
822
- function mirror(position, axis) {
823
- if (axis === "x") {
824
- if (position.includes("right")) return position.replace("right", "left");
825
- if (position.includes("left")) return position.replace("left", "right");
826
- return position;
827
- }
828
- if (position.includes("top")) return position.replace("top", "bottom");
829
- if (position.includes("bottom")) return position.replace("bottom", "top");
830
- return position;
831
- }
832
- function candidatePositions(position) {
833
- const candidates = [
834
- position,
835
- mirror(position, "x"),
836
- mirror(position, "y"),
837
- mirror(mirror(position, "x"), "y")
838
- ];
839
- return candidates.filter((value, index) => candidates.indexOf(value) === index);
840
- }
841
980
  function boxForPosition(rect, position, size, gap) {
842
981
  const direction = directionFromPosition(position);
843
982
  const guard = inflateRect(rect, gap);
@@ -856,75 +995,67 @@ function boxForPosition(rect, position, size, gap) {
856
995
  }
857
996
  return { x, y, width: size.width, height: size.height };
858
997
  }
859
- function placeNote(rect, position, size, gap, viewport) {
860
- const guard = inflateRect(rect, gap * 0.7);
861
- let best = null;
862
- candidatePositions(position).forEach((candidate, index) => {
863
- const box = boxForPosition(rect, candidate, size, gap);
864
- const penalty = intersectionArea(box, guard) * 6 + outsideArea(box, viewport) + index * 0.5;
865
- if (!best || penalty < best.penalty) {
866
- best = { box, penalty, position: candidate };
867
- }
868
- });
869
- return best;
870
- }
871
- function clampToViewport(box, guard, viewport) {
872
- if (!viewport || viewport.width <= 0 || viewport.height <= 0) return box;
873
- const shifted = {
874
- ...box,
875
- x: clamp(
876
- box.x,
877
- viewport.x,
878
- Math.max(viewport.x, viewport.x + viewport.width - box.width)
879
- ),
880
- y: clamp(
881
- box.y,
882
- viewport.y,
883
- Math.max(viewport.y, viewport.y + viewport.height - box.height)
884
- )
885
- };
886
- if (intersectionArea(shifted, guard) > intersectionArea(box, guard)) {
887
- return box;
998
+ function limitAgainstElement(box, guard, dx) {
999
+ const level = box.y < guard.y + guard.height && box.y + box.height > guard.y;
1000
+ if (!level) return dx;
1001
+ if (dx < 0) {
1002
+ const room2 = box.x - (guard.x + guard.width);
1003
+ return room2 <= 0 ? 0 : -Math.min(-dx, room2);
888
1004
  }
889
- return shifted;
1005
+ const room = guard.x - (box.x + box.width);
1006
+ return room <= 0 ? 0 : Math.min(dx, room);
1007
+ }
1008
+ function nudgeIntoBand(box, guard, band) {
1009
+ if (!band || band.width <= 0) return box;
1010
+ const overhangLeft = band.x - box.x;
1011
+ const overhangRight = box.x + box.width - (band.x + band.width);
1012
+ let dx = 0;
1013
+ if (overhangLeft > 0) dx = overhangLeft;
1014
+ else if (overhangRight > 0) dx = -overhangRight;
1015
+ if (!dx) return box;
1016
+ dx = limitAgainstElement(box, guard, dx);
1017
+ if (!dx) return box;
1018
+ return { ...box, x: box.x + dx };
890
1019
  }
891
1020
  function renderAnnotation(svg, rect, noteOptions, style, seed = 1, layout = {}) {
892
1021
  if (!noteOptions?.text) return null;
893
- const viewport = layout.viewport ?? null;
1022
+ const band = layout.band ?? null;
894
1023
  const position = isValidPosition(noteOptions.position) ? noteOptions.position : "top-right";
895
1024
  const random = createRandom(seed + 9);
896
- const note = createNoteText(svg, noteOptions.text, style);
1025
+ const note = createNoteText(svg, noteOptions.text, style, {
1026
+ wordsPerLine: noteOptions.wordsPerLine
1027
+ });
897
1028
  const tilt = (random() - 0.45) * 7;
898
1029
  const tiltPad = Math.abs(Math.sin(tilt * Math.PI / 180)) * note.width * 0.5;
899
1030
  const size = {
900
1031
  width: note.width,
901
- height: note.ascent + note.descent + (noteOptions.underline === false ? 0 : 5)
1032
+ height: note.height + (noteOptions.underline === false ? 0 : 5)
902
1033
  };
903
1034
  const gap = (layout.gap ?? NOTE_GAP) + random() * 6;
904
- const placed = placeNote(
1035
+ const placed = boxForPosition(
905
1036
  rect,
906
1037
  position,
907
1038
  { width: size.width, height: size.height + tiltPad },
908
- gap,
909
- viewport
1039
+ gap
910
1040
  );
1041
+ const shifted = {
1042
+ ...placed,
1043
+ x: placed.x + (Number(noteOptions.offset?.x) || 0),
1044
+ y: placed.y + (Number(noteOptions.offset?.y) || 0)
1045
+ };
911
1046
  const guard = inflateRect(rect, gap * 0.7);
912
- const outer = clampToViewport(
913
- { ...placed.box, height: size.height + tiltPad },
914
- guard,
915
- viewport
916
- );
1047
+ const outer = nudgeIntoBand(shifted, guard, band);
917
1048
  const box = {
918
1049
  x: outer.x,
919
1050
  y: outer.y + tiltPad / 2,
920
1051
  width: size.width,
921
- height: note.ascent + note.descent
1052
+ height: note.height
922
1053
  };
923
1054
  placeNoteText(note, box, style, seed, {
924
1055
  tilt,
925
1056
  underline: noteOptions.underline !== false
926
1057
  });
927
- return { group: note.group, box: inflateRect(outer, 4), position: placed.position };
1058
+ return { group: note.group, box: inflateRect(outer, 4), position };
928
1059
  }
929
1060
 
930
1061
  // src/decorations.js
@@ -1319,16 +1450,34 @@ function renderDecorations(svg, rect, decorationOptions, style, seed, context =
1319
1450
  }
1320
1451
  }
1321
1452
 
1453
+ // src/fonts.js
1454
+ var FONT_NAME = "Caveat";
1455
+ var FONT_HREF = "https://fonts.googleapis.com/css2?family=Caveat:wght@400;600&display=swap";
1456
+ var MARKER = "data-freehand-font";
1457
+ var requested = false;
1458
+ function ensureHandwrittenFont() {
1459
+ if (requested) return;
1460
+ if (typeof document === "undefined" || !document.head) return;
1461
+ requested = true;
1462
+ try {
1463
+ if (document.fonts?.check?.(`600 19px "${FONT_NAME}"`)) return;
1464
+ } catch {
1465
+ }
1466
+ if (document.querySelector(`link[${MARKER}]`)) return;
1467
+ const link = document.createElement("link");
1468
+ link.rel = "stylesheet";
1469
+ link.href = FONT_HREF;
1470
+ link.setAttribute(MARKER, "");
1471
+ document.head.appendChild(link);
1472
+ }
1473
+
1322
1474
  // src/doodle.js
1323
1475
  var instances = /* @__PURE__ */ new WeakMap();
1324
- function localViewport(overlayRect, inset = 8) {
1476
+ function localBand(overlayRect, inset = 8) {
1325
1477
  const width = window.innerWidth || document.documentElement.clientWidth || 0;
1326
- const height = window.innerHeight || document.documentElement.clientHeight || 0;
1327
1478
  return {
1328
1479
  x: -overlayRect.x + inset,
1329
- y: -overlayRect.y + inset,
1330
- width: Math.max(0, width - inset * 2),
1331
- height: Math.max(0, height - inset * 2)
1480
+ width: Math.max(0, width - inset * 2)
1332
1481
  };
1333
1482
  }
1334
1483
  var DoodleOverlay = class {
@@ -1370,7 +1519,12 @@ var DoodleOverlay = class {
1370
1519
  }
1371
1520
  window.addEventListener("resize", this.onResize, { passive: true });
1372
1521
  this.scrollTargets.push({ target: window, type: "resize" });
1373
- document.fonts?.ready?.then(() => this.scheduleUpdate()).catch(() => {
1522
+ if (this.options.note && this.options.autoLoadFont && !this.options.fontFamily) {
1523
+ ensureHandwrittenFont();
1524
+ }
1525
+ this.onFontsLoaded = () => this.scheduleUpdate();
1526
+ document.fonts?.addEventListener?.("loadingdone", this.onFontsLoaded);
1527
+ document.fonts?.ready?.then(this.onFontsLoaded).catch(() => {
1374
1528
  });
1375
1529
  this.update();
1376
1530
  }
@@ -1413,11 +1567,17 @@ var DoodleOverlay = class {
1413
1567
  this.svg.style.width = `${overlayRect.width}px`;
1414
1568
  this.svg.style.height = `${overlayRect.height}px`;
1415
1569
  const strokeStyle = { color, strokeWidth, roughness, opacity };
1416
- const noteStyle = { color, opacity, strokeWidth, roughness };
1570
+ const noteStyle = {
1571
+ color,
1572
+ opacity,
1573
+ strokeWidth,
1574
+ roughness,
1575
+ fontFamily: this.options.fontFamily
1576
+ };
1417
1577
  const noteGroups = [];
1418
1578
  const noteLayout = {
1419
- viewport: localViewport(overlayRect),
1420
- gap: this.options.arrow ? 32 : 14
1579
+ band: localBand(overlayRect),
1580
+ gap: this.options.arrow ? 40 : 14
1421
1581
  };
1422
1582
  targets.forEach((target, index) => {
1423
1583
  const absolute = absoluteRects[index];
@@ -1488,6 +1648,10 @@ var DoodleOverlay = class {
1488
1648
  this.mutationObserver.disconnect();
1489
1649
  this.mutationObserver = null;
1490
1650
  }
1651
+ if (this.onFontsLoaded) {
1652
+ document.fonts?.removeEventListener?.("loadingdone", this.onFontsLoaded);
1653
+ this.onFontsLoaded = null;
1654
+ }
1491
1655
  for (const { target, type } of this.scrollTargets) {
1492
1656
  target.removeEventListener(type, type === "scroll" ? this.onScroll : this.onResize);
1493
1657
  }
@@ -1530,7 +1694,9 @@ var OPTION_KEYS = [
1530
1694
  "note",
1531
1695
  "arrow",
1532
1696
  "decorations",
1533
- "addBreaks"
1697
+ "addBreaks",
1698
+ "fontFamily",
1699
+ "autoLoadFont"
1534
1700
  ];
1535
1701
  function collectOptions(props) {
1536
1702
  const options = {};
@@ -1580,6 +1746,8 @@ var Doodle = (0, import_react.forwardRef)(function Doodle2(props, forwardedRef)
1580
1746
  arrow,
1581
1747
  decorations,
1582
1748
  addBreaks,
1749
+ fontFamily,
1750
+ autoLoadFont,
1583
1751
  childSelector,
1584
1752
  ...rest
1585
1753
  } = props;
@@ -1597,6 +1765,8 @@ var Doodle = (0, import_react.forwardRef)(function Doodle2(props, forwardedRef)
1597
1765
  arrow,
1598
1766
  decorations,
1599
1767
  addBreaks,
1768
+ fontFamily,
1769
+ autoLoadFont,
1600
1770
  childSelector
1601
1771
  }),
1602
1772
  [
@@ -1612,6 +1782,8 @@ var Doodle = (0, import_react.forwardRef)(function Doodle2(props, forwardedRef)
1612
1782
  JSON.stringify(arrow ?? null),
1613
1783
  JSON.stringify(decorations ?? null),
1614
1784
  JSON.stringify(addBreaks ?? null),
1785
+ fontFamily,
1786
+ autoLoadFont,
1615
1787
  childSelector
1616
1788
  ]
1617
1789
  );