@aznabee/freehand-ui 0.1.2 → 0.1.4
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 +212 -157
- package/dist/freehand-ui.cjs +251 -135
- package/dist/freehand-ui.js +251 -135
- package/dist/index.d.ts +22 -6
- package/dist/react.cjs +252 -136
- package/dist/react.d.ts +3 -3
- package/dist/react.js +252 -136
- package/package.json +1 -1
package/dist/freehand-ui.js
CHANGED
|
@@ -33,9 +33,16 @@ function mergeOptions(options = {}) {
|
|
|
33
33
|
if (merged.note && typeof merged.note === "string") {
|
|
34
34
|
merged.note = { text: merged.note, position: "top-right" };
|
|
35
35
|
}
|
|
36
|
-
if (merged.arrow
|
|
37
|
-
const
|
|
38
|
-
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
|
+
};
|
|
39
46
|
}
|
|
40
47
|
if (merged.decorations === true) {
|
|
41
48
|
merged.decorations = { count: null, types: null };
|
|
@@ -180,10 +187,6 @@ function intersectionArea(a, b) {
|
|
|
180
187
|
const h = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y);
|
|
181
188
|
return w > 0 && h > 0 ? w * h : 0;
|
|
182
189
|
}
|
|
183
|
-
function outsideArea(box, bounds) {
|
|
184
|
-
if (!bounds) return 0;
|
|
185
|
-
return Math.max(0, box.width * box.height - intersectionArea(box, bounds));
|
|
186
|
-
}
|
|
187
190
|
function nearestPointOnRect(rect, point) {
|
|
188
191
|
return {
|
|
189
192
|
x: clamp(point.x, rect.x, rect.x + rect.width),
|
|
@@ -196,6 +199,17 @@ function distanceToRoundedRect(point, rect, radius = 0) {
|
|
|
196
199
|
const cy = clamp(point.y, rect.y + r, rect.y + rect.height - r);
|
|
197
200
|
return Math.hypot(point.x - cx, point.y - cy) - r;
|
|
198
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
|
+
}
|
|
199
213
|
function directionFromPosition(position) {
|
|
200
214
|
return {
|
|
201
215
|
x: position.includes("right") ? 1 : position.includes("left") ? -1 : 0,
|
|
@@ -445,21 +459,14 @@ function isBroken(fraction, breaks) {
|
|
|
445
459
|
function createBorderBreaks(perimeter, config, random) {
|
|
446
460
|
if (!config || perimeter <= 0) return [];
|
|
447
461
|
const settings = config === true ? {} : config;
|
|
448
|
-
const count = Math.max(
|
|
449
|
-
1,
|
|
450
|
-
Math.round(settings.count ?? 2 + random() * 3)
|
|
451
|
-
);
|
|
462
|
+
const count = Math.max(1, Math.round(settings.count ?? 2 + random() * 3));
|
|
452
463
|
const min = Math.max(2, settings.min ?? 6);
|
|
453
464
|
const max = Math.max(min, settings.max ?? 30);
|
|
454
465
|
const slice = perimeter / count;
|
|
455
466
|
let budget = perimeter * 0.35;
|
|
456
467
|
const breaks = [];
|
|
457
468
|
for (let i = 0; i < count; i++) {
|
|
458
|
-
const length = Math.min(
|
|
459
|
-
min + random() * (max - min),
|
|
460
|
-
slice * 0.7,
|
|
461
|
-
budget
|
|
462
|
-
);
|
|
469
|
+
const length = Math.min(min + random() * (max - min), slice * 0.7, budget);
|
|
463
470
|
if (length < 2) break;
|
|
464
471
|
budget -= length;
|
|
465
472
|
const start = i * slice + random() * Math.max(0, slice - length);
|
|
@@ -559,14 +566,86 @@ function drawBorder(svg, rect, options, seed) {
|
|
|
559
566
|
});
|
|
560
567
|
}
|
|
561
568
|
}
|
|
569
|
+
function cubicAt(p0, c1, c2, p3, t) {
|
|
570
|
+
const u = 1 - t;
|
|
571
|
+
const a = u * u * u;
|
|
572
|
+
const b = 3 * u * u * t;
|
|
573
|
+
const c = 3 * u * t * t;
|
|
574
|
+
const d = t * t * t;
|
|
575
|
+
return {
|
|
576
|
+
x: a * p0.x + b * c1.x + c * c2.x + d * p3.x,
|
|
577
|
+
y: a * p0.y + b * c1.y + c * c2.y + d * p3.y
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
function cubicTangentAt(p0, c1, c2, p3, t) {
|
|
581
|
+
const u = 1 - t;
|
|
582
|
+
const a = 3 * u * u;
|
|
583
|
+
const b = 6 * u * t;
|
|
584
|
+
const c = 3 * t * t;
|
|
585
|
+
return {
|
|
586
|
+
x: a * (c1.x - p0.x) + b * (c2.x - c1.x) + c * (p3.x - c2.x),
|
|
587
|
+
y: a * (c1.y - p0.y) + b * (c2.y - c1.y) + c * (p3.y - c2.y)
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
function loopedCurvePoints(start, c1, c2, end, radius, random) {
|
|
591
|
+
const at = (t) => cubicAt(start, c1, c2, end, t);
|
|
592
|
+
const tangentAt = (t) => normalize(cubicTangentAt(start, c1, c2, end, t));
|
|
593
|
+
const knot = 0.46 + random() * 0.12;
|
|
594
|
+
const points = [];
|
|
595
|
+
const lead = Math.max(2, Math.round(knot * 16));
|
|
596
|
+
for (let i = 0; i <= lead; i++) points.push(at(knot * i / lead));
|
|
597
|
+
const pivot = at(knot);
|
|
598
|
+
const heading = tangentAt(knot);
|
|
599
|
+
const normal = { x: -heading.y, y: heading.x };
|
|
600
|
+
const side = random() < 0.5 ? 1 : -1;
|
|
601
|
+
const centre = {
|
|
602
|
+
x: pivot.x + normal.x * radius * side,
|
|
603
|
+
y: pivot.y + normal.y * radius * side
|
|
604
|
+
};
|
|
605
|
+
const from = Math.atan2(pivot.y - centre.y, pivot.x - centre.x);
|
|
606
|
+
const turns = Math.PI * 2 * (0.97 + random() * 0.08);
|
|
607
|
+
const steps = 18;
|
|
608
|
+
for (let i = 1; i <= steps; i++) {
|
|
609
|
+
const progress = i / steps;
|
|
610
|
+
const angle = from + turns * progress * side;
|
|
611
|
+
const drift = radius * 0.55 * progress;
|
|
612
|
+
points.push({
|
|
613
|
+
x: centre.x + Math.cos(angle) * radius + heading.x * drift,
|
|
614
|
+
y: centre.y + Math.sin(angle) * radius + heading.y * drift
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
const tail = 14;
|
|
618
|
+
for (let i = 1; i <= tail; i++) {
|
|
619
|
+
points.push(at(knot + (1 - knot) * (i / tail)));
|
|
620
|
+
}
|
|
621
|
+
return points;
|
|
622
|
+
}
|
|
562
623
|
function normalize(vector) {
|
|
563
624
|
const length = Math.hypot(vector.x, vector.y);
|
|
564
625
|
if (!length) return { x: 0, y: 0 };
|
|
565
626
|
return { x: vector.x / length, y: vector.y / length };
|
|
566
627
|
}
|
|
567
|
-
|
|
628
|
+
var NOTE_CLEARANCE = 10;
|
|
629
|
+
var TIP_GAP = 11;
|
|
630
|
+
function arrowStartFromNote(origin, target, from) {
|
|
568
631
|
const cx = origin.x + origin.width / 2;
|
|
569
632
|
const cy = origin.y + origin.height / 2;
|
|
633
|
+
if (from && from !== "note") {
|
|
634
|
+
const point = pointFromPosition(from, origin);
|
|
635
|
+
const away = directionFromPosition(from);
|
|
636
|
+
if (!away.x && !away.y) {
|
|
637
|
+
const edge = pointOnBoxToward(origin, target);
|
|
638
|
+
const outward = normalize({ x: edge.x - cx, y: edge.y - cy });
|
|
639
|
+
return {
|
|
640
|
+
x: edge.x + outward.x * NOTE_CLEARANCE,
|
|
641
|
+
y: edge.y + outward.y * NOTE_CLEARANCE
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
return {
|
|
645
|
+
x: point.x + away.x * NOTE_CLEARANCE,
|
|
646
|
+
y: point.y + away.y * NOTE_CLEARANCE
|
|
647
|
+
};
|
|
648
|
+
}
|
|
570
649
|
const dx = target.x - cx;
|
|
571
650
|
const dy = target.y - cy;
|
|
572
651
|
const hw = origin.width / 2 || 1e-3;
|
|
@@ -574,15 +653,16 @@ function arrowStartFromNote(origin, target) {
|
|
|
574
653
|
const sx = Math.sign(dx) || 1;
|
|
575
654
|
const sy = Math.sign(dy) || 1;
|
|
576
655
|
if (Math.abs(dy) / hh >= Math.abs(dx) / hw) {
|
|
577
|
-
return { x: cx - sx * hw
|
|
656
|
+
return { x: cx - sx * (hw + 10), y: cy + sy * (hh + 4) };
|
|
578
657
|
}
|
|
579
|
-
return { x: cx + sx * (hw +
|
|
658
|
+
return { x: cx + sx * (hw + 8), y: cy + sy * (hh + 6) };
|
|
580
659
|
}
|
|
581
660
|
function arrowEndpoints(rect, arrowOptions, random, origin) {
|
|
582
661
|
const center = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
|
|
662
|
+
const from = arrowOptions.from || "note";
|
|
583
663
|
const anchored = Boolean(origin && origin.width > 0 && origin.height > 0);
|
|
584
|
-
let start = anchored ? arrowStartFromNote(origin, center) : annotationAnchor(
|
|
585
|
-
|
|
664
|
+
let start = anchored ? arrowStartFromNote(origin, center, from) : annotationAnchor(
|
|
665
|
+
from === "note" ? "top-right" : from,
|
|
586
666
|
rect,
|
|
587
667
|
38 + random() * 12
|
|
588
668
|
);
|
|
@@ -594,7 +674,7 @@ function arrowEndpoints(rect, arrowOptions, random, origin) {
|
|
|
594
674
|
const near = nearestPointOnRect(rect, start);
|
|
595
675
|
let outward = normalize({ x: start.x - near.x, y: start.y - near.y });
|
|
596
676
|
if (!outward.x && !outward.y) outward = { x: 0, y: -1 };
|
|
597
|
-
const gap =
|
|
677
|
+
const gap = TIP_GAP + random() * 5;
|
|
598
678
|
end = { x: near.x + outward.x * gap, y: near.y + outward.y * gap };
|
|
599
679
|
} else {
|
|
600
680
|
end = pointFromPosition(toPos, rect);
|
|
@@ -607,6 +687,35 @@ function arrowEndpoints(rect, arrowOptions, random, origin) {
|
|
|
607
687
|
}
|
|
608
688
|
return { start, end };
|
|
609
689
|
}
|
|
690
|
+
function drawArrowHead(svg, tip, angle, style, random, shaftLength = Infinity) {
|
|
691
|
+
const length = Math.min(9 + style.strokeWidth * 1.4, shaftLength * 0.3);
|
|
692
|
+
const spread = 0.46 + random() * 0.1;
|
|
693
|
+
for (const side of [-1, 1]) {
|
|
694
|
+
const armAngle = angle + spread * side;
|
|
695
|
+
const tail = {
|
|
696
|
+
x: tip.x - length * Math.cos(armAngle),
|
|
697
|
+
y: tip.y - length * Math.sin(armAngle)
|
|
698
|
+
};
|
|
699
|
+
appendPath(
|
|
700
|
+
svg,
|
|
701
|
+
roughLine(tail.x, tail.y, tip.x, tip.y, style.roughness * 0.5, random, {
|
|
702
|
+
steps: 2,
|
|
703
|
+
bow: 0.35 * side
|
|
704
|
+
}),
|
|
705
|
+
{ ...style, dashed: false }
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
function boundsOf(points) {
|
|
710
|
+
const xs = points.map((p) => p.x);
|
|
711
|
+
const ys = points.map((p) => p.y);
|
|
712
|
+
return {
|
|
713
|
+
x: Math.min(...xs),
|
|
714
|
+
y: Math.min(...ys),
|
|
715
|
+
width: Math.max(...xs) - Math.min(...xs),
|
|
716
|
+
height: Math.max(...ys) - Math.min(...ys)
|
|
717
|
+
};
|
|
718
|
+
}
|
|
610
719
|
function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
|
|
611
720
|
const random = createRandom(seed + 17);
|
|
612
721
|
const { start, end } = arrowEndpoints(rect, arrowOptions, random, origin);
|
|
@@ -628,13 +737,33 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
|
|
|
628
737
|
x: rect.x + rect.width / 2 - mid.x,
|
|
629
738
|
y: rect.y + rect.height / 2 - mid.y
|
|
630
739
|
};
|
|
631
|
-
const
|
|
740
|
+
const lateral = perpendicular.x * towardCenter.x + perpendicular.y * towardCenter.y;
|
|
741
|
+
const sign = Math.abs(lateral) > distance * 0.12 ? lateral > 0 ? -1 : 1 : random() < 0.5 ? -1 : 1;
|
|
632
742
|
const bow = Math.min(distance * (0.16 + random() * 0.08), 30) * sign;
|
|
633
743
|
const c1x = start.x + dx * 0.28 + perpendicular.x * bow;
|
|
634
744
|
const c1y = start.y + dy * 0.28 + perpendicular.y * bow;
|
|
635
745
|
const c2x = start.x + dx * 0.72 + perpendicular.x * bow * 0.85;
|
|
636
746
|
const c2y = start.y + dy * 0.72 + perpendicular.y * bow * 0.85;
|
|
637
747
|
hull.push({ x: c1x, y: c1y }, { x: c2x, y: c2y });
|
|
748
|
+
if (arrowOptions.style === "looped") {
|
|
749
|
+
const radius = clamp(distance * 0.11, 7, 17);
|
|
750
|
+
const points = loopedCurvePoints(
|
|
751
|
+
start,
|
|
752
|
+
{ x: c1x, y: c1y },
|
|
753
|
+
{ x: c2x, y: c2y },
|
|
754
|
+
end,
|
|
755
|
+
radius,
|
|
756
|
+
random
|
|
757
|
+
);
|
|
758
|
+
shaft = catmullRomPath(points, false);
|
|
759
|
+
hull.push(...points);
|
|
760
|
+
const last = points[points.length - 1];
|
|
761
|
+
const prior = points[points.length - 2] ?? start;
|
|
762
|
+
tipAngle = Math.atan2(last.y - prior.y, last.x - prior.x);
|
|
763
|
+
appendPath(svg, shaft, { ...style, dashed });
|
|
764
|
+
drawArrowHead(svg, end, tipAngle, style, random, distance);
|
|
765
|
+
return { start, end, bounds: boundsOf(hull) };
|
|
766
|
+
}
|
|
638
767
|
shaft = `M ${fmt(start.x)} ${fmt(start.y)} C ${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(end.x)} ${fmt(end.y)}`;
|
|
639
768
|
if (style.roughness >= 1.1) {
|
|
640
769
|
const drift = () => (random() - 0.5) * 1.4;
|
|
@@ -649,31 +778,8 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
|
|
|
649
778
|
tipAngle = Math.atan2(end.y - c2y, end.x - c2x);
|
|
650
779
|
}
|
|
651
780
|
appendPath(svg, shaft, { ...style, dashed });
|
|
652
|
-
|
|
653
|
-
const
|
|
654
|
-
for (const side of [-1, 1]) {
|
|
655
|
-
const angle = tipAngle + spread * side;
|
|
656
|
-
const tail = {
|
|
657
|
-
x: end.x - headLength * Math.cos(angle),
|
|
658
|
-
y: end.y - headLength * Math.sin(angle)
|
|
659
|
-
};
|
|
660
|
-
appendPath(
|
|
661
|
-
svg,
|
|
662
|
-
roughLine(tail.x, tail.y, end.x, end.y, style.roughness * 0.5, random, {
|
|
663
|
-
steps: 2,
|
|
664
|
-
bow: 0.35 * side
|
|
665
|
-
}),
|
|
666
|
-
{ ...style, dashed: false }
|
|
667
|
-
);
|
|
668
|
-
}
|
|
669
|
-
const xs = hull.map((p) => p.x);
|
|
670
|
-
const ys = hull.map((p) => p.y);
|
|
671
|
-
const bounds = {
|
|
672
|
-
x: Math.min(...xs),
|
|
673
|
-
y: Math.min(...ys),
|
|
674
|
-
width: Math.max(...xs) - Math.min(...xs),
|
|
675
|
-
height: Math.max(...ys) - Math.min(...ys)
|
|
676
|
-
};
|
|
781
|
+
drawArrowHead(svg, end, tipAngle, style, random, distance);
|
|
782
|
+
const bounds = boundsOf(hull);
|
|
677
783
|
return { start, end, bounds };
|
|
678
784
|
}
|
|
679
785
|
function drawUnderline(svg, x, y, width, style, random) {
|
|
@@ -711,11 +817,38 @@ function drawUnderline(svg, x, y, width, style, random) {
|
|
|
711
817
|
}
|
|
712
818
|
);
|
|
713
819
|
}
|
|
714
|
-
|
|
820
|
+
var DEFAULT_WORDS_PER_LINE = 2;
|
|
821
|
+
function wrapWords(text, perLine) {
|
|
822
|
+
const lines = [];
|
|
823
|
+
for (const paragraph of String(text).split(/\r?\n/)) {
|
|
824
|
+
const words = paragraph.trim().split(/\s+/).filter(Boolean);
|
|
825
|
+
if (!words.length) continue;
|
|
826
|
+
const size = Number.isFinite(perLine) && perLine >= 1 ? Math.round(perLine) : words.length;
|
|
827
|
+
for (let i = 0; i < words.length; i += size) {
|
|
828
|
+
lines.push(words.slice(i, i + size).join(" "));
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
return lines.length ? lines : [String(text)];
|
|
832
|
+
}
|
|
833
|
+
function measureWidth(element, content, fontSize) {
|
|
834
|
+
try {
|
|
835
|
+
const box = element.getBBox();
|
|
836
|
+
if (box && box.width > 0) return box.width;
|
|
837
|
+
} catch {
|
|
838
|
+
}
|
|
839
|
+
try {
|
|
840
|
+
const measured = element.getComputedTextLength();
|
|
841
|
+
if (Number.isFinite(measured) && measured > 0) return measured;
|
|
842
|
+
} catch {
|
|
843
|
+
}
|
|
844
|
+
return content.length * fontSize * 0.45;
|
|
845
|
+
}
|
|
846
|
+
function createNoteText(svg, text, style, options = {}) {
|
|
715
847
|
const fontSize = style.fontSize ?? NOTE_FONT_SIZE;
|
|
848
|
+
const lines = wrapWords(text, options.wordsPerLine ?? DEFAULT_WORDS_PER_LINE);
|
|
849
|
+
const lineHeight = fontSize * 1.05;
|
|
716
850
|
const group = document.createElementNS(SVG_NS, "g");
|
|
717
851
|
const textEl = document.createElementNS(SVG_NS, "text");
|
|
718
|
-
textEl.textContent = text;
|
|
719
852
|
textEl.setAttribute("x", "0");
|
|
720
853
|
textEl.setAttribute("y", "0");
|
|
721
854
|
textEl.setAttribute("fill", style.color);
|
|
@@ -726,31 +859,43 @@ function createNoteText(svg, text, style) {
|
|
|
726
859
|
textEl.setAttribute("letter-spacing", "0.4");
|
|
727
860
|
textEl.setAttribute("text-anchor", "start");
|
|
728
861
|
textEl.setAttribute("dominant-baseline", "auto");
|
|
862
|
+
const tspans = lines.map((line, index) => {
|
|
863
|
+
const tspan = document.createElementNS(SVG_NS, "tspan");
|
|
864
|
+
tspan.textContent = line;
|
|
865
|
+
tspan.setAttribute("x", "0");
|
|
866
|
+
tspan.setAttribute("dy", index === 0 ? "0" : String(lineHeight));
|
|
867
|
+
textEl.appendChild(tspan);
|
|
868
|
+
return tspan;
|
|
869
|
+
});
|
|
729
870
|
group.appendChild(textEl);
|
|
730
871
|
svg.appendChild(group);
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
} catch {
|
|
736
|
-
}
|
|
737
|
-
if (!width) {
|
|
738
|
-
try {
|
|
739
|
-
const measured = textEl.getComputedTextLength();
|
|
740
|
-
if (Number.isFinite(measured) && measured > 0) width = measured;
|
|
741
|
-
} catch {
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
if (!width) width = text.length * fontSize * 0.45;
|
|
872
|
+
const lineWidths = tspans.map(
|
|
873
|
+
(tspan, index) => measureWidth(tspan, lines[index], fontSize)
|
|
874
|
+
);
|
|
875
|
+
const width = Math.max(...lineWidths);
|
|
745
876
|
const ascent = fontSize * 0.74;
|
|
746
877
|
const descent = fontSize * 0.26;
|
|
747
|
-
|
|
878
|
+
const height = (lines.length - 1) * lineHeight + ascent + descent;
|
|
879
|
+
return {
|
|
880
|
+
group,
|
|
881
|
+
textEl,
|
|
882
|
+
tspans,
|
|
883
|
+
lines,
|
|
884
|
+
lineWidths,
|
|
885
|
+
lineHeight,
|
|
886
|
+
width,
|
|
887
|
+
height,
|
|
888
|
+
ascent,
|
|
889
|
+
descent,
|
|
890
|
+
fontSize
|
|
891
|
+
};
|
|
748
892
|
}
|
|
749
893
|
function placeNoteText(note, box, style, seed, options = {}) {
|
|
750
894
|
const random = createRandom(seed + 55);
|
|
751
895
|
const tilt = options.tilt ?? (random() - 0.5) * 7;
|
|
752
896
|
note.textEl.setAttribute("x", fmt(box.x));
|
|
753
897
|
note.textEl.setAttribute("y", fmt(box.y + note.ascent));
|
|
898
|
+
for (const tspan of note.tspans) tspan.setAttribute("x", fmt(box.x));
|
|
754
899
|
const cx = box.x + box.width / 2;
|
|
755
900
|
const cy = box.y + box.height / 2;
|
|
756
901
|
note.group.setAttribute(
|
|
@@ -758,11 +903,12 @@ function placeNoteText(note, box, style, seed, options = {}) {
|
|
|
758
903
|
`rotate(${tilt.toFixed(2)} ${fmt(cx)} ${fmt(cy)})`
|
|
759
904
|
);
|
|
760
905
|
if (options.underline !== false) {
|
|
906
|
+
const lastWidth = note.lineWidths[note.lineWidths.length - 1] ?? box.width;
|
|
761
907
|
drawUnderline(
|
|
762
908
|
note.group,
|
|
763
909
|
box.x + 1,
|
|
764
910
|
box.y + box.height + 2,
|
|
765
|
-
|
|
911
|
+
lastWidth - 2,
|
|
766
912
|
{
|
|
767
913
|
color: style.color,
|
|
768
914
|
strokeWidth: style.strokeWidth || 1.4,
|
|
@@ -794,25 +940,6 @@ function createOverlaySvg(left, top, width, height) {
|
|
|
794
940
|
|
|
795
941
|
// src/annotations.js
|
|
796
942
|
var NOTE_GAP = 14;
|
|
797
|
-
function mirror(position, axis) {
|
|
798
|
-
if (axis === "x") {
|
|
799
|
-
if (position.includes("right")) return position.replace("right", "left");
|
|
800
|
-
if (position.includes("left")) return position.replace("left", "right");
|
|
801
|
-
return position;
|
|
802
|
-
}
|
|
803
|
-
if (position.includes("top")) return position.replace("top", "bottom");
|
|
804
|
-
if (position.includes("bottom")) return position.replace("bottom", "top");
|
|
805
|
-
return position;
|
|
806
|
-
}
|
|
807
|
-
function candidatePositions(position) {
|
|
808
|
-
const candidates = [
|
|
809
|
-
position,
|
|
810
|
-
mirror(position, "x"),
|
|
811
|
-
mirror(position, "y"),
|
|
812
|
-
mirror(mirror(position, "x"), "y")
|
|
813
|
-
];
|
|
814
|
-
return candidates.filter((value, index) => candidates.indexOf(value) === index);
|
|
815
|
-
}
|
|
816
943
|
function boxForPosition(rect, position, size, gap) {
|
|
817
944
|
const direction = directionFromPosition(position);
|
|
818
945
|
const guard = inflateRect(rect, gap);
|
|
@@ -831,75 +958,67 @@ function boxForPosition(rect, position, size, gap) {
|
|
|
831
958
|
}
|
|
832
959
|
return { x, y, width: size.width, height: size.height };
|
|
833
960
|
}
|
|
834
|
-
function
|
|
835
|
-
const
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
const
|
|
839
|
-
|
|
840
|
-
if (!best || penalty < best.penalty) {
|
|
841
|
-
best = { box, penalty, position: candidate };
|
|
842
|
-
}
|
|
843
|
-
});
|
|
844
|
-
return best;
|
|
845
|
-
}
|
|
846
|
-
function clampToViewport(box, guard, viewport) {
|
|
847
|
-
if (!viewport || viewport.width <= 0 || viewport.height <= 0) return box;
|
|
848
|
-
const shifted = {
|
|
849
|
-
...box,
|
|
850
|
-
x: clamp(
|
|
851
|
-
box.x,
|
|
852
|
-
viewport.x,
|
|
853
|
-
Math.max(viewport.x, viewport.x + viewport.width - box.width)
|
|
854
|
-
),
|
|
855
|
-
y: clamp(
|
|
856
|
-
box.y,
|
|
857
|
-
viewport.y,
|
|
858
|
-
Math.max(viewport.y, viewport.y + viewport.height - box.height)
|
|
859
|
-
)
|
|
860
|
-
};
|
|
861
|
-
if (intersectionArea(shifted, guard) > intersectionArea(box, guard)) {
|
|
862
|
-
return box;
|
|
961
|
+
function limitAgainstElement(box, guard, dx) {
|
|
962
|
+
const level = box.y < guard.y + guard.height && box.y + box.height > guard.y;
|
|
963
|
+
if (!level) return dx;
|
|
964
|
+
if (dx < 0) {
|
|
965
|
+
const room2 = box.x - (guard.x + guard.width);
|
|
966
|
+
return room2 <= 0 ? 0 : -Math.min(-dx, room2);
|
|
863
967
|
}
|
|
864
|
-
|
|
968
|
+
const room = guard.x - (box.x + box.width);
|
|
969
|
+
return room <= 0 ? 0 : Math.min(dx, room);
|
|
970
|
+
}
|
|
971
|
+
function nudgeIntoBand(box, guard, band) {
|
|
972
|
+
if (!band || band.width <= 0) return box;
|
|
973
|
+
const overhangLeft = band.x - box.x;
|
|
974
|
+
const overhangRight = box.x + box.width - (band.x + band.width);
|
|
975
|
+
let dx = 0;
|
|
976
|
+
if (overhangLeft > 0) dx = overhangLeft;
|
|
977
|
+
else if (overhangRight > 0) dx = -overhangRight;
|
|
978
|
+
if (!dx) return box;
|
|
979
|
+
dx = limitAgainstElement(box, guard, dx);
|
|
980
|
+
if (!dx) return box;
|
|
981
|
+
return { ...box, x: box.x + dx };
|
|
865
982
|
}
|
|
866
983
|
function renderAnnotation(svg, rect, noteOptions, style, seed = 1, layout = {}) {
|
|
867
984
|
if (!noteOptions?.text) return null;
|
|
868
|
-
const
|
|
985
|
+
const band = layout.band ?? null;
|
|
869
986
|
const position = isValidPosition(noteOptions.position) ? noteOptions.position : "top-right";
|
|
870
987
|
const random = createRandom(seed + 9);
|
|
871
|
-
const note = createNoteText(svg, noteOptions.text, style
|
|
988
|
+
const note = createNoteText(svg, noteOptions.text, style, {
|
|
989
|
+
wordsPerLine: noteOptions.wordsPerLine
|
|
990
|
+
});
|
|
872
991
|
const tilt = (random() - 0.45) * 7;
|
|
873
992
|
const tiltPad = Math.abs(Math.sin(tilt * Math.PI / 180)) * note.width * 0.5;
|
|
874
993
|
const size = {
|
|
875
994
|
width: note.width,
|
|
876
|
-
height: note.
|
|
995
|
+
height: note.height + (noteOptions.underline === false ? 0 : 5)
|
|
877
996
|
};
|
|
878
997
|
const gap = (layout.gap ?? NOTE_GAP) + random() * 6;
|
|
879
|
-
const placed =
|
|
998
|
+
const placed = boxForPosition(
|
|
880
999
|
rect,
|
|
881
1000
|
position,
|
|
882
1001
|
{ width: size.width, height: size.height + tiltPad },
|
|
883
|
-
gap
|
|
884
|
-
viewport
|
|
1002
|
+
gap
|
|
885
1003
|
);
|
|
1004
|
+
const shifted = {
|
|
1005
|
+
...placed,
|
|
1006
|
+
x: placed.x + (Number(noteOptions.offset?.x) || 0),
|
|
1007
|
+
y: placed.y + (Number(noteOptions.offset?.y) || 0)
|
|
1008
|
+
};
|
|
886
1009
|
const guard = inflateRect(rect, gap * 0.7);
|
|
887
|
-
const outer =
|
|
888
|
-
{ ...placed.box, height: size.height + tiltPad },
|
|
889
|
-
guard,
|
|
890
|
-
viewport
|
|
891
|
-
);
|
|
1010
|
+
const outer = nudgeIntoBand(shifted, guard, band);
|
|
892
1011
|
const box = {
|
|
893
1012
|
x: outer.x,
|
|
894
1013
|
y: outer.y + tiltPad / 2,
|
|
895
1014
|
width: size.width,
|
|
896
|
-
height: note.
|
|
1015
|
+
height: note.height
|
|
897
1016
|
};
|
|
898
1017
|
placeNoteText(note, box, style, seed, {
|
|
899
1018
|
tilt,
|
|
900
1019
|
underline: noteOptions.underline !== false
|
|
901
1020
|
});
|
|
902
|
-
return { group: note.group, box: inflateRect(outer, 4), position
|
|
1021
|
+
return { group: note.group, box: inflateRect(outer, 4), position };
|
|
903
1022
|
}
|
|
904
1023
|
|
|
905
1024
|
// src/decorations.js
|
|
@@ -1317,14 +1436,11 @@ function ensureHandwrittenFont() {
|
|
|
1317
1436
|
|
|
1318
1437
|
// src/doodle.js
|
|
1319
1438
|
var instances = /* @__PURE__ */ new WeakMap();
|
|
1320
|
-
function
|
|
1439
|
+
function localBand(overlayRect, inset = 8) {
|
|
1321
1440
|
const width = window.innerWidth || document.documentElement.clientWidth || 0;
|
|
1322
|
-
const height = window.innerHeight || document.documentElement.clientHeight || 0;
|
|
1323
1441
|
return {
|
|
1324
1442
|
x: -overlayRect.x + inset,
|
|
1325
|
-
|
|
1326
|
-
width: Math.max(0, width - inset * 2),
|
|
1327
|
-
height: Math.max(0, height - inset * 2)
|
|
1443
|
+
width: Math.max(0, width - inset * 2)
|
|
1328
1444
|
};
|
|
1329
1445
|
}
|
|
1330
1446
|
var DoodleOverlay = class {
|
|
@@ -1423,8 +1539,8 @@ var DoodleOverlay = class {
|
|
|
1423
1539
|
};
|
|
1424
1540
|
const noteGroups = [];
|
|
1425
1541
|
const noteLayout = {
|
|
1426
|
-
|
|
1427
|
-
gap: this.options.arrow ?
|
|
1542
|
+
band: localBand(overlayRect),
|
|
1543
|
+
gap: this.options.arrow ? 40 : 14
|
|
1428
1544
|
};
|
|
1429
1545
|
targets.forEach((target, index) => {
|
|
1430
1546
|
const absolute = absoluteRects[index];
|
package/dist/index.d.ts
CHANGED
|
@@ -23,17 +23,33 @@ export type DecorationType =
|
|
|
23
23
|
|
|
24
24
|
export interface NoteOptions {
|
|
25
25
|
text: string;
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Which side of the element the note sits on. Always honoured - a note that
|
|
28
|
+
* would run off screen slides horizontally to stay in view rather than
|
|
29
|
+
* moving to another side.
|
|
30
|
+
*/
|
|
27
31
|
position?: DoodlePosition;
|
|
28
32
|
/** Underline sized to the measured text. Default `true`. */
|
|
29
33
|
underline?: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Nudge the note from where `position` put it, in px. The note is still kept
|
|
36
|
+
* in view from the offset spot, so a large `x` slides back rather than
|
|
37
|
+
* running off screen.
|
|
38
|
+
*/
|
|
39
|
+
offset?: { x?: number; y?: number };
|
|
30
40
|
}
|
|
31
41
|
|
|
32
42
|
export interface ArrowOptions {
|
|
33
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Where the arrow leaves. With a `note` this names a side of *the note* -
|
|
45
|
+
* the arrow always departs from the handwriting and travels to the element,
|
|
46
|
+
* however far an `offset` has moved it. `"note"` (the default) picks the side
|
|
47
|
+
* facing the element. Without a note it names a side of the element instead.
|
|
48
|
+
*/
|
|
49
|
+
from?: DoodlePosition | "note";
|
|
34
50
|
/** `"edge"` lands just outside the frame; `"center"` points into it. */
|
|
35
51
|
to?: DoodlePosition | "edge";
|
|
36
|
-
style?: "curved" | "straight" | "dotted";
|
|
52
|
+
style?: "curved" | "straight" | "dotted" | "looped";
|
|
37
53
|
}
|
|
38
54
|
|
|
39
55
|
export interface DecorationOptions {
|
|
@@ -72,8 +88,8 @@ export interface DoodleOptions {
|
|
|
72
88
|
/** Lift the pen at random points around the outline. */
|
|
73
89
|
addBreaks?: boolean | number | BreakOptions;
|
|
74
90
|
/**
|
|
75
|
-
* Font stack for handwritten notes. Point this at your own family
|
|
76
|
-
* `next/font` CSS variable, for instance
|
|
91
|
+
* Font stack for handwritten notes. Point this at your own family - a
|
|
92
|
+
* `next/font` CSS variable, for instance - to take over from the built-in one.
|
|
77
93
|
*/
|
|
78
94
|
fontFamily?: string | null;
|
|
79
95
|
/**
|
|
@@ -98,5 +114,5 @@ export interface DoodleInstance {
|
|
|
98
114
|
*/
|
|
99
115
|
export function doodle(
|
|
100
116
|
target: string | Element,
|
|
101
|
-
options?: DoodleOptions
|
|
117
|
+
options?: DoodleOptions,
|
|
102
118
|
): DoodleInstance;
|