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