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