@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/README.md +96 -14
- package/dist/freehand-ui.cjs +296 -132
- package/dist/freehand-ui.js +296 -132
- package/dist/index.d.ts +32 -3
- package/dist/react.cjs +305 -133
- package/dist/react.js +305 -133
- package/package.json +1 -1
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
|
-
|
|
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
|
|
45
|
-
const
|
|
46
|
-
merged.arrow = {
|
|
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
|
-
|
|
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
|
|
676
|
+
return { x: cx - sx * (hw + 10), y: cy + sy * (hh + 4) };
|
|
586
677
|
}
|
|
587
|
-
return { x: cx + sx * (hw +
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
661
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
843
|
-
const
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
const
|
|
847
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
1015
|
+
height: note.height + (noteOptions.underline === false ? 0 : 5)
|
|
885
1016
|
};
|
|
886
1017
|
const gap = (layout.gap ?? NOTE_GAP) + random() * 6;
|
|
887
|
-
const placed =
|
|
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 =
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
|
|
1403
|
-
gap: this.options.arrow ?
|
|
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