@aznabee/freehand-ui 0.1.2 → 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 +53 -11
- package/dist/freehand-ui.cjs +249 -126
- package/dist/freehand-ui.js +249 -126
- package/dist/index.d.ts +19 -3
- package/dist/react.cjs +249 -126
- package/dist/react.js +249 -126
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -180,11 +180,34 @@ doodle(".cta", {
|
|
|
180
180
|
|
|
181
181
|
Positions: `top`, `top-right`, `right`, `bottom-right`, `bottom`, `bottom-left`, `left`, `top-left`. Same names apply to `arrow.from`/`arrow.to`.
|
|
182
182
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
183
|
+
Notes are measured from their real glyph metrics and laid out so they never sit
|
|
184
|
+
on top of the element. The underline is drawn to the measured text width; set
|
|
185
|
+
`underline: false` for plain handwriting.
|
|
186
|
+
|
|
187
|
+
**The side you ask for is the side you get.** When a note runs past the edge of
|
|
188
|
+
the viewport it slides horizontally by exactly the amount it overhangs, so a
|
|
189
|
+
narrowing viewport walks it gradually inward instead of snapping it to the
|
|
190
|
+
opposite side. The only limit is the element itself: a note level with it stops
|
|
191
|
+
once they meet, rather than sliding across it.
|
|
192
|
+
|
|
193
|
+
Vertical placement is fixed relative to the element and does not react to
|
|
194
|
+
scrolling, so a note stays pinned to what it annotates instead of crawling back
|
|
195
|
+
into view as the page moves. A note beside an element that is itself jammed
|
|
196
|
+
against the viewport edge has nowhere to slide, so it may sit partly off screen
|
|
197
|
+
— use a different `position` or an `offset` there.
|
|
198
|
+
|
|
199
|
+
Nudge a note off its computed spot with `offset`:
|
|
200
|
+
|
|
201
|
+
```javascript
|
|
202
|
+
doodle(".cta", {
|
|
203
|
+
note: { text: "start chat", position: "top", offset: { x: 40, y: -12 } },
|
|
204
|
+
});
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
The offset moves the note from the spot `position` chose, and staying in view is
|
|
208
|
+
judged from there — so a large offset slides back into the viewport rather than
|
|
209
|
+
carrying the note off screen. Vertical offsets are always applied verbatim,
|
|
210
|
+
since vertical placement never reacts to the viewport.
|
|
188
211
|
|
|
189
212
|
## Arrows
|
|
190
213
|
|
|
@@ -193,17 +216,36 @@ doodle(".cta", { arrow: true });
|
|
|
193
216
|
|
|
194
217
|
doodle(".cta", {
|
|
195
218
|
arrow: {
|
|
196
|
-
from: "top-right",
|
|
219
|
+
from: "top-right", // note | any position name
|
|
197
220
|
to: "center",
|
|
198
|
-
style: "curved",
|
|
221
|
+
style: "curved", // curved | straight | dotted | looped
|
|
199
222
|
},
|
|
200
223
|
});
|
|
201
224
|
```
|
|
202
225
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
226
|
+
`looped` ties a curl into the middle of the sweep — the hand-drawn flourish that
|
|
227
|
+
doubles back on itself before reaching the tip.
|
|
228
|
+
|
|
229
|
+
`from` decides where the arrow leaves. **With a note it names a side of the
|
|
230
|
+
note** — where the pen lifts off the handwriting — and the arrow then travels to
|
|
231
|
+
the element wherever that is:
|
|
232
|
+
|
|
233
|
+
```javascript
|
|
234
|
+
doodle(".cta", {
|
|
235
|
+
note: { text: "start chat", position: "top-right" },
|
|
236
|
+
arrow: { from: "bottom-left" }, // leaves the note's bottom-left corner
|
|
237
|
+
});
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
`"note"` is the default and picks the side of the note facing the element. A
|
|
241
|
+
callout always departs from its note, so moving the note with `offset` takes the
|
|
242
|
+
arrow with it rather than leaving it stranded by the element.
|
|
243
|
+
|
|
244
|
+
Without a note there is no handwriting to leave from, so `from` falls back to
|
|
245
|
+
naming a side of the element itself.
|
|
246
|
+
|
|
247
|
+
`to: "edge"` (the default) lands the tip just *outside* the frame; `to: "center"`
|
|
248
|
+
is the one setting that deliberately points into the element.
|
|
207
249
|
|
|
208
250
|
## Decorations
|
|
209
251
|
|
package/dist/freehand-ui.cjs
CHANGED
|
@@ -58,9 +58,16 @@ function mergeOptions(options = {}) {
|
|
|
58
58
|
if (merged.note && typeof merged.note === "string") {
|
|
59
59
|
merged.note = { text: merged.note, position: "top-right" };
|
|
60
60
|
}
|
|
61
|
-
if (merged.arrow
|
|
62
|
-
const
|
|
63
|
-
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
|
+
};
|
|
64
71
|
}
|
|
65
72
|
if (merged.decorations === true) {
|
|
66
73
|
merged.decorations = { count: null, types: null };
|
|
@@ -205,10 +212,6 @@ function intersectionArea(a, b) {
|
|
|
205
212
|
const h = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y);
|
|
206
213
|
return w > 0 && h > 0 ? w * h : 0;
|
|
207
214
|
}
|
|
208
|
-
function outsideArea(box, bounds) {
|
|
209
|
-
if (!bounds) return 0;
|
|
210
|
-
return Math.max(0, box.width * box.height - intersectionArea(box, bounds));
|
|
211
|
-
}
|
|
212
215
|
function nearestPointOnRect(rect, point) {
|
|
213
216
|
return {
|
|
214
217
|
x: clamp(point.x, rect.x, rect.x + rect.width),
|
|
@@ -221,6 +224,17 @@ function distanceToRoundedRect(point, rect, radius = 0) {
|
|
|
221
224
|
const cy = clamp(point.y, rect.y + r, rect.y + rect.height - r);
|
|
222
225
|
return Math.hypot(point.x - cx, point.y - cy) - r;
|
|
223
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
|
+
}
|
|
224
238
|
function directionFromPosition(position) {
|
|
225
239
|
return {
|
|
226
240
|
x: position.includes("right") ? 1 : position.includes("left") ? -1 : 0,
|
|
@@ -584,14 +598,86 @@ function drawBorder(svg, rect, options, seed) {
|
|
|
584
598
|
});
|
|
585
599
|
}
|
|
586
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
|
+
}
|
|
587
655
|
function normalize(vector) {
|
|
588
656
|
const length = Math.hypot(vector.x, vector.y);
|
|
589
657
|
if (!length) return { x: 0, y: 0 };
|
|
590
658
|
return { x: vector.x / length, y: vector.y / length };
|
|
591
659
|
}
|
|
592
|
-
|
|
660
|
+
var NOTE_CLEARANCE = 10;
|
|
661
|
+
var TIP_GAP = 11;
|
|
662
|
+
function arrowStartFromNote(origin, target, from) {
|
|
593
663
|
const cx = origin.x + origin.width / 2;
|
|
594
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
|
+
}
|
|
595
681
|
const dx = target.x - cx;
|
|
596
682
|
const dy = target.y - cy;
|
|
597
683
|
const hw = origin.width / 2 || 1e-3;
|
|
@@ -599,15 +685,16 @@ function arrowStartFromNote(origin, target) {
|
|
|
599
685
|
const sx = Math.sign(dx) || 1;
|
|
600
686
|
const sy = Math.sign(dy) || 1;
|
|
601
687
|
if (Math.abs(dy) / hh >= Math.abs(dx) / hw) {
|
|
602
|
-
return { x: cx - sx * hw
|
|
688
|
+
return { x: cx - sx * (hw + 10), y: cy + sy * (hh + 4) };
|
|
603
689
|
}
|
|
604
|
-
return { x: cx + sx * (hw +
|
|
690
|
+
return { x: cx + sx * (hw + 8), y: cy + sy * (hh + 6) };
|
|
605
691
|
}
|
|
606
692
|
function arrowEndpoints(rect, arrowOptions, random, origin) {
|
|
607
693
|
const center = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
|
|
694
|
+
const from = arrowOptions.from || "note";
|
|
608
695
|
const anchored = Boolean(origin && origin.width > 0 && origin.height > 0);
|
|
609
|
-
let start = anchored ? arrowStartFromNote(origin, center) : annotationAnchor(
|
|
610
|
-
|
|
696
|
+
let start = anchored ? arrowStartFromNote(origin, center, from) : annotationAnchor(
|
|
697
|
+
from === "note" ? "top-right" : from,
|
|
611
698
|
rect,
|
|
612
699
|
38 + random() * 12
|
|
613
700
|
);
|
|
@@ -619,7 +706,7 @@ function arrowEndpoints(rect, arrowOptions, random, origin) {
|
|
|
619
706
|
const near = nearestPointOnRect(rect, start);
|
|
620
707
|
let outward = normalize({ x: start.x - near.x, y: start.y - near.y });
|
|
621
708
|
if (!outward.x && !outward.y) outward = { x: 0, y: -1 };
|
|
622
|
-
const gap =
|
|
709
|
+
const gap = TIP_GAP + random() * 5;
|
|
623
710
|
end = { x: near.x + outward.x * gap, y: near.y + outward.y * gap };
|
|
624
711
|
} else {
|
|
625
712
|
end = pointFromPosition(toPos, rect);
|
|
@@ -632,6 +719,35 @@ function arrowEndpoints(rect, arrowOptions, random, origin) {
|
|
|
632
719
|
}
|
|
633
720
|
return { start, end };
|
|
634
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
|
+
}
|
|
635
751
|
function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
|
|
636
752
|
const random = createRandom(seed + 17);
|
|
637
753
|
const { start, end } = arrowEndpoints(rect, arrowOptions, random, origin);
|
|
@@ -653,13 +769,33 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
|
|
|
653
769
|
x: rect.x + rect.width / 2 - mid.x,
|
|
654
770
|
y: rect.y + rect.height / 2 - mid.y
|
|
655
771
|
};
|
|
656
|
-
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;
|
|
657
774
|
const bow = Math.min(distance * (0.16 + random() * 0.08), 30) * sign;
|
|
658
775
|
const c1x = start.x + dx * 0.28 + perpendicular.x * bow;
|
|
659
776
|
const c1y = start.y + dy * 0.28 + perpendicular.y * bow;
|
|
660
777
|
const c2x = start.x + dx * 0.72 + perpendicular.x * bow * 0.85;
|
|
661
778
|
const c2y = start.y + dy * 0.72 + perpendicular.y * bow * 0.85;
|
|
662
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
|
+
}
|
|
663
799
|
shaft = `M ${fmt(start.x)} ${fmt(start.y)} C ${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(end.x)} ${fmt(end.y)}`;
|
|
664
800
|
if (style.roughness >= 1.1) {
|
|
665
801
|
const drift = () => (random() - 0.5) * 1.4;
|
|
@@ -674,31 +810,8 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
|
|
|
674
810
|
tipAngle = Math.atan2(end.y - c2y, end.x - c2x);
|
|
675
811
|
}
|
|
676
812
|
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
|
-
};
|
|
813
|
+
drawArrowHead(svg, end, tipAngle, style, random, distance);
|
|
814
|
+
const bounds = boundsOf(hull);
|
|
702
815
|
return { start, end, bounds };
|
|
703
816
|
}
|
|
704
817
|
function drawUnderline(svg, x, y, width, style, random) {
|
|
@@ -736,11 +849,38 @@ function drawUnderline(svg, x, y, width, style, random) {
|
|
|
736
849
|
}
|
|
737
850
|
);
|
|
738
851
|
}
|
|
739
|
-
|
|
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 = {}) {
|
|
740
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;
|
|
741
882
|
const group = document.createElementNS(SVG_NS, "g");
|
|
742
883
|
const textEl = document.createElementNS(SVG_NS, "text");
|
|
743
|
-
textEl.textContent = text;
|
|
744
884
|
textEl.setAttribute("x", "0");
|
|
745
885
|
textEl.setAttribute("y", "0");
|
|
746
886
|
textEl.setAttribute("fill", style.color);
|
|
@@ -751,31 +891,43 @@ function createNoteText(svg, text, style) {
|
|
|
751
891
|
textEl.setAttribute("letter-spacing", "0.4");
|
|
752
892
|
textEl.setAttribute("text-anchor", "start");
|
|
753
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
|
+
});
|
|
754
902
|
group.appendChild(textEl);
|
|
755
903
|
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;
|
|
904
|
+
const lineWidths = tspans.map(
|
|
905
|
+
(tspan, index) => measureWidth(tspan, lines[index], fontSize)
|
|
906
|
+
);
|
|
907
|
+
const width = Math.max(...lineWidths);
|
|
770
908
|
const ascent = fontSize * 0.74;
|
|
771
909
|
const descent = fontSize * 0.26;
|
|
772
|
-
|
|
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
|
+
};
|
|
773
924
|
}
|
|
774
925
|
function placeNoteText(note, box, style, seed, options = {}) {
|
|
775
926
|
const random = createRandom(seed + 55);
|
|
776
927
|
const tilt = options.tilt ?? (random() - 0.5) * 7;
|
|
777
928
|
note.textEl.setAttribute("x", fmt(box.x));
|
|
778
929
|
note.textEl.setAttribute("y", fmt(box.y + note.ascent));
|
|
930
|
+
for (const tspan of note.tspans) tspan.setAttribute("x", fmt(box.x));
|
|
779
931
|
const cx = box.x + box.width / 2;
|
|
780
932
|
const cy = box.y + box.height / 2;
|
|
781
933
|
note.group.setAttribute(
|
|
@@ -783,11 +935,12 @@ function placeNoteText(note, box, style, seed, options = {}) {
|
|
|
783
935
|
`rotate(${tilt.toFixed(2)} ${fmt(cx)} ${fmt(cy)})`
|
|
784
936
|
);
|
|
785
937
|
if (options.underline !== false) {
|
|
938
|
+
const lastWidth = note.lineWidths[note.lineWidths.length - 1] ?? box.width;
|
|
786
939
|
drawUnderline(
|
|
787
940
|
note.group,
|
|
788
941
|
box.x + 1,
|
|
789
942
|
box.y + box.height + 2,
|
|
790
|
-
|
|
943
|
+
lastWidth - 2,
|
|
791
944
|
{
|
|
792
945
|
color: style.color,
|
|
793
946
|
strokeWidth: style.strokeWidth || 1.4,
|
|
@@ -819,25 +972,6 @@ function createOverlaySvg(left, top, width, height) {
|
|
|
819
972
|
|
|
820
973
|
// src/annotations.js
|
|
821
974
|
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
975
|
function boxForPosition(rect, position, size, gap) {
|
|
842
976
|
const direction = directionFromPosition(position);
|
|
843
977
|
const guard = inflateRect(rect, gap);
|
|
@@ -856,75 +990,67 @@ function boxForPosition(rect, position, size, gap) {
|
|
|
856
990
|
}
|
|
857
991
|
return { x, y, width: size.width, height: size.height };
|
|
858
992
|
}
|
|
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;
|
|
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);
|
|
888
999
|
}
|
|
889
|
-
|
|
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 };
|
|
890
1014
|
}
|
|
891
1015
|
function renderAnnotation(svg, rect, noteOptions, style, seed = 1, layout = {}) {
|
|
892
1016
|
if (!noteOptions?.text) return null;
|
|
893
|
-
const
|
|
1017
|
+
const band = layout.band ?? null;
|
|
894
1018
|
const position = isValidPosition(noteOptions.position) ? noteOptions.position : "top-right";
|
|
895
1019
|
const random = createRandom(seed + 9);
|
|
896
|
-
const note = createNoteText(svg, noteOptions.text, style
|
|
1020
|
+
const note = createNoteText(svg, noteOptions.text, style, {
|
|
1021
|
+
wordsPerLine: noteOptions.wordsPerLine
|
|
1022
|
+
});
|
|
897
1023
|
const tilt = (random() - 0.45) * 7;
|
|
898
1024
|
const tiltPad = Math.abs(Math.sin(tilt * Math.PI / 180)) * note.width * 0.5;
|
|
899
1025
|
const size = {
|
|
900
1026
|
width: note.width,
|
|
901
|
-
height: note.
|
|
1027
|
+
height: note.height + (noteOptions.underline === false ? 0 : 5)
|
|
902
1028
|
};
|
|
903
1029
|
const gap = (layout.gap ?? NOTE_GAP) + random() * 6;
|
|
904
|
-
const placed =
|
|
1030
|
+
const placed = boxForPosition(
|
|
905
1031
|
rect,
|
|
906
1032
|
position,
|
|
907
1033
|
{ width: size.width, height: size.height + tiltPad },
|
|
908
|
-
gap
|
|
909
|
-
viewport
|
|
1034
|
+
gap
|
|
910
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
|
+
};
|
|
911
1041
|
const guard = inflateRect(rect, gap * 0.7);
|
|
912
|
-
const outer =
|
|
913
|
-
{ ...placed.box, height: size.height + tiltPad },
|
|
914
|
-
guard,
|
|
915
|
-
viewport
|
|
916
|
-
);
|
|
1042
|
+
const outer = nudgeIntoBand(shifted, guard, band);
|
|
917
1043
|
const box = {
|
|
918
1044
|
x: outer.x,
|
|
919
1045
|
y: outer.y + tiltPad / 2,
|
|
920
1046
|
width: size.width,
|
|
921
|
-
height: note.
|
|
1047
|
+
height: note.height
|
|
922
1048
|
};
|
|
923
1049
|
placeNoteText(note, box, style, seed, {
|
|
924
1050
|
tilt,
|
|
925
1051
|
underline: noteOptions.underline !== false
|
|
926
1052
|
});
|
|
927
|
-
return { group: note.group, box: inflateRect(outer, 4), position
|
|
1053
|
+
return { group: note.group, box: inflateRect(outer, 4), position };
|
|
928
1054
|
}
|
|
929
1055
|
|
|
930
1056
|
// src/decorations.js
|
|
@@ -1342,14 +1468,11 @@ function ensureHandwrittenFont() {
|
|
|
1342
1468
|
|
|
1343
1469
|
// src/doodle.js
|
|
1344
1470
|
var instances = /* @__PURE__ */ new WeakMap();
|
|
1345
|
-
function
|
|
1471
|
+
function localBand(overlayRect, inset = 8) {
|
|
1346
1472
|
const width = window.innerWidth || document.documentElement.clientWidth || 0;
|
|
1347
|
-
const height = window.innerHeight || document.documentElement.clientHeight || 0;
|
|
1348
1473
|
return {
|
|
1349
1474
|
x: -overlayRect.x + inset,
|
|
1350
|
-
|
|
1351
|
-
width: Math.max(0, width - inset * 2),
|
|
1352
|
-
height: Math.max(0, height - inset * 2)
|
|
1475
|
+
width: Math.max(0, width - inset * 2)
|
|
1353
1476
|
};
|
|
1354
1477
|
}
|
|
1355
1478
|
var DoodleOverlay = class {
|
|
@@ -1448,8 +1571,8 @@ var DoodleOverlay = class {
|
|
|
1448
1571
|
};
|
|
1449
1572
|
const noteGroups = [];
|
|
1450
1573
|
const noteLayout = {
|
|
1451
|
-
|
|
1452
|
-
gap: this.options.arrow ?
|
|
1574
|
+
band: localBand(overlayRect),
|
|
1575
|
+
gap: this.options.arrow ? 40 : 14
|
|
1453
1576
|
};
|
|
1454
1577
|
targets.forEach((target, index) => {
|
|
1455
1578
|
const absolute = absoluteRects[index];
|