@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.js CHANGED
@@ -17,14 +17,19 @@ var DEFAULT_OPTIONS = {
17
17
  color: "#ffffff",
18
18
  strokeWidth: 1.5,
19
19
  roughness: 1.5,
20
- padding: 8,
20
+ // The frame traces the element's own edge. Raise this to stand it off.
21
+ padding: 0,
21
22
  radius: null,
22
23
  opacity: 0.9,
23
24
  children: null,
24
25
  note: null,
25
26
  arrow: false,
26
27
  decorations: false,
27
- addBreaks: false
28
+ addBreaks: false,
29
+ /** Override the handwriting stack, e.g. a next/font CSS variable. */
30
+ fontFamily: null,
31
+ /** Fetch Caveat when the page has not provided it. */
32
+ autoLoadFont: true
28
33
  };
29
34
  function resolveElement(target) {
30
35
  if (!target) return null;
@@ -41,9 +46,16 @@ function mergeOptions(options = {}) {
41
46
  if (merged.note && typeof merged.note === "string") {
42
47
  merged.note = { text: merged.note, position: "top-right" };
43
48
  }
44
- if (merged.arrow === true) {
45
- const from = merged.note && typeof merged.note === "object" && merged.note.position ? merged.note.position : "top-right";
46
- merged.arrow = { from, to: "edge", style: "curved" };
49
+ if (merged.arrow) {
50
+ const config = merged.arrow === true ? {} : merged.arrow;
51
+ merged.arrow = {
52
+ // "note" launches from the annotation, a position name from that side of
53
+ // the element. Normalised for every arrow, not just `arrow: true`, so an
54
+ // explicit `from` is honoured whether or not there is a note.
55
+ from: config.from ?? (merged.note ? "note" : "top-right"),
56
+ to: config.to ?? "edge",
57
+ style: config.style ?? "curved"
58
+ };
47
59
  }
48
60
  if (merged.decorations === true) {
49
61
  merged.decorations = { count: null, types: null };
@@ -188,10 +200,6 @@ function intersectionArea(a, b) {
188
200
  const h = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y);
189
201
  return w > 0 && h > 0 ? w * h : 0;
190
202
  }
191
- function outsideArea(box, bounds) {
192
- if (!bounds) return 0;
193
- return Math.max(0, box.width * box.height - intersectionArea(box, bounds));
194
- }
195
203
  function nearestPointOnRect(rect, point) {
196
204
  return {
197
205
  x: clamp(point.x, rect.x, rect.x + rect.width),
@@ -204,6 +212,17 @@ function distanceToRoundedRect(point, rect, radius = 0) {
204
212
  const cy = clamp(point.y, rect.y + r, rect.y + rect.height - r);
205
213
  return Math.hypot(point.x - cx, point.y - cy) - r;
206
214
  }
215
+ function pointOnBoxToward(box, target) {
216
+ const cx = box.x + box.width / 2;
217
+ const cy = box.y + box.height / 2;
218
+ const dx = target.x - cx;
219
+ const dy = target.y - cy;
220
+ if (dx === 0 && dy === 0) return { x: cx, y: cy };
221
+ const hw = box.width / 2 || 1e-3;
222
+ const hh = box.height / 2 || 1e-3;
223
+ const scale = 1 / Math.max(Math.abs(dx) / hw, Math.abs(dy) / hh);
224
+ return { x: cx + dx * scale, y: cy + dy * scale };
225
+ }
207
226
  function directionFromPosition(position) {
208
227
  return {
209
228
  x: position.includes("right") ? 1 : position.includes("left") ? -1 : 0,
@@ -250,7 +269,7 @@ function annotationAnchor(position, rect, offset = 12) {
250
269
 
251
270
  // src/renderer.js
252
271
  var SVG_NS = "http://www.w3.org/2000/svg";
253
- var HANDWRITTEN_FONT = '"Caveat", "Kalam", "Patrick Hand", cursive';
272
+ var HANDWRITTEN_FONT = '"Caveat", "Kalam", "Patrick Hand", "Bradley Hand", "Segoe Script", "Comic Sans MS", cursive';
254
273
  var NOTE_FONT_SIZE = 19;
255
274
  function clearSvg(svg) {
256
275
  while (svg.firstChild) {
@@ -567,14 +586,86 @@ function drawBorder(svg, rect, options, seed) {
567
586
  });
568
587
  }
569
588
  }
589
+ function cubicAt(p0, c1, c2, p3, t) {
590
+ const u = 1 - t;
591
+ const a = u * u * u;
592
+ const b = 3 * u * u * t;
593
+ const c = 3 * u * t * t;
594
+ const d = t * t * t;
595
+ return {
596
+ x: a * p0.x + b * c1.x + c * c2.x + d * p3.x,
597
+ y: a * p0.y + b * c1.y + c * c2.y + d * p3.y
598
+ };
599
+ }
600
+ function cubicTangentAt(p0, c1, c2, p3, t) {
601
+ const u = 1 - t;
602
+ const a = 3 * u * u;
603
+ const b = 6 * u * t;
604
+ const c = 3 * t * t;
605
+ return {
606
+ x: a * (c1.x - p0.x) + b * (c2.x - c1.x) + c * (p3.x - c2.x),
607
+ y: a * (c1.y - p0.y) + b * (c2.y - c1.y) + c * (p3.y - c2.y)
608
+ };
609
+ }
610
+ function loopedCurvePoints(start, c1, c2, end, radius, random) {
611
+ const at = (t) => cubicAt(start, c1, c2, end, t);
612
+ const tangentAt = (t) => normalize(cubicTangentAt(start, c1, c2, end, t));
613
+ const knot = 0.46 + random() * 0.12;
614
+ const points = [];
615
+ const lead = Math.max(2, Math.round(knot * 16));
616
+ for (let i = 0; i <= lead; i++) points.push(at(knot * i / lead));
617
+ const pivot = at(knot);
618
+ const heading = tangentAt(knot);
619
+ const normal = { x: -heading.y, y: heading.x };
620
+ const side = random() < 0.5 ? 1 : -1;
621
+ const centre = {
622
+ x: pivot.x + normal.x * radius * side,
623
+ y: pivot.y + normal.y * radius * side
624
+ };
625
+ const from = Math.atan2(pivot.y - centre.y, pivot.x - centre.x);
626
+ const turns = Math.PI * 2 * (0.97 + random() * 0.08);
627
+ const steps = 18;
628
+ for (let i = 1; i <= steps; i++) {
629
+ const progress = i / steps;
630
+ const angle = from + turns * progress * side;
631
+ const drift = radius * 0.55 * progress;
632
+ points.push({
633
+ x: centre.x + Math.cos(angle) * radius + heading.x * drift,
634
+ y: centre.y + Math.sin(angle) * radius + heading.y * drift
635
+ });
636
+ }
637
+ const tail = 14;
638
+ for (let i = 1; i <= tail; i++) {
639
+ points.push(at(knot + (1 - knot) * (i / tail)));
640
+ }
641
+ return points;
642
+ }
570
643
  function normalize(vector) {
571
644
  const length = Math.hypot(vector.x, vector.y);
572
645
  if (!length) return { x: 0, y: 0 };
573
646
  return { x: vector.x / length, y: vector.y / length };
574
647
  }
575
- function arrowStartFromNote(origin, target) {
648
+ var NOTE_CLEARANCE = 10;
649
+ var TIP_GAP = 11;
650
+ function arrowStartFromNote(origin, target, from) {
576
651
  const cx = origin.x + origin.width / 2;
577
652
  const cy = origin.y + origin.height / 2;
653
+ if (from && from !== "note") {
654
+ const point = pointFromPosition(from, origin);
655
+ const away = directionFromPosition(from);
656
+ if (!away.x && !away.y) {
657
+ const edge = pointOnBoxToward(origin, target);
658
+ const outward = normalize({ x: edge.x - cx, y: edge.y - cy });
659
+ return {
660
+ x: edge.x + outward.x * NOTE_CLEARANCE,
661
+ y: edge.y + outward.y * NOTE_CLEARANCE
662
+ };
663
+ }
664
+ return {
665
+ x: point.x + away.x * NOTE_CLEARANCE,
666
+ y: point.y + away.y * NOTE_CLEARANCE
667
+ };
668
+ }
578
669
  const dx = target.x - cx;
579
670
  const dy = target.y - cy;
580
671
  const hw = origin.width / 2 || 1e-3;
@@ -582,15 +673,16 @@ function arrowStartFromNote(origin, target) {
582
673
  const sx = Math.sign(dx) || 1;
583
674
  const sy = Math.sign(dy) || 1;
584
675
  if (Math.abs(dy) / hh >= Math.abs(dx) / hw) {
585
- return { x: cx - sx * hw * 0.72, y: cy + sy * (hh + 7) };
676
+ return { x: cx - sx * (hw + 10), y: cy + sy * (hh + 4) };
586
677
  }
587
- return { x: cx + sx * (hw + 7), y: cy + sy * hh * 0.2 };
678
+ return { x: cx + sx * (hw + 8), y: cy + sy * (hh + 6) };
588
679
  }
589
680
  function arrowEndpoints(rect, arrowOptions, random, origin) {
590
681
  const center = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
682
+ const from = arrowOptions.from || "note";
591
683
  const anchored = Boolean(origin && origin.width > 0 && origin.height > 0);
592
- let start = anchored ? arrowStartFromNote(origin, center) : annotationAnchor(
593
- arrowOptions.from || "top-right",
684
+ let start = anchored ? arrowStartFromNote(origin, center, from) : annotationAnchor(
685
+ from === "note" ? "top-right" : from,
594
686
  rect,
595
687
  38 + random() * 12
596
688
  );
@@ -602,7 +694,7 @@ function arrowEndpoints(rect, arrowOptions, random, origin) {
602
694
  const near = nearestPointOnRect(rect, start);
603
695
  let outward = normalize({ x: start.x - near.x, y: start.y - near.y });
604
696
  if (!outward.x && !outward.y) outward = { x: 0, y: -1 };
605
- const gap = 6 + random() * 3;
697
+ const gap = TIP_GAP + random() * 5;
606
698
  end = { x: near.x + outward.x * gap, y: near.y + outward.y * gap };
607
699
  } else {
608
700
  end = pointFromPosition(toPos, rect);
@@ -615,6 +707,35 @@ function arrowEndpoints(rect, arrowOptions, random, origin) {
615
707
  }
616
708
  return { start, end };
617
709
  }
710
+ function drawArrowHead(svg, tip, angle, style, random, shaftLength = Infinity) {
711
+ const length = Math.min(9 + style.strokeWidth * 1.4, shaftLength * 0.3);
712
+ const spread = 0.46 + random() * 0.1;
713
+ for (const side of [-1, 1]) {
714
+ const armAngle = angle + spread * side;
715
+ const tail = {
716
+ x: tip.x - length * Math.cos(armAngle),
717
+ y: tip.y - length * Math.sin(armAngle)
718
+ };
719
+ appendPath(
720
+ svg,
721
+ roughLine(tail.x, tail.y, tip.x, tip.y, style.roughness * 0.5, random, {
722
+ steps: 2,
723
+ bow: 0.35 * side
724
+ }),
725
+ { ...style, dashed: false }
726
+ );
727
+ }
728
+ }
729
+ function boundsOf(points) {
730
+ const xs = points.map((p) => p.x);
731
+ const ys = points.map((p) => p.y);
732
+ return {
733
+ x: Math.min(...xs),
734
+ y: Math.min(...ys),
735
+ width: Math.max(...xs) - Math.min(...xs),
736
+ height: Math.max(...ys) - Math.min(...ys)
737
+ };
738
+ }
618
739
  function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
619
740
  const random = createRandom(seed + 17);
620
741
  const { start, end } = arrowEndpoints(rect, arrowOptions, random, origin);
@@ -636,13 +757,33 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
636
757
  x: rect.x + rect.width / 2 - mid.x,
637
758
  y: rect.y + rect.height / 2 - mid.y
638
759
  };
639
- const sign = perpendicular.x * towardCenter.x + perpendicular.y * towardCenter.y > 0 ? -1 : 1;
760
+ const lateral = perpendicular.x * towardCenter.x + perpendicular.y * towardCenter.y;
761
+ const sign = Math.abs(lateral) > distance * 0.12 ? lateral > 0 ? -1 : 1 : random() < 0.5 ? -1 : 1;
640
762
  const bow = Math.min(distance * (0.16 + random() * 0.08), 30) * sign;
641
763
  const c1x = start.x + dx * 0.28 + perpendicular.x * bow;
642
764
  const c1y = start.y + dy * 0.28 + perpendicular.y * bow;
643
765
  const c2x = start.x + dx * 0.72 + perpendicular.x * bow * 0.85;
644
766
  const c2y = start.y + dy * 0.72 + perpendicular.y * bow * 0.85;
645
767
  hull.push({ x: c1x, y: c1y }, { x: c2x, y: c2y });
768
+ if (arrowOptions.style === "looped") {
769
+ const radius = clamp(distance * 0.11, 7, 17);
770
+ const points = loopedCurvePoints(
771
+ start,
772
+ { x: c1x, y: c1y },
773
+ { x: c2x, y: c2y },
774
+ end,
775
+ radius,
776
+ random
777
+ );
778
+ shaft = catmullRomPath(points, false);
779
+ hull.push(...points);
780
+ const last = points[points.length - 1];
781
+ const prior = points[points.length - 2] ?? start;
782
+ tipAngle = Math.atan2(last.y - prior.y, last.x - prior.x);
783
+ appendPath(svg, shaft, { ...style, dashed });
784
+ drawArrowHead(svg, end, tipAngle, style, random, distance);
785
+ return { start, end, bounds: boundsOf(hull) };
786
+ }
646
787
  shaft = `M ${fmt(start.x)} ${fmt(start.y)} C ${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(end.x)} ${fmt(end.y)}`;
647
788
  if (style.roughness >= 1.1) {
648
789
  const drift = () => (random() - 0.5) * 1.4;
@@ -657,31 +798,8 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
657
798
  tipAngle = Math.atan2(end.y - c2y, end.x - c2x);
658
799
  }
659
800
  appendPath(svg, shaft, { ...style, dashed });
660
- const headLength = 9 + style.strokeWidth * 1.4;
661
- const spread = 0.46 + random() * 0.1;
662
- for (const side of [-1, 1]) {
663
- const angle = tipAngle + spread * side;
664
- const tail = {
665
- x: end.x - headLength * Math.cos(angle),
666
- y: end.y - headLength * Math.sin(angle)
667
- };
668
- appendPath(
669
- svg,
670
- roughLine(tail.x, tail.y, end.x, end.y, style.roughness * 0.5, random, {
671
- steps: 2,
672
- bow: 0.35 * side
673
- }),
674
- { ...style, dashed: false }
675
- );
676
- }
677
- const xs = hull.map((p) => p.x);
678
- const ys = hull.map((p) => p.y);
679
- const bounds = {
680
- x: Math.min(...xs),
681
- y: Math.min(...ys),
682
- width: Math.max(...xs) - Math.min(...xs),
683
- height: Math.max(...ys) - Math.min(...ys)
684
- };
801
+ drawArrowHead(svg, end, tipAngle, style, random, distance);
802
+ const bounds = boundsOf(hull);
685
803
  return { start, end, bounds };
686
804
  }
687
805
  function drawUnderline(svg, x, y, width, style, random) {
@@ -719,46 +837,85 @@ function drawUnderline(svg, x, y, width, style, random) {
719
837
  }
720
838
  );
721
839
  }
722
- function createNoteText(svg, text, style) {
840
+ var DEFAULT_WORDS_PER_LINE = 2;
841
+ function wrapWords(text, perLine) {
842
+ const lines = [];
843
+ for (const paragraph of String(text).split(/\r?\n/)) {
844
+ const words = paragraph.trim().split(/\s+/).filter(Boolean);
845
+ if (!words.length) continue;
846
+ const size = Number.isFinite(perLine) && perLine >= 1 ? Math.round(perLine) : words.length;
847
+ for (let i = 0; i < words.length; i += size) {
848
+ lines.push(words.slice(i, i + size).join(" "));
849
+ }
850
+ }
851
+ return lines.length ? lines : [String(text)];
852
+ }
853
+ function measureWidth(element, content, fontSize) {
854
+ try {
855
+ const box = element.getBBox();
856
+ if (box && box.width > 0) return box.width;
857
+ } catch {
858
+ }
859
+ try {
860
+ const measured = element.getComputedTextLength();
861
+ if (Number.isFinite(measured) && measured > 0) return measured;
862
+ } catch {
863
+ }
864
+ return content.length * fontSize * 0.45;
865
+ }
866
+ function createNoteText(svg, text, style, options = {}) {
723
867
  const fontSize = style.fontSize ?? NOTE_FONT_SIZE;
868
+ const lines = wrapWords(text, options.wordsPerLine ?? DEFAULT_WORDS_PER_LINE);
869
+ const lineHeight = fontSize * 1.05;
724
870
  const group = document.createElementNS(SVG_NS, "g");
725
871
  const textEl = document.createElementNS(SVG_NS, "text");
726
- textEl.textContent = text;
727
872
  textEl.setAttribute("x", "0");
728
873
  textEl.setAttribute("y", "0");
729
874
  textEl.setAttribute("fill", style.color);
730
875
  textEl.setAttribute("opacity", String(style.opacity));
731
876
  textEl.setAttribute("font-size", String(fontSize));
732
- textEl.setAttribute("font-family", HANDWRITTEN_FONT);
877
+ textEl.setAttribute("font-family", style.fontFamily || HANDWRITTEN_FONT);
733
878
  textEl.setAttribute("font-weight", "600");
734
879
  textEl.setAttribute("letter-spacing", "0.4");
735
880
  textEl.setAttribute("text-anchor", "start");
736
881
  textEl.setAttribute("dominant-baseline", "auto");
882
+ const tspans = lines.map((line, index) => {
883
+ const tspan = document.createElementNS(SVG_NS, "tspan");
884
+ tspan.textContent = line;
885
+ tspan.setAttribute("x", "0");
886
+ tspan.setAttribute("dy", index === 0 ? "0" : String(lineHeight));
887
+ textEl.appendChild(tspan);
888
+ return tspan;
889
+ });
737
890
  group.appendChild(textEl);
738
891
  svg.appendChild(group);
739
- let width = 0;
740
- try {
741
- const box = textEl.getBBox();
742
- if (box && box.width > 0) width = box.width;
743
- } catch {
744
- }
745
- if (!width) {
746
- try {
747
- const measured = textEl.getComputedTextLength();
748
- if (Number.isFinite(measured) && measured > 0) width = measured;
749
- } catch {
750
- }
751
- }
752
- if (!width) width = text.length * fontSize * 0.45;
892
+ const lineWidths = tspans.map(
893
+ (tspan, index) => measureWidth(tspan, lines[index], fontSize)
894
+ );
895
+ const width = Math.max(...lineWidths);
753
896
  const ascent = fontSize * 0.74;
754
897
  const descent = fontSize * 0.26;
755
- return { group, textEl, width, ascent, descent, fontSize };
898
+ const height = (lines.length - 1) * lineHeight + ascent + descent;
899
+ return {
900
+ group,
901
+ textEl,
902
+ tspans,
903
+ lines,
904
+ lineWidths,
905
+ lineHeight,
906
+ width,
907
+ height,
908
+ ascent,
909
+ descent,
910
+ fontSize
911
+ };
756
912
  }
757
913
  function placeNoteText(note, box, style, seed, options = {}) {
758
914
  const random = createRandom(seed + 55);
759
915
  const tilt = options.tilt ?? (random() - 0.5) * 7;
760
916
  note.textEl.setAttribute("x", fmt(box.x));
761
917
  note.textEl.setAttribute("y", fmt(box.y + note.ascent));
918
+ for (const tspan of note.tspans) tspan.setAttribute("x", fmt(box.x));
762
919
  const cx = box.x + box.width / 2;
763
920
  const cy = box.y + box.height / 2;
764
921
  note.group.setAttribute(
@@ -766,11 +923,12 @@ function placeNoteText(note, box, style, seed, options = {}) {
766
923
  `rotate(${tilt.toFixed(2)} ${fmt(cx)} ${fmt(cy)})`
767
924
  );
768
925
  if (options.underline !== false) {
926
+ const lastWidth = note.lineWidths[note.lineWidths.length - 1] ?? box.width;
769
927
  drawUnderline(
770
928
  note.group,
771
929
  box.x + 1,
772
930
  box.y + box.height + 2,
773
- box.width - 2,
931
+ lastWidth - 2,
774
932
  {
775
933
  color: style.color,
776
934
  strokeWidth: style.strokeWidth || 1.4,
@@ -802,25 +960,6 @@ function createOverlaySvg(left, top, width, height) {
802
960
 
803
961
  // src/annotations.js
804
962
  var NOTE_GAP = 14;
805
- function mirror(position, axis) {
806
- if (axis === "x") {
807
- if (position.includes("right")) return position.replace("right", "left");
808
- if (position.includes("left")) return position.replace("left", "right");
809
- return position;
810
- }
811
- if (position.includes("top")) return position.replace("top", "bottom");
812
- if (position.includes("bottom")) return position.replace("bottom", "top");
813
- return position;
814
- }
815
- function candidatePositions(position) {
816
- const candidates = [
817
- position,
818
- mirror(position, "x"),
819
- mirror(position, "y"),
820
- mirror(mirror(position, "x"), "y")
821
- ];
822
- return candidates.filter((value, index) => candidates.indexOf(value) === index);
823
- }
824
963
  function boxForPosition(rect, position, size, gap) {
825
964
  const direction = directionFromPosition(position);
826
965
  const guard = inflateRect(rect, gap);
@@ -839,75 +978,67 @@ function boxForPosition(rect, position, size, gap) {
839
978
  }
840
979
  return { x, y, width: size.width, height: size.height };
841
980
  }
842
- function placeNote(rect, position, size, gap, viewport) {
843
- const guard = inflateRect(rect, gap * 0.7);
844
- let best = null;
845
- candidatePositions(position).forEach((candidate, index) => {
846
- const box = boxForPosition(rect, candidate, size, gap);
847
- const penalty = intersectionArea(box, guard) * 6 + outsideArea(box, viewport) + index * 0.5;
848
- if (!best || penalty < best.penalty) {
849
- best = { box, penalty, position: candidate };
850
- }
851
- });
852
- return best;
853
- }
854
- function clampToViewport(box, guard, viewport) {
855
- if (!viewport || viewport.width <= 0 || viewport.height <= 0) return box;
856
- const shifted = {
857
- ...box,
858
- x: clamp(
859
- box.x,
860
- viewport.x,
861
- Math.max(viewport.x, viewport.x + viewport.width - box.width)
862
- ),
863
- y: clamp(
864
- box.y,
865
- viewport.y,
866
- Math.max(viewport.y, viewport.y + viewport.height - box.height)
867
- )
868
- };
869
- if (intersectionArea(shifted, guard) > intersectionArea(box, guard)) {
870
- return box;
981
+ function limitAgainstElement(box, guard, dx) {
982
+ const level = box.y < guard.y + guard.height && box.y + box.height > guard.y;
983
+ if (!level) return dx;
984
+ if (dx < 0) {
985
+ const room2 = box.x - (guard.x + guard.width);
986
+ return room2 <= 0 ? 0 : -Math.min(-dx, room2);
871
987
  }
872
- return shifted;
988
+ const room = guard.x - (box.x + box.width);
989
+ return room <= 0 ? 0 : Math.min(dx, room);
990
+ }
991
+ function nudgeIntoBand(box, guard, band) {
992
+ if (!band || band.width <= 0) return box;
993
+ const overhangLeft = band.x - box.x;
994
+ const overhangRight = box.x + box.width - (band.x + band.width);
995
+ let dx = 0;
996
+ if (overhangLeft > 0) dx = overhangLeft;
997
+ else if (overhangRight > 0) dx = -overhangRight;
998
+ if (!dx) return box;
999
+ dx = limitAgainstElement(box, guard, dx);
1000
+ if (!dx) return box;
1001
+ return { ...box, x: box.x + dx };
873
1002
  }
874
1003
  function renderAnnotation(svg, rect, noteOptions, style, seed = 1, layout = {}) {
875
1004
  if (!noteOptions?.text) return null;
876
- const viewport = layout.viewport ?? null;
1005
+ const band = layout.band ?? null;
877
1006
  const position = isValidPosition(noteOptions.position) ? noteOptions.position : "top-right";
878
1007
  const random = createRandom(seed + 9);
879
- const note = createNoteText(svg, noteOptions.text, style);
1008
+ const note = createNoteText(svg, noteOptions.text, style, {
1009
+ wordsPerLine: noteOptions.wordsPerLine
1010
+ });
880
1011
  const tilt = (random() - 0.45) * 7;
881
1012
  const tiltPad = Math.abs(Math.sin(tilt * Math.PI / 180)) * note.width * 0.5;
882
1013
  const size = {
883
1014
  width: note.width,
884
- height: note.ascent + note.descent + (noteOptions.underline === false ? 0 : 5)
1015
+ height: note.height + (noteOptions.underline === false ? 0 : 5)
885
1016
  };
886
1017
  const gap = (layout.gap ?? NOTE_GAP) + random() * 6;
887
- const placed = placeNote(
1018
+ const placed = boxForPosition(
888
1019
  rect,
889
1020
  position,
890
1021
  { width: size.width, height: size.height + tiltPad },
891
- gap,
892
- viewport
1022
+ gap
893
1023
  );
1024
+ const shifted = {
1025
+ ...placed,
1026
+ x: placed.x + (Number(noteOptions.offset?.x) || 0),
1027
+ y: placed.y + (Number(noteOptions.offset?.y) || 0)
1028
+ };
894
1029
  const guard = inflateRect(rect, gap * 0.7);
895
- const outer = clampToViewport(
896
- { ...placed.box, height: size.height + tiltPad },
897
- guard,
898
- viewport
899
- );
1030
+ const outer = nudgeIntoBand(shifted, guard, band);
900
1031
  const box = {
901
1032
  x: outer.x,
902
1033
  y: outer.y + tiltPad / 2,
903
1034
  width: size.width,
904
- height: note.ascent + note.descent
1035
+ height: note.height
905
1036
  };
906
1037
  placeNoteText(note, box, style, seed, {
907
1038
  tilt,
908
1039
  underline: noteOptions.underline !== false
909
1040
  });
910
- return { group: note.group, box: inflateRect(outer, 4), position: placed.position };
1041
+ return { group: note.group, box: inflateRect(outer, 4), position };
911
1042
  }
912
1043
 
913
1044
  // src/decorations.js
@@ -1302,16 +1433,34 @@ function renderDecorations(svg, rect, decorationOptions, style, seed, context =
1302
1433
  }
1303
1434
  }
1304
1435
 
1436
+ // src/fonts.js
1437
+ var FONT_NAME = "Caveat";
1438
+ var FONT_HREF = "https://fonts.googleapis.com/css2?family=Caveat:wght@400;600&display=swap";
1439
+ var MARKER = "data-freehand-font";
1440
+ var requested = false;
1441
+ function ensureHandwrittenFont() {
1442
+ if (requested) return;
1443
+ if (typeof document === "undefined" || !document.head) return;
1444
+ requested = true;
1445
+ try {
1446
+ if (document.fonts?.check?.(`600 19px "${FONT_NAME}"`)) return;
1447
+ } catch {
1448
+ }
1449
+ if (document.querySelector(`link[${MARKER}]`)) return;
1450
+ const link = document.createElement("link");
1451
+ link.rel = "stylesheet";
1452
+ link.href = FONT_HREF;
1453
+ link.setAttribute(MARKER, "");
1454
+ document.head.appendChild(link);
1455
+ }
1456
+
1305
1457
  // src/doodle.js
1306
1458
  var instances = /* @__PURE__ */ new WeakMap();
1307
- function localViewport(overlayRect, inset = 8) {
1459
+ function localBand(overlayRect, inset = 8) {
1308
1460
  const width = window.innerWidth || document.documentElement.clientWidth || 0;
1309
- const height = window.innerHeight || document.documentElement.clientHeight || 0;
1310
1461
  return {
1311
1462
  x: -overlayRect.x + inset,
1312
- y: -overlayRect.y + inset,
1313
- width: Math.max(0, width - inset * 2),
1314
- height: Math.max(0, height - inset * 2)
1463
+ width: Math.max(0, width - inset * 2)
1315
1464
  };
1316
1465
  }
1317
1466
  var DoodleOverlay = class {
@@ -1353,7 +1502,12 @@ var DoodleOverlay = class {
1353
1502
  }
1354
1503
  window.addEventListener("resize", this.onResize, { passive: true });
1355
1504
  this.scrollTargets.push({ target: window, type: "resize" });
1356
- document.fonts?.ready?.then(() => this.scheduleUpdate()).catch(() => {
1505
+ if (this.options.note && this.options.autoLoadFont && !this.options.fontFamily) {
1506
+ ensureHandwrittenFont();
1507
+ }
1508
+ this.onFontsLoaded = () => this.scheduleUpdate();
1509
+ document.fonts?.addEventListener?.("loadingdone", this.onFontsLoaded);
1510
+ document.fonts?.ready?.then(this.onFontsLoaded).catch(() => {
1357
1511
  });
1358
1512
  this.update();
1359
1513
  }
@@ -1396,11 +1550,17 @@ var DoodleOverlay = class {
1396
1550
  this.svg.style.width = `${overlayRect.width}px`;
1397
1551
  this.svg.style.height = `${overlayRect.height}px`;
1398
1552
  const strokeStyle = { color, strokeWidth, roughness, opacity };
1399
- const noteStyle = { color, opacity, strokeWidth, roughness };
1553
+ const noteStyle = {
1554
+ color,
1555
+ opacity,
1556
+ strokeWidth,
1557
+ roughness,
1558
+ fontFamily: this.options.fontFamily
1559
+ };
1400
1560
  const noteGroups = [];
1401
1561
  const noteLayout = {
1402
- viewport: localViewport(overlayRect),
1403
- gap: this.options.arrow ? 32 : 14
1562
+ band: localBand(overlayRect),
1563
+ gap: this.options.arrow ? 40 : 14
1404
1564
  };
1405
1565
  targets.forEach((target, index) => {
1406
1566
  const absolute = absoluteRects[index];
@@ -1471,6 +1631,10 @@ var DoodleOverlay = class {
1471
1631
  this.mutationObserver.disconnect();
1472
1632
  this.mutationObserver = null;
1473
1633
  }
1634
+ if (this.onFontsLoaded) {
1635
+ document.fonts?.removeEventListener?.("loadingdone", this.onFontsLoaded);
1636
+ this.onFontsLoaded = null;
1637
+ }
1474
1638
  for (const { target, type } of this.scrollTargets) {
1475
1639
  target.removeEventListener(type, type === "scroll" ? this.onScroll : this.onResize);
1476
1640
  }
@@ -1513,7 +1677,9 @@ var OPTION_KEYS = [
1513
1677
  "note",
1514
1678
  "arrow",
1515
1679
  "decorations",
1516
- "addBreaks"
1680
+ "addBreaks",
1681
+ "fontFamily",
1682
+ "autoLoadFont"
1517
1683
  ];
1518
1684
  function collectOptions(props) {
1519
1685
  const options = {};
@@ -1563,6 +1729,8 @@ var Doodle = forwardRef(function Doodle2(props, forwardedRef) {
1563
1729
  arrow,
1564
1730
  decorations,
1565
1731
  addBreaks,
1732
+ fontFamily,
1733
+ autoLoadFont,
1566
1734
  childSelector,
1567
1735
  ...rest
1568
1736
  } = props;
@@ -1580,6 +1748,8 @@ var Doodle = forwardRef(function Doodle2(props, forwardedRef) {
1580
1748
  arrow,
1581
1749
  decorations,
1582
1750
  addBreaks,
1751
+ fontFamily,
1752
+ autoLoadFont,
1583
1753
  childSelector
1584
1754
  }),
1585
1755
  [
@@ -1595,6 +1765,8 @@ var Doodle = forwardRef(function Doodle2(props, forwardedRef) {
1595
1765
  JSON.stringify(arrow ?? null),
1596
1766
  JSON.stringify(decorations ?? null),
1597
1767
  JSON.stringify(addBreaks ?? null),
1768
+ fontFamily,
1769
+ autoLoadFont,
1598
1770
  childSelector
1599
1771
  ]
1600
1772
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aznabee/freehand-ui",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Wrap any existing web element with a beautiful, responsive, hand-drawn doodle layer",
5
5
  "type": "module",
6
6
  "main": "./dist/freehand-ui.cjs",