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