@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.
@@ -4,14 +4,19 @@ var DEFAULT_OPTIONS = {
4
4
  color: "#ffffff",
5
5
  strokeWidth: 1.5,
6
6
  roughness: 1.5,
7
- padding: 8,
7
+ // The frame traces the element's own edge. Raise this to stand it off.
8
+ padding: 0,
8
9
  radius: null,
9
10
  opacity: 0.9,
10
11
  children: null,
11
12
  note: null,
12
13
  arrow: false,
13
14
  decorations: false,
14
- addBreaks: false
15
+ addBreaks: false,
16
+ /** Override the handwriting stack, e.g. a next/font CSS variable. */
17
+ fontFamily: null,
18
+ /** Fetch Caveat when the page has not provided it. */
19
+ autoLoadFont: true
15
20
  };
16
21
  function resolveElement(target) {
17
22
  if (!target) return null;
@@ -28,9 +33,16 @@ function mergeOptions(options = {}) {
28
33
  if (merged.note && typeof merged.note === "string") {
29
34
  merged.note = { text: merged.note, position: "top-right" };
30
35
  }
31
- if (merged.arrow === true) {
32
- const from = merged.note && typeof merged.note === "object" && merged.note.position ? merged.note.position : "top-right";
33
- 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
+ };
34
46
  }
35
47
  if (merged.decorations === true) {
36
48
  merged.decorations = { count: null, types: null };
@@ -175,10 +187,6 @@ function intersectionArea(a, b) {
175
187
  const h = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y);
176
188
  return w > 0 && h > 0 ? w * h : 0;
177
189
  }
178
- function outsideArea(box, bounds) {
179
- if (!bounds) return 0;
180
- return Math.max(0, box.width * box.height - intersectionArea(box, bounds));
181
- }
182
190
  function nearestPointOnRect(rect, point) {
183
191
  return {
184
192
  x: clamp(point.x, rect.x, rect.x + rect.width),
@@ -191,6 +199,17 @@ function distanceToRoundedRect(point, rect, radius = 0) {
191
199
  const cy = clamp(point.y, rect.y + r, rect.y + rect.height - r);
192
200
  return Math.hypot(point.x - cx, point.y - cy) - r;
193
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
+ }
194
213
  function directionFromPosition(position) {
195
214
  return {
196
215
  x: position.includes("right") ? 1 : position.includes("left") ? -1 : 0,
@@ -237,7 +256,7 @@ function annotationAnchor(position, rect, offset = 12) {
237
256
 
238
257
  // src/renderer.js
239
258
  var SVG_NS = "http://www.w3.org/2000/svg";
240
- var HANDWRITTEN_FONT = '"Caveat", "Kalam", "Patrick Hand", cursive';
259
+ var HANDWRITTEN_FONT = '"Caveat", "Kalam", "Patrick Hand", "Bradley Hand", "Segoe Script", "Comic Sans MS", cursive';
241
260
  var NOTE_FONT_SIZE = 19;
242
261
  function clearSvg(svg) {
243
262
  while (svg.firstChild) {
@@ -554,14 +573,86 @@ function drawBorder(svg, rect, options, seed) {
554
573
  });
555
574
  }
556
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
+ }
557
630
  function normalize(vector) {
558
631
  const length = Math.hypot(vector.x, vector.y);
559
632
  if (!length) return { x: 0, y: 0 };
560
633
  return { x: vector.x / length, y: vector.y / length };
561
634
  }
562
- function arrowStartFromNote(origin, target) {
635
+ var NOTE_CLEARANCE = 10;
636
+ var TIP_GAP = 11;
637
+ function arrowStartFromNote(origin, target, from) {
563
638
  const cx = origin.x + origin.width / 2;
564
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
+ }
565
656
  const dx = target.x - cx;
566
657
  const dy = target.y - cy;
567
658
  const hw = origin.width / 2 || 1e-3;
@@ -569,15 +660,16 @@ function arrowStartFromNote(origin, target) {
569
660
  const sx = Math.sign(dx) || 1;
570
661
  const sy = Math.sign(dy) || 1;
571
662
  if (Math.abs(dy) / hh >= Math.abs(dx) / hw) {
572
- return { x: cx - sx * hw * 0.72, y: cy + sy * (hh + 7) };
663
+ return { x: cx - sx * (hw + 10), y: cy + sy * (hh + 4) };
573
664
  }
574
- return { x: cx + sx * (hw + 7), y: cy + sy * hh * 0.2 };
665
+ return { x: cx + sx * (hw + 8), y: cy + sy * (hh + 6) };
575
666
  }
576
667
  function arrowEndpoints(rect, arrowOptions, random, origin) {
577
668
  const center = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
669
+ const from = arrowOptions.from || "note";
578
670
  const anchored = Boolean(origin && origin.width > 0 && origin.height > 0);
579
- let start = anchored ? arrowStartFromNote(origin, center) : annotationAnchor(
580
- arrowOptions.from || "top-right",
671
+ let start = anchored ? arrowStartFromNote(origin, center, from) : annotationAnchor(
672
+ from === "note" ? "top-right" : from,
581
673
  rect,
582
674
  38 + random() * 12
583
675
  );
@@ -589,7 +681,7 @@ function arrowEndpoints(rect, arrowOptions, random, origin) {
589
681
  const near = nearestPointOnRect(rect, start);
590
682
  let outward = normalize({ x: start.x - near.x, y: start.y - near.y });
591
683
  if (!outward.x && !outward.y) outward = { x: 0, y: -1 };
592
- const gap = 6 + random() * 3;
684
+ const gap = TIP_GAP + random() * 5;
593
685
  end = { x: near.x + outward.x * gap, y: near.y + outward.y * gap };
594
686
  } else {
595
687
  end = pointFromPosition(toPos, rect);
@@ -602,6 +694,35 @@ function arrowEndpoints(rect, arrowOptions, random, origin) {
602
694
  }
603
695
  return { start, end };
604
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
+ }
605
726
  function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
606
727
  const random = createRandom(seed + 17);
607
728
  const { start, end } = arrowEndpoints(rect, arrowOptions, random, origin);
@@ -623,13 +744,33 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
623
744
  x: rect.x + rect.width / 2 - mid.x,
624
745
  y: rect.y + rect.height / 2 - mid.y
625
746
  };
626
- 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;
627
749
  const bow = Math.min(distance * (0.16 + random() * 0.08), 30) * sign;
628
750
  const c1x = start.x + dx * 0.28 + perpendicular.x * bow;
629
751
  const c1y = start.y + dy * 0.28 + perpendicular.y * bow;
630
752
  const c2x = start.x + dx * 0.72 + perpendicular.x * bow * 0.85;
631
753
  const c2y = start.y + dy * 0.72 + perpendicular.y * bow * 0.85;
632
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
+ }
633
774
  shaft = `M ${fmt(start.x)} ${fmt(start.y)} C ${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(end.x)} ${fmt(end.y)}`;
634
775
  if (style.roughness >= 1.1) {
635
776
  const drift = () => (random() - 0.5) * 1.4;
@@ -644,31 +785,8 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
644
785
  tipAngle = Math.atan2(end.y - c2y, end.x - c2x);
645
786
  }
646
787
  appendPath(svg, shaft, { ...style, dashed });
647
- const headLength = 9 + style.strokeWidth * 1.4;
648
- const spread = 0.46 + random() * 0.1;
649
- for (const side of [-1, 1]) {
650
- const angle = tipAngle + spread * side;
651
- const tail = {
652
- x: end.x - headLength * Math.cos(angle),
653
- y: end.y - headLength * Math.sin(angle)
654
- };
655
- appendPath(
656
- svg,
657
- roughLine(tail.x, tail.y, end.x, end.y, style.roughness * 0.5, random, {
658
- steps: 2,
659
- bow: 0.35 * side
660
- }),
661
- { ...style, dashed: false }
662
- );
663
- }
664
- const xs = hull.map((p) => p.x);
665
- const ys = hull.map((p) => p.y);
666
- const bounds = {
667
- x: Math.min(...xs),
668
- y: Math.min(...ys),
669
- width: Math.max(...xs) - Math.min(...xs),
670
- height: Math.max(...ys) - Math.min(...ys)
671
- };
788
+ drawArrowHead(svg, end, tipAngle, style, random, distance);
789
+ const bounds = boundsOf(hull);
672
790
  return { start, end, bounds };
673
791
  }
674
792
  function drawUnderline(svg, x, y, width, style, random) {
@@ -706,46 +824,85 @@ function drawUnderline(svg, x, y, width, style, random) {
706
824
  }
707
825
  );
708
826
  }
709
- 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 = {}) {
710
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;
711
857
  const group = document.createElementNS(SVG_NS, "g");
712
858
  const textEl = document.createElementNS(SVG_NS, "text");
713
- textEl.textContent = text;
714
859
  textEl.setAttribute("x", "0");
715
860
  textEl.setAttribute("y", "0");
716
861
  textEl.setAttribute("fill", style.color);
717
862
  textEl.setAttribute("opacity", String(style.opacity));
718
863
  textEl.setAttribute("font-size", String(fontSize));
719
- textEl.setAttribute("font-family", HANDWRITTEN_FONT);
864
+ textEl.setAttribute("font-family", style.fontFamily || HANDWRITTEN_FONT);
720
865
  textEl.setAttribute("font-weight", "600");
721
866
  textEl.setAttribute("letter-spacing", "0.4");
722
867
  textEl.setAttribute("text-anchor", "start");
723
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
+ });
724
877
  group.appendChild(textEl);
725
878
  svg.appendChild(group);
726
- let width = 0;
727
- try {
728
- const box = textEl.getBBox();
729
- if (box && box.width > 0) width = box.width;
730
- } catch {
731
- }
732
- if (!width) {
733
- try {
734
- const measured = textEl.getComputedTextLength();
735
- if (Number.isFinite(measured) && measured > 0) width = measured;
736
- } catch {
737
- }
738
- }
739
- 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);
740
883
  const ascent = fontSize * 0.74;
741
884
  const descent = fontSize * 0.26;
742
- 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
+ };
743
899
  }
744
900
  function placeNoteText(note, box, style, seed, options = {}) {
745
901
  const random = createRandom(seed + 55);
746
902
  const tilt = options.tilt ?? (random() - 0.5) * 7;
747
903
  note.textEl.setAttribute("x", fmt(box.x));
748
904
  note.textEl.setAttribute("y", fmt(box.y + note.ascent));
905
+ for (const tspan of note.tspans) tspan.setAttribute("x", fmt(box.x));
749
906
  const cx = box.x + box.width / 2;
750
907
  const cy = box.y + box.height / 2;
751
908
  note.group.setAttribute(
@@ -753,11 +910,12 @@ function placeNoteText(note, box, style, seed, options = {}) {
753
910
  `rotate(${tilt.toFixed(2)} ${fmt(cx)} ${fmt(cy)})`
754
911
  );
755
912
  if (options.underline !== false) {
913
+ const lastWidth = note.lineWidths[note.lineWidths.length - 1] ?? box.width;
756
914
  drawUnderline(
757
915
  note.group,
758
916
  box.x + 1,
759
917
  box.y + box.height + 2,
760
- box.width - 2,
918
+ lastWidth - 2,
761
919
  {
762
920
  color: style.color,
763
921
  strokeWidth: style.strokeWidth || 1.4,
@@ -789,25 +947,6 @@ function createOverlaySvg(left, top, width, height) {
789
947
 
790
948
  // src/annotations.js
791
949
  var NOTE_GAP = 14;
792
- function mirror(position, axis) {
793
- if (axis === "x") {
794
- if (position.includes("right")) return position.replace("right", "left");
795
- if (position.includes("left")) return position.replace("left", "right");
796
- return position;
797
- }
798
- if (position.includes("top")) return position.replace("top", "bottom");
799
- if (position.includes("bottom")) return position.replace("bottom", "top");
800
- return position;
801
- }
802
- function candidatePositions(position) {
803
- const candidates = [
804
- position,
805
- mirror(position, "x"),
806
- mirror(position, "y"),
807
- mirror(mirror(position, "x"), "y")
808
- ];
809
- return candidates.filter((value, index) => candidates.indexOf(value) === index);
810
- }
811
950
  function boxForPosition(rect, position, size, gap) {
812
951
  const direction = directionFromPosition(position);
813
952
  const guard = inflateRect(rect, gap);
@@ -826,75 +965,67 @@ function boxForPosition(rect, position, size, gap) {
826
965
  }
827
966
  return { x, y, width: size.width, height: size.height };
828
967
  }
829
- function placeNote(rect, position, size, gap, viewport) {
830
- const guard = inflateRect(rect, gap * 0.7);
831
- let best = null;
832
- candidatePositions(position).forEach((candidate, index) => {
833
- const box = boxForPosition(rect, candidate, size, gap);
834
- const penalty = intersectionArea(box, guard) * 6 + outsideArea(box, viewport) + index * 0.5;
835
- if (!best || penalty < best.penalty) {
836
- best = { box, penalty, position: candidate };
837
- }
838
- });
839
- return best;
840
- }
841
- function clampToViewport(box, guard, viewport) {
842
- if (!viewport || viewport.width <= 0 || viewport.height <= 0) return box;
843
- const shifted = {
844
- ...box,
845
- x: clamp(
846
- box.x,
847
- viewport.x,
848
- Math.max(viewport.x, viewport.x + viewport.width - box.width)
849
- ),
850
- y: clamp(
851
- box.y,
852
- viewport.y,
853
- Math.max(viewport.y, viewport.y + viewport.height - box.height)
854
- )
855
- };
856
- if (intersectionArea(shifted, guard) > intersectionArea(box, guard)) {
857
- 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);
858
974
  }
859
- 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 };
860
989
  }
861
990
  function renderAnnotation(svg, rect, noteOptions, style, seed = 1, layout = {}) {
862
991
  if (!noteOptions?.text) return null;
863
- const viewport = layout.viewport ?? null;
992
+ const band = layout.band ?? null;
864
993
  const position = isValidPosition(noteOptions.position) ? noteOptions.position : "top-right";
865
994
  const random = createRandom(seed + 9);
866
- const note = createNoteText(svg, noteOptions.text, style);
995
+ const note = createNoteText(svg, noteOptions.text, style, {
996
+ wordsPerLine: noteOptions.wordsPerLine
997
+ });
867
998
  const tilt = (random() - 0.45) * 7;
868
999
  const tiltPad = Math.abs(Math.sin(tilt * Math.PI / 180)) * note.width * 0.5;
869
1000
  const size = {
870
1001
  width: note.width,
871
- height: note.ascent + note.descent + (noteOptions.underline === false ? 0 : 5)
1002
+ height: note.height + (noteOptions.underline === false ? 0 : 5)
872
1003
  };
873
1004
  const gap = (layout.gap ?? NOTE_GAP) + random() * 6;
874
- const placed = placeNote(
1005
+ const placed = boxForPosition(
875
1006
  rect,
876
1007
  position,
877
1008
  { width: size.width, height: size.height + tiltPad },
878
- gap,
879
- viewport
1009
+ gap
880
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
+ };
881
1016
  const guard = inflateRect(rect, gap * 0.7);
882
- const outer = clampToViewport(
883
- { ...placed.box, height: size.height + tiltPad },
884
- guard,
885
- viewport
886
- );
1017
+ const outer = nudgeIntoBand(shifted, guard, band);
887
1018
  const box = {
888
1019
  x: outer.x,
889
1020
  y: outer.y + tiltPad / 2,
890
1021
  width: size.width,
891
- height: note.ascent + note.descent
1022
+ height: note.height
892
1023
  };
893
1024
  placeNoteText(note, box, style, seed, {
894
1025
  tilt,
895
1026
  underline: noteOptions.underline !== false
896
1027
  });
897
- return { group: note.group, box: inflateRect(outer, 4), position: placed.position };
1028
+ return { group: note.group, box: inflateRect(outer, 4), position };
898
1029
  }
899
1030
 
900
1031
  // src/decorations.js
@@ -1289,16 +1420,34 @@ function renderDecorations(svg, rect, decorationOptions, style, seed, context =
1289
1420
  }
1290
1421
  }
1291
1422
 
1423
+ // src/fonts.js
1424
+ var FONT_NAME = "Caveat";
1425
+ var FONT_HREF = "https://fonts.googleapis.com/css2?family=Caveat:wght@400;600&display=swap";
1426
+ var MARKER = "data-freehand-font";
1427
+ var requested = false;
1428
+ function ensureHandwrittenFont() {
1429
+ if (requested) return;
1430
+ if (typeof document === "undefined" || !document.head) return;
1431
+ requested = true;
1432
+ try {
1433
+ if (document.fonts?.check?.(`600 19px "${FONT_NAME}"`)) return;
1434
+ } catch {
1435
+ }
1436
+ if (document.querySelector(`link[${MARKER}]`)) return;
1437
+ const link = document.createElement("link");
1438
+ link.rel = "stylesheet";
1439
+ link.href = FONT_HREF;
1440
+ link.setAttribute(MARKER, "");
1441
+ document.head.appendChild(link);
1442
+ }
1443
+
1292
1444
  // src/doodle.js
1293
1445
  var instances = /* @__PURE__ */ new WeakMap();
1294
- function localViewport(overlayRect, inset = 8) {
1446
+ function localBand(overlayRect, inset = 8) {
1295
1447
  const width = window.innerWidth || document.documentElement.clientWidth || 0;
1296
- const height = window.innerHeight || document.documentElement.clientHeight || 0;
1297
1448
  return {
1298
1449
  x: -overlayRect.x + inset,
1299
- y: -overlayRect.y + inset,
1300
- width: Math.max(0, width - inset * 2),
1301
- height: Math.max(0, height - inset * 2)
1450
+ width: Math.max(0, width - inset * 2)
1302
1451
  };
1303
1452
  }
1304
1453
  var DoodleOverlay = class {
@@ -1340,7 +1489,12 @@ var DoodleOverlay = class {
1340
1489
  }
1341
1490
  window.addEventListener("resize", this.onResize, { passive: true });
1342
1491
  this.scrollTargets.push({ target: window, type: "resize" });
1343
- document.fonts?.ready?.then(() => this.scheduleUpdate()).catch(() => {
1492
+ if (this.options.note && this.options.autoLoadFont && !this.options.fontFamily) {
1493
+ ensureHandwrittenFont();
1494
+ }
1495
+ this.onFontsLoaded = () => this.scheduleUpdate();
1496
+ document.fonts?.addEventListener?.("loadingdone", this.onFontsLoaded);
1497
+ document.fonts?.ready?.then(this.onFontsLoaded).catch(() => {
1344
1498
  });
1345
1499
  this.update();
1346
1500
  }
@@ -1383,11 +1537,17 @@ var DoodleOverlay = class {
1383
1537
  this.svg.style.width = `${overlayRect.width}px`;
1384
1538
  this.svg.style.height = `${overlayRect.height}px`;
1385
1539
  const strokeStyle = { color, strokeWidth, roughness, opacity };
1386
- const noteStyle = { color, opacity, strokeWidth, roughness };
1540
+ const noteStyle = {
1541
+ color,
1542
+ opacity,
1543
+ strokeWidth,
1544
+ roughness,
1545
+ fontFamily: this.options.fontFamily
1546
+ };
1387
1547
  const noteGroups = [];
1388
1548
  const noteLayout = {
1389
- viewport: localViewport(overlayRect),
1390
- gap: this.options.arrow ? 32 : 14
1549
+ band: localBand(overlayRect),
1550
+ gap: this.options.arrow ? 40 : 14
1391
1551
  };
1392
1552
  targets.forEach((target, index) => {
1393
1553
  const absolute = absoluteRects[index];
@@ -1458,6 +1618,10 @@ var DoodleOverlay = class {
1458
1618
  this.mutationObserver.disconnect();
1459
1619
  this.mutationObserver = null;
1460
1620
  }
1621
+ if (this.onFontsLoaded) {
1622
+ document.fonts?.removeEventListener?.("loadingdone", this.onFontsLoaded);
1623
+ this.onFontsLoaded = null;
1624
+ }
1461
1625
  for (const { target, type } of this.scrollTargets) {
1462
1626
  target.removeEventListener(type, type === "scroll" ? this.onScroll : this.onResize);
1463
1627
  }