@aznabee/freehand-ui 0.1.4 → 0.1.6

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.
@@ -1,3 +1,262 @@
1
+ // src/animation.js
2
+ var STYLE_MARKER = "data-freehand-animation";
3
+ var CSS = `
4
+ @keyframes freehand-arrow-dash {
5
+ from { stroke-dashoffset: var(--freehand-dash, 0px); }
6
+ to { stroke-dashoffset: 0; }
7
+ }
8
+ @keyframes freehand-arrow-drift {
9
+ 0%, 100% { transform: translate(0px, 0px); }
10
+ 50% { transform: translate(var(--freehand-drift-x, 0px), var(--freehand-drift-y, 0px)); }
11
+ }
12
+ @keyframes freehand-arrow-settle {
13
+ from { transform: translate(0px, 0px); }
14
+ to { transform: translate(var(--freehand-drift-x, 0px), var(--freehand-drift-y, 0px)); }
15
+ }
16
+ @media (prefers-reduced-motion: reduce) {
17
+ .freehand-arrow, .freehand-arrow path { animation: none !important; }
18
+ .freehand-arrow path { stroke-dashoffset: 0 !important; }
19
+ }
20
+ `;
21
+ var ARROW_ANIMATION_DEFAULTS = {
22
+ /** Reveal the stroke with a dash sweep. */
23
+ draw: true,
24
+ /** Lean the arrow toward its tip. */
25
+ drift: true,
26
+ /** Multiplier over every duration - 2 plays twice as fast. */
27
+ speed: 1,
28
+ /** How long the shaft takes to draw, ms, before `speed`. */
29
+ duration: 900,
30
+ /** Wait before the first stroke appears, ms. */
31
+ delay: 150,
32
+ /** How far the arrow leans, px. */
33
+ distance: 5,
34
+ /** One full lean-and-return, ms. */
35
+ driftDuration: 2200,
36
+ /** Keep leaning. `false` settles once and stays put. */
37
+ repeat: true
38
+ };
39
+ function positive(value, fallback) {
40
+ const n = Number(value);
41
+ return Number.isFinite(n) && n > 0 ? n : fallback;
42
+ }
43
+ function atLeastZero(value, fallback) {
44
+ const n = Number(value);
45
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
46
+ }
47
+ function normalizeArrowAnimation(config) {
48
+ if (config === false || config === null) return false;
49
+ const settings = config === true || config === void 0 || typeof config !== "object" ? {} : config;
50
+ const animation = {
51
+ draw: settings.draw !== false,
52
+ drift: settings.drift !== false,
53
+ speed: positive(settings.speed, ARROW_ANIMATION_DEFAULTS.speed),
54
+ duration: positive(settings.duration, ARROW_ANIMATION_DEFAULTS.duration),
55
+ delay: atLeastZero(settings.delay, ARROW_ANIMATION_DEFAULTS.delay),
56
+ distance: atLeastZero(settings.distance, ARROW_ANIMATION_DEFAULTS.distance),
57
+ driftDuration: positive(
58
+ settings.driftDuration,
59
+ ARROW_ANIMATION_DEFAULTS.driftDuration
60
+ ),
61
+ repeat: settings.repeat !== false
62
+ };
63
+ if (!animation.draw && (!animation.drift || !animation.distance)) return false;
64
+ return animation;
65
+ }
66
+ var FLOW_PERIOD = 260;
67
+ function arrowTiming(animation) {
68
+ const duration = animation.duration / animation.speed;
69
+ const delay = animation.delay / animation.speed;
70
+ const drawn = animation.draw ? duration : 0;
71
+ return {
72
+ shaft: { duration, delay },
73
+ head: { duration: duration * 0.22, delay: delay + drawn * 0.82 },
74
+ flow: { duration: FLOW_PERIOD / animation.speed, delay },
75
+ drift: {
76
+ duration: animation.driftDuration / animation.speed,
77
+ delay: delay + drawn * 1.04
78
+ }
79
+ };
80
+ }
81
+ var injected = false;
82
+ function ensureArrowAnimationStyles() {
83
+ if (injected) return;
84
+ if (typeof document === "undefined" || !document.head) return;
85
+ injected = true;
86
+ if (document.querySelector(`style[${STYLE_MARKER}]`)) return;
87
+ const style = document.createElement("style");
88
+ style.setAttribute(STYLE_MARKER, "");
89
+ style.textContent = CSS;
90
+ document.head.appendChild(style);
91
+ }
92
+ function time(ms) {
93
+ return `${Math.round(ms)}ms`;
94
+ }
95
+ function setAnimation(element, spec) {
96
+ const parts = [
97
+ spec.name,
98
+ time(spec.duration),
99
+ spec.easing,
100
+ time(spec.delay),
101
+ spec.iterations ?? "1"
102
+ ];
103
+ if (spec.fill) parts.push(spec.fill);
104
+ element.style.animation = parts.join(" ");
105
+ }
106
+ function pathLength(path) {
107
+ try {
108
+ const length = path.getTotalLength();
109
+ return Number.isFinite(length) && length > 0 ? length : 0;
110
+ } catch {
111
+ return 0;
112
+ }
113
+ }
114
+ function dashPeriod(path) {
115
+ const pattern = (path.getAttribute("stroke-dasharray") || "").split(/[\s,]+/).map(Number).filter((n) => Number.isFinite(n) && n > 0);
116
+ if (!pattern.length) return 0;
117
+ const sum = pattern.reduce((total, n) => total + n, 0);
118
+ return pattern.length % 2 === 0 ? sum : sum * 2;
119
+ }
120
+ function applyDash(path, timing, elapsed, flow) {
121
+ const length = pathLength(path);
122
+ if (!length) return;
123
+ const period = dashPeriod(path);
124
+ if (period) {
125
+ path.style.setProperty("--freehand-dash", `${period.toFixed(2)}px`);
126
+ setAnimation(path, {
127
+ name: "freehand-arrow-dash",
128
+ duration: flow.duration,
129
+ easing: "linear",
130
+ delay: flow.delay - elapsed,
131
+ iterations: "infinite"
132
+ });
133
+ return;
134
+ }
135
+ path.style.setProperty("--freehand-dash", `${length.toFixed(2)}px`);
136
+ path.style.strokeDasharray = `${length.toFixed(2)}px`;
137
+ setAnimation(path, {
138
+ name: "freehand-arrow-dash",
139
+ duration: timing.duration,
140
+ easing: "ease-out",
141
+ delay: timing.delay - elapsed,
142
+ fill: "both"
143
+ });
144
+ }
145
+ function applyDrift(group, parts, timing, elapsed, animation) {
146
+ const dx = parts.end.x - parts.start.x;
147
+ const dy = parts.end.y - parts.start.y;
148
+ const length = Math.hypot(dx, dy);
149
+ if (!length) return;
150
+ const scale = animation.distance / length;
151
+ group.style.setProperty("--freehand-drift-x", `${(dx * scale).toFixed(2)}px`);
152
+ group.style.setProperty("--freehand-drift-y", `${(dy * scale).toFixed(2)}px`);
153
+ setAnimation(
154
+ group,
155
+ animation.repeat ? {
156
+ name: "freehand-arrow-drift",
157
+ duration: timing.duration,
158
+ easing: "ease-in-out",
159
+ delay: timing.delay - elapsed,
160
+ iterations: "infinite"
161
+ } : {
162
+ // A single lean is a settle, not a round trip, so it takes half a cycle
163
+ name: "freehand-arrow-settle",
164
+ duration: timing.duration * 0.5,
165
+ easing: "ease-out",
166
+ delay: timing.delay - elapsed,
167
+ fill: "both"
168
+ }
169
+ );
170
+ }
171
+ function animateArrow(group, parts, animation, elapsed = 0) {
172
+ if (!animation || !group) return;
173
+ ensureArrowAnimationStyles();
174
+ group.setAttribute("class", "freehand-arrow");
175
+ const timing = arrowTiming(animation);
176
+ const age = Number.isFinite(elapsed) ? Math.max(0, elapsed) : 0;
177
+ if (animation.draw) {
178
+ for (const path of parts.shaft) {
179
+ applyDash(path, timing.shaft, age, timing.flow);
180
+ }
181
+ for (const path of parts.head) applyDash(path, timing.head, age, timing.flow);
182
+ }
183
+ if (animation.drift && animation.distance > 0) {
184
+ applyDrift(group, parts, timing.drift, age, animation);
185
+ }
186
+ }
187
+
188
+ // src/locale.js
189
+ var DEFAULT_KEY = "default";
190
+ var LANGUAGE_TAG = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i;
191
+ var RTL_STRONG = /[֑-߿יִ-﷿ﹰ-ﻼ]/;
192
+ var LTR_STRONG = /[A-Za-zÀ-֏ऀ-῿Ⰰ-퟿豈-ﬗA-Za-z]/;
193
+ function normalizeTag(tag) {
194
+ if (typeof tag !== "string") return "";
195
+ return tag.trim().replace(/_/g, "-").toLowerCase();
196
+ }
197
+ function isLocaleMap(value) {
198
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
199
+ const entries = Object.entries(value);
200
+ if (!entries.length) return false;
201
+ return entries.every(([key, text]) => {
202
+ if (typeof text !== "string") return false;
203
+ const tag = normalizeTag(key);
204
+ return tag === DEFAULT_KEY || LANGUAGE_TAG.test(tag);
205
+ });
206
+ }
207
+ function preferredLocales(override) {
208
+ const tags = [];
209
+ const add = (value) => {
210
+ const tag = normalizeTag(value);
211
+ if (tag && !tags.includes(tag)) tags.push(tag);
212
+ };
213
+ if (Array.isArray(override)) override.forEach(add);
214
+ else add(override);
215
+ const nav = typeof navigator === "undefined" ? null : navigator;
216
+ if (nav) {
217
+ if (Array.isArray(nav.languages)) nav.languages.forEach(add);
218
+ add(nav.language);
219
+ }
220
+ if (typeof document !== "undefined") add(document.documentElement?.lang);
221
+ return tags;
222
+ }
223
+ function matchLocale(tags, preferred) {
224
+ const available = tags.map(normalizeTag);
225
+ for (const want of preferred) {
226
+ let tag = want;
227
+ while (tag) {
228
+ const exact = available.indexOf(tag);
229
+ if (exact !== -1) return exact;
230
+ const cut = tag.lastIndexOf("-");
231
+ tag = cut > 0 ? tag.slice(0, cut) : "";
232
+ }
233
+ const base = want.split("-")[0];
234
+ const sibling = available.findIndex((key) => key.split("-")[0] === base);
235
+ if (sibling !== -1) return sibling;
236
+ }
237
+ return -1;
238
+ }
239
+ function resolveLocalizedText(text, locale = null) {
240
+ if (typeof text === "string") return { text, locale: null };
241
+ if (!isLocaleMap(text)) return { text: "", locale: null };
242
+ const keys = Object.keys(text);
243
+ const tags = keys.filter((key) => normalizeTag(key) !== DEFAULT_KEY);
244
+ const index = matchLocale(tags, preferredLocales(locale));
245
+ if (index !== -1) {
246
+ return { text: text[tags[index]], locale: normalizeTag(tags[index]) };
247
+ }
248
+ const fallback = keys.find((key) => normalizeTag(key) === DEFAULT_KEY);
249
+ if (fallback) return { text: text[fallback], locale: null };
250
+ return { text: text[keys[0]], locale: normalizeTag(keys[0]) };
251
+ }
252
+ function isRtlText(text) {
253
+ if (typeof text !== "string") return false;
254
+ const rtl = text.search(RTL_STRONG);
255
+ if (rtl === -1) return false;
256
+ const ltr = text.search(LTR_STRONG);
257
+ return ltr === -1 || rtl < ltr;
258
+ }
259
+
1
260
  // src/utils.js
2
261
  var DEFAULT_OPTIONS = {
3
262
  border: true,
@@ -16,7 +275,17 @@ var DEFAULT_OPTIONS = {
16
275
  /** Override the handwriting stack, e.g. a next/font CSS variable. */
17
276
  fontFamily: null,
18
277
  /** Fetch Caveat when the page has not provided it. */
19
- autoLoadFont: true
278
+ autoLoadFont: true,
279
+ /**
280
+ * Language for a note written in more than one. Omit to follow the visitor's
281
+ * own languages, which is what their operating system is set to.
282
+ */
283
+ locale: null,
284
+ /**
285
+ * Overlay stacking order. Omit to mirror the target element's z-index so
286
+ * doodles stay above the annotated element but below modals and other UI.
287
+ */
288
+ zIndex: null
20
289
  };
21
290
  function resolveElement(target) {
22
291
  if (!target) return null;
@@ -30,8 +299,15 @@ function resolveElement(target) {
30
299
  }
31
300
  function mergeOptions(options = {}) {
32
301
  const merged = { ...DEFAULT_OPTIONS, ...options };
33
- if (merged.note && typeof merged.note === "string") {
34
- merged.note = { text: merged.note, position: "top-right" };
302
+ if (merged.note) {
303
+ const config = typeof merged.note === "string" || isLocaleMap(merged.note) ? { text: merged.note } : merged.note;
304
+ merged.note = {
305
+ ...config,
306
+ position: config.position ?? "top-right",
307
+ // The note carries the chosen language, so `renderAnnotation` can resolve
308
+ // a multilingual note on its own.
309
+ locale: config.locale ?? merged.locale ?? null
310
+ };
35
311
  }
36
312
  if (merged.arrow) {
37
313
  const config = merged.arrow === true ? {} : merged.arrow;
@@ -41,7 +317,10 @@ function mergeOptions(options = {}) {
41
317
  // explicit `from` is honoured whether or not there is a note.
42
318
  from: config.from ?? (merged.note ? "note" : "top-right"),
43
319
  to: config.to ?? "edge",
44
- style: config.style ?? "curved"
320
+ style: config.style ?? "curved",
321
+ // Arrows draw themselves on and lean toward their target by default;
322
+ // `animate: false` opts out, an object tunes speed and gestures
323
+ animate: normalizeArrowAnimation(config.animate)
45
324
  };
46
325
  }
47
326
  if (merged.decorations === true) {
@@ -109,6 +388,40 @@ function getScrollableAncestors(element) {
109
388
  ancestors.push(window);
110
389
  return ancestors;
111
390
  }
391
+ function resolveOverlayZIndex(element, override) {
392
+ if (override != null) return override;
393
+ const { zIndex, position } = getComputedStyle(element);
394
+ if (zIndex !== "auto") {
395
+ const parsed = Number.parseInt(zIndex, 10);
396
+ if (Number.isFinite(parsed)) return parsed + 1;
397
+ }
398
+ let node = element.parentElement;
399
+ while (node && node !== document.documentElement) {
400
+ const style = getComputedStyle(node);
401
+ if (style.zIndex !== "auto" && style.position !== "static") {
402
+ const parsed = Number.parseInt(style.zIndex, 10);
403
+ if (Number.isFinite(parsed)) return parsed + 1;
404
+ }
405
+ node = node.parentElement;
406
+ }
407
+ return null;
408
+ }
409
+ function insertOverlaySvg(svg, element) {
410
+ const parent = element.parentNode;
411
+ if (!parent) {
412
+ document.body.appendChild(svg);
413
+ return;
414
+ }
415
+ parent.insertBefore(svg, element.nextSibling);
416
+ }
417
+ function syncOverlayStacking(svg, element, zIndexOverride) {
418
+ const zIndex = resolveOverlayZIndex(element, zIndexOverride);
419
+ if (zIndex == null) {
420
+ svg.style.zIndex = "";
421
+ } else {
422
+ svg.style.zIndex = String(zIndex);
423
+ }
424
+ }
112
425
 
113
426
  // src/geometry.js
114
427
  function averageCornerRadius(element) {
@@ -256,6 +569,7 @@ function annotationAnchor(position, rect, offset = 12) {
256
569
 
257
570
  // src/renderer.js
258
571
  var SVG_NS = "http://www.w3.org/2000/svg";
572
+ var XML_NS = "http://www.w3.org/XML/1998/namespace";
259
573
  var HANDWRITTEN_FONT = '"Caveat", "Kalam", "Patrick Hand", "Bradley Hand", "Segoe Script", "Comic Sans MS", cursive';
260
574
  var NOTE_FONT_SIZE = 19;
261
575
  function clearSvg(svg) {
@@ -501,6 +815,15 @@ function appendPath(svg, d, style) {
501
815
  svg.appendChild(path);
502
816
  return path;
503
817
  }
818
+ function appendGlow(svg, radius, style) {
819
+ const circle = document.createElementNS(SVG_NS, "circle");
820
+ circle.setAttribute("r", fmt(radius));
821
+ circle.setAttribute("fill", style.color);
822
+ circle.setAttribute("opacity", String(style.opacity));
823
+ circle.style.filter = `blur(${fmt(radius * 0.4)}px)`;
824
+ svg.appendChild(circle);
825
+ return circle;
826
+ }
504
827
  function rectPerimeter(width, height, radius) {
505
828
  const r = clamp(radius, 0, Math.min(width, height) / 2);
506
829
  return 2 * (width - 2 * r) + 2 * (height - 2 * r) + 2 * Math.PI * r;
@@ -587,20 +910,21 @@ function cubicTangentAt(p0, c1, c2, p3, t) {
587
910
  y: a * (c1.y - p0.y) + b * (c2.y - c1.y) + c * (p3.y - c2.y)
588
911
  };
589
912
  }
590
- function loopedCurvePoints(start, c1, c2, end, radius, random) {
913
+ var STRADDLE = 0.75;
914
+ function loopedCurvePoints(start, c1, c2, end, radius, random, side = 1) {
591
915
  const at = (t) => cubicAt(start, c1, c2, end, t);
592
- const tangentAt = (t) => normalize(cubicTangentAt(start, c1, c2, end, t));
593
916
  const knot = 0.46 + random() * 0.12;
594
917
  const points = [];
595
918
  const lead = Math.max(2, Math.round(knot * 16));
596
919
  for (let i = 0; i <= lead; i++) points.push(at(knot * i / lead));
597
920
  const pivot = at(knot);
598
- const heading = tangentAt(knot);
921
+ const derivative = cubicTangentAt(start, c1, c2, end, knot);
922
+ const speed = Math.hypot(derivative.x, derivative.y) || 1;
923
+ const heading = normalize(derivative);
599
924
  const normal = { x: -heading.y, y: heading.x };
600
- const side = random() < 0.5 ? 1 : -1;
601
925
  const centre = {
602
- x: pivot.x + normal.x * radius * side,
603
- y: pivot.y + normal.y * radius * side
926
+ x: pivot.x + normal.x * radius * STRADDLE * side,
927
+ y: pivot.y + normal.y * radius * STRADDLE * side
604
928
  };
605
929
  const from = Math.atan2(pivot.y - centre.y, pivot.x - centre.x);
606
930
  const turns = Math.PI * 2 * (0.97 + random() * 0.08);
@@ -608,15 +932,27 @@ function loopedCurvePoints(start, c1, c2, end, radius, random) {
608
932
  for (let i = 1; i <= steps; i++) {
609
933
  const progress = i / steps;
610
934
  const angle = from + turns * progress * side;
935
+ const spread = radius * (STRADDLE + (1 - STRADDLE) * progress);
611
936
  const drift = radius * 0.55 * progress;
612
937
  points.push({
613
- x: centre.x + Math.cos(angle) * radius + heading.x * drift,
614
- y: centre.y + Math.sin(angle) * radius + heading.y * drift
938
+ x: centre.x + Math.cos(angle) * spread + heading.x * drift,
939
+ y: centre.y + Math.sin(angle) * spread + heading.y * drift
615
940
  });
616
941
  }
942
+ const exit = points[points.length - 1];
943
+ const gained = (exit.x - pivot.x) * heading.x + (exit.y - pivot.y) * heading.y;
944
+ const resume = Math.min(knot + Math.max(0, gained) / speed, 0.985);
945
+ const rejoin = at(resume);
946
+ const residual = (exit.x - rejoin.x) * normal.x + (exit.y - rejoin.y) * normal.y;
947
+ const settle = 6;
617
948
  const tail = 14;
618
949
  for (let i = 1; i <= tail; i++) {
619
- points.push(at(knot + (1 - knot) * (i / tail)));
950
+ const point = at(resume + (1 - resume) * (i / tail));
951
+ const fade = Math.max(0, 1 - i / settle);
952
+ points.push({
953
+ x: point.x + normal.x * residual * fade,
954
+ y: point.y + normal.y * residual * fade
955
+ });
620
956
  }
621
957
  return points;
622
958
  }
@@ -690,21 +1026,29 @@ function arrowEndpoints(rect, arrowOptions, random, origin) {
690
1026
  function drawArrowHead(svg, tip, angle, style, random, shaftLength = Infinity) {
691
1027
  const length = Math.min(9 + style.strokeWidth * 1.4, shaftLength * 0.3);
692
1028
  const spread = 0.46 + random() * 0.1;
1029
+ const arms = [];
693
1030
  for (const side of [-1, 1]) {
694
1031
  const armAngle = angle + spread * side;
695
1032
  const tail = {
696
1033
  x: tip.x - length * Math.cos(armAngle),
697
1034
  y: tip.y - length * Math.sin(armAngle)
698
1035
  };
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 }
1036
+ arms.push(
1037
+ appendPath(
1038
+ svg,
1039
+ roughLine(tail.x, tail.y, tip.x, tip.y, style.roughness * 0.5, random, {
1040
+ steps: 2,
1041
+ bow: 0.35 * side
1042
+ }),
1043
+ { ...style, dashed: false }
1044
+ )
706
1045
  );
707
1046
  }
1047
+ return arms;
1048
+ }
1049
+ function bowOverTheTop(perpendicular) {
1050
+ if (Math.abs(perpendicular.y) < 0.02) return perpendicular.x > 0 ? 1 : -1;
1051
+ return perpendicular.y > 0 ? -1 : 1;
708
1052
  }
709
1053
  function boundsOf(points) {
710
1054
  const xs = points.map((p) => p.x);
@@ -716,7 +1060,7 @@ function boundsOf(points) {
716
1060
  height: Math.max(...ys) - Math.min(...ys)
717
1061
  };
718
1062
  }
719
- function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
1063
+ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null, context = {}) {
720
1064
  const random = createRandom(seed + 17);
721
1065
  const { start, end } = arrowEndpoints(rect, arrowOptions, random, origin);
722
1066
  const dx = end.x - start.x;
@@ -725,6 +1069,7 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
725
1069
  const dashed = arrowOptions.style === "dotted";
726
1070
  let tipAngle = Math.atan2(dy, dx);
727
1071
  let shaft;
1072
+ let ghost = null;
728
1073
  const hull = [start, end];
729
1074
  if (arrowOptions.style === "straight") {
730
1075
  shaft = roughLine(start.x, start.y, end.x, end.y, style.roughness, random, {
@@ -738,7 +1083,12 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
738
1083
  y: rect.y + rect.height / 2 - mid.y
739
1084
  };
740
1085
  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;
1086
+ const sign = Math.abs(lateral) > distance * 0.12 ? lateral > 0 ? -1 : 1 : (
1087
+ // Nothing to steer around, so sweep over the top: a hand arcs above
1088
+ // the straight line and drops onto the target, and a coin flip here
1089
+ // used to mirror the whole gesture from one redraw to the next.
1090
+ bowOverTheTop(perpendicular)
1091
+ );
742
1092
  const bow = Math.min(distance * (0.16 + random() * 0.08), 30) * sign;
743
1093
  const c1x = start.x + dx * 0.28 + perpendicular.x * bow;
744
1094
  const c1y = start.y + dy * 0.28 + perpendicular.y * bow;
@@ -753,34 +1103,46 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
753
1103
  { x: c2x, y: c2y },
754
1104
  end,
755
1105
  radius,
756
- random
1106
+ random,
1107
+ -sign
757
1108
  );
758
1109
  shaft = catmullRomPath(points, false);
759
1110
  hull.push(...points);
760
1111
  const last = points[points.length - 1];
761
1112
  const prior = points[points.length - 2] ?? start;
762
1113
  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) };
1114
+ } else {
1115
+ shaft = `M ${fmt(start.x)} ${fmt(start.y)} C ${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(end.x)} ${fmt(end.y)}`;
1116
+ if (style.roughness >= 1.1) {
1117
+ const drift = () => (random() - 0.5) * 1.4;
1118
+ ghost = `M ${fmt(start.x + drift())} ${fmt(start.y + drift())} C ${fmt(c1x + drift())} ${fmt(c1y + drift())} ${fmt(c2x + drift())} ${fmt(c2y + drift())} ${fmt(end.x + drift() * 0.4)} ${fmt(end.y + drift() * 0.4)}`;
1119
+ }
1120
+ tipAngle = Math.atan2(end.y - c2y, end.x - c2x);
766
1121
  }
767
- shaft = `M ${fmt(start.x)} ${fmt(start.y)} C ${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(end.x)} ${fmt(end.y)}`;
768
- if (style.roughness >= 1.1) {
769
- const drift = () => (random() - 0.5) * 1.4;
770
- const ghost = `M ${fmt(start.x + drift())} ${fmt(start.y + drift())} C ${fmt(c1x + drift())} ${fmt(c1y + drift())} ${fmt(c2x + drift())} ${fmt(c2y + drift())} ${fmt(end.x + drift() * 0.4)} ${fmt(end.y + drift() * 0.4)}`;
771
- appendPath(svg, ghost, {
1122
+ }
1123
+ const group = document.createElementNS(SVG_NS, "g");
1124
+ svg.appendChild(group);
1125
+ const shaftPaths = [];
1126
+ if (ghost) {
1127
+ shaftPaths.push(
1128
+ appendPath(group, ghost, {
772
1129
  ...style,
773
1130
  opacity: style.opacity * 0.3,
774
1131
  strokeWidth: style.strokeWidth * 0.7,
775
1132
  dashed
776
- });
777
- }
778
- tipAngle = Math.atan2(end.y - c2y, end.x - c2x);
1133
+ })
1134
+ );
779
1135
  }
780
- appendPath(svg, shaft, { ...style, dashed });
781
- drawArrowHead(svg, end, tipAngle, style, random, distance);
1136
+ shaftPaths.push(appendPath(group, shaft, { ...style, dashed }));
1137
+ const head = drawArrowHead(group, end, tipAngle, style, random, distance);
1138
+ animateArrow(
1139
+ group,
1140
+ { shaft: shaftPaths, head, start, end },
1141
+ arrowOptions.animate,
1142
+ context.elapsed
1143
+ );
782
1144
  const bounds = boundsOf(hull);
783
- return { start, end, bounds };
1145
+ return { start, end, bounds, group };
784
1146
  }
785
1147
  function drawUnderline(svg, x, y, width, style, random) {
786
1148
  if (width <= 4) return;
@@ -847,6 +1209,7 @@ function createNoteText(svg, text, style, options = {}) {
847
1209
  const fontSize = style.fontSize ?? NOTE_FONT_SIZE;
848
1210
  const lines = wrapWords(text, options.wordsPerLine ?? DEFAULT_WORDS_PER_LINE);
849
1211
  const lineHeight = fontSize * 1.05;
1212
+ const rtl = options.rtl ?? isRtlText(text);
850
1213
  const group = document.createElementNS(SVG_NS, "g");
851
1214
  const textEl = document.createElementNS(SVG_NS, "text");
852
1215
  textEl.setAttribute("x", "0");
@@ -859,6 +1222,13 @@ function createNoteText(svg, text, style, options = {}) {
859
1222
  textEl.setAttribute("letter-spacing", "0.4");
860
1223
  textEl.setAttribute("text-anchor", "start");
861
1224
  textEl.setAttribute("dominant-baseline", "auto");
1225
+ if (rtl) {
1226
+ textEl.setAttribute("direction", "rtl");
1227
+ textEl.setAttribute("unicode-bidi", "embed");
1228
+ }
1229
+ if (options.locale) {
1230
+ textEl.setAttributeNS(XML_NS, "xml:lang", options.locale);
1231
+ }
862
1232
  const tspans = lines.map((line, index) => {
863
1233
  const tspan = document.createElementNS(SVG_NS, "tspan");
864
1234
  tspan.textContent = line;
@@ -887,15 +1257,17 @@ function createNoteText(svg, text, style, options = {}) {
887
1257
  height,
888
1258
  ascent,
889
1259
  descent,
890
- fontSize
1260
+ fontSize,
1261
+ rtl
891
1262
  };
892
1263
  }
893
1264
  function placeNoteText(note, box, style, seed, options = {}) {
894
1265
  const random = createRandom(seed + 55);
895
1266
  const tilt = options.tilt ?? (random() - 0.5) * 7;
896
- note.textEl.setAttribute("x", fmt(box.x));
1267
+ const anchorX = note.rtl ? box.x + box.width : box.x;
1268
+ note.textEl.setAttribute("x", fmt(anchorX));
897
1269
  note.textEl.setAttribute("y", fmt(box.y + note.ascent));
898
- for (const tspan of note.tspans) tspan.setAttribute("x", fmt(box.x));
1270
+ for (const tspan of note.tspans) tspan.setAttribute("x", fmt(anchorX));
899
1271
  const cx = box.x + box.width / 2;
900
1272
  const cy = box.y + box.height / 2;
901
1273
  note.group.setAttribute(
@@ -904,9 +1276,10 @@ function placeNoteText(note, box, style, seed, options = {}) {
904
1276
  );
905
1277
  if (options.underline !== false) {
906
1278
  const lastWidth = note.lineWidths[note.lineWidths.length - 1] ?? box.width;
1279
+ const underlineX = note.rtl ? box.x + box.width - lastWidth + 1 : box.x + 1;
907
1280
  drawUnderline(
908
1281
  note.group,
909
- box.x + 1,
1282
+ underlineX,
910
1283
  box.y + box.height + 2,
911
1284
  lastWidth - 2,
912
1285
  {
@@ -934,7 +1307,7 @@ function createOverlaySvg(left, top, width, height) {
934
1307
  svg.style.height = `${height}px`;
935
1308
  svg.style.pointerEvents = "none";
936
1309
  svg.style.overflow = "visible";
937
- svg.style.zIndex = "2147483646";
1310
+ svg.style.willChange = "transform";
938
1311
  return svg;
939
1312
  }
940
1313
 
@@ -981,12 +1354,17 @@ function nudgeIntoBand(box, guard, band) {
981
1354
  return { ...box, x: box.x + dx };
982
1355
  }
983
1356
  function renderAnnotation(svg, rect, noteOptions, style, seed = 1, layout = {}) {
984
- if (!noteOptions?.text) return null;
1357
+ const { text, locale } = resolveLocalizedText(
1358
+ noteOptions?.text,
1359
+ noteOptions?.locale
1360
+ );
1361
+ if (!text) return null;
985
1362
  const band = layout.band ?? null;
986
1363
  const position = isValidPosition(noteOptions.position) ? noteOptions.position : "top-right";
987
1364
  const random = createRandom(seed + 9);
988
- const note = createNoteText(svg, noteOptions.text, style, {
989
- wordsPerLine: noteOptions.wordsPerLine
1365
+ const note = createNoteText(svg, text, style, {
1366
+ wordsPerLine: noteOptions.wordsPerLine,
1367
+ locale
990
1368
  });
991
1369
  const tilt = (random() - 0.45) * 7;
992
1370
  const tiltPad = Math.abs(Math.sin(tilt * Math.PI / 180)) * note.width * 0.5;
@@ -1028,6 +1406,25 @@ function drawDot(parent, x, y, style, scale = 1.7) {
1028
1406
  strokeWidth: style.strokeWidth * scale
1029
1407
  });
1030
1408
  }
1409
+ function drawFleck(parent, x, y, radius, style, random) {
1410
+ const count = 6;
1411
+ const points = [];
1412
+ for (let i = 0; i < count; i++) {
1413
+ const angle = Math.PI * 2 * i / count + (random() - 0.5) * 0.5;
1414
+ const r = radius * (0.78 + random() * 0.34);
1415
+ points.push({
1416
+ x: x + Math.cos(angle) * r,
1417
+ y: y + Math.sin(angle) * r
1418
+ });
1419
+ }
1420
+ const blob = appendPath(parent, catmullRomPath(points, true), {
1421
+ ...style,
1422
+ fill: style.color,
1423
+ strokeWidth: 0
1424
+ });
1425
+ blob.style.filter = `blur(${(radius * 0.35).toFixed(2)}px)`;
1426
+ return blob;
1427
+ }
1031
1428
  function drawHeart(parent, size, style, random) {
1032
1429
  const s = size;
1033
1430
  const j = () => (random() - 0.5) * s * 0.12;
@@ -1051,26 +1448,61 @@ function twinkle(parent, radiusX, radiusY, pinch, style) {
1051
1448
  `Q ${(-c).toFixed(2)} ${(-c).toFixed(2)} 0 ${(-ry).toFixed(2)}`,
1052
1449
  "Z"
1053
1450
  ].join(" ");
1054
- appendPath(parent, path, style);
1451
+ return appendPath(parent, path, style);
1055
1452
  }
1056
1453
  function drawSparkle(parent, size, style, random) {
1057
1454
  twinkle(
1058
1455
  parent,
1059
- size * (0.34 + random() * 0.1),
1456
+ size * (0.62 + random() * 0.12),
1060
1457
  size,
1061
- size * (0.06 + random() * 0.06),
1458
+ size * (0.14 + random() * 0.08),
1062
1459
  style
1063
1460
  );
1064
1461
  }
1065
1462
  function drawStar(parent, size, style, random) {
1066
1463
  twinkle(
1067
1464
  parent,
1068
- size * (0.66 + random() * 0.12),
1069
- size,
1070
- size * (0.2 + random() * 0.08),
1465
+ size * (0.62 + random() * 0.12),
1466
+ size * (0.8 + random() * 0.12),
1467
+ size * (0.14 + random() * 0.08),
1071
1468
  style
1072
1469
  );
1073
1470
  }
1471
+ function drawGlimmer(parent, size, style, random) {
1472
+ appendGlow(parent, size * (1.2 + random() * 0.2), {
1473
+ color: style.color,
1474
+ opacity: style.opacity * 0.22
1475
+ });
1476
+ const armX = size * (0.5 + random() * 0.08);
1477
+ const armY = size * (0.45 + random() * 0.08);
1478
+ const bluntness = size * 0.19;
1479
+ const main = twinkle(parent, armX, armY, size * (0.08 + random() * 0.03), {
1480
+ ...style,
1481
+ fill: style.color,
1482
+ strokeWidth: bluntness
1483
+ });
1484
+ main.setAttribute("stroke-linejoin", "round");
1485
+ main.setAttribute("stroke-linecap", "round");
1486
+ main.style.filter = `blur(${(size * 0.05).toFixed(2)}px)`;
1487
+ const gap = bluntness / 2 + size * 0.2;
1488
+ const corners = [
1489
+ { x: 0, y: -(armY + gap) },
1490
+ { x: armX + gap, y: 0 },
1491
+ { x: 0, y: armY + gap },
1492
+ { x: -(armX + gap), y: 0 }
1493
+ ];
1494
+ for (const { x, y } of corners) {
1495
+ const jitter = 0.95 + random() * 0.1;
1496
+ drawFleck(
1497
+ parent,
1498
+ x * jitter,
1499
+ y * jitter,
1500
+ size * (0.1 + random() * 0.03),
1501
+ style,
1502
+ random
1503
+ );
1504
+ }
1505
+ }
1074
1506
  function drawTwinkle(parent, size, style, random) {
1075
1507
  const count = 5;
1076
1508
  const first = -Math.PI / 2 + (random() - 0.5) * 0.5;
@@ -1178,46 +1610,15 @@ function drawEmphasis(parent, size, style, random, opts = {}) {
1178
1610
  );
1179
1611
  }
1180
1612
  }
1181
- function drawDots(parent, size, style, random) {
1182
- let x = -size * 0.7;
1183
- for (let i = 0; i < 3; i++) {
1184
- drawDot(parent, x, (random() - 0.5) * size * 0.16, style);
1185
- x += size * (0.6 + random() * 0.25);
1186
- }
1187
- }
1188
- function drawStroke(parent, size, style, random) {
1189
- appendPath(
1190
- parent,
1191
- roughLine(-size, 0, size, 0, 1.2, random, {
1192
- bow: (random() - 0.5) * size * 0.35,
1193
- steps: 4
1194
- }),
1195
- style
1196
- );
1197
- }
1198
- function drawSteam(parent, size, style, random) {
1199
- const lean = random() < 0.5 ? -1 : 1;
1200
- for (let i = 0; i < 2; i++) {
1201
- const x = (i - 0.5) * size * 0.72;
1202
- const base = size * 0.62 - i * size * 0.14;
1203
- appendPath(
1204
- parent,
1205
- `M ${x.toFixed(2)} ${base.toFixed(2)} Q ${(x + lean * size * 0.52).toFixed(2)} ${(base - size * 0.62).toFixed(2)} ${(x + lean * size * 0.06).toFixed(2)} ${(base - size * 1.3).toFixed(2)}`,
1206
- style
1207
- );
1208
- }
1209
- }
1210
1613
  var DECORATION_DRAWERS = {
1211
1614
  heart: drawHeart,
1212
1615
  sparkle: drawSparkle,
1213
1616
  star: drawStar,
1214
1617
  twinkle: drawTwinkle,
1618
+ glimmer: drawGlimmer,
1215
1619
  smiley: drawSmiley,
1216
1620
  emphasis: drawEmphasis,
1217
- arcs: drawArcs,
1218
- dots: drawDots,
1219
- stroke: drawStroke,
1220
- steam: drawSteam
1621
+ arcs: drawArcs
1221
1622
  };
1222
1623
  var TEXT_TYPES = ["heart", "sparkle"];
1223
1624
  var CORNER_ACCENTS = ["arcs", "emphasis"];
@@ -1228,12 +1629,10 @@ var TYPE_TRAITS = {
1228
1629
  sparkle: { size: [6, 9], tilt: 20, standoff: 22 },
1229
1630
  star: { size: [8, 11], tilt: 25, standoff: 26 },
1230
1631
  twinkle: { size: [5, 7.5], tilt: 30, standoff: 26, brightness: 1.4 },
1632
+ glimmer: { size: [7, 10], tilt: 20, standoff: 28, brightness: 1.3 },
1231
1633
  smiley: { size: [8, 11], tilt: 10, standoff: 22 },
1232
1634
  emphasis: { size: [12, 17], tilt: 10, standoff: 13, outward: true },
1233
- arcs: { size: [11, 16], tilt: 12, standoff: 12, outward: true },
1234
- dots: { size: [6, 9], tilt: 8, standoff: 20 },
1235
- stroke: { size: [8, 12], tilt: 40, standoff: 20 },
1236
- steam: { size: [8, 11], tilt: 10, standoff: 20 }
1635
+ arcs: { size: [11, 16], tilt: 12, standoff: 12, outward: true }
1237
1636
  };
1238
1637
  var DEFAULT_TYPES = [
1239
1638
  "twinkle",
@@ -1436,6 +1835,9 @@ function ensureHandwrittenFont() {
1436
1835
 
1437
1836
  // src/doodle.js
1438
1837
  var instances = /* @__PURE__ */ new WeakMap();
1838
+ function now() {
1839
+ return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1840
+ }
1439
1841
  function localBand(overlayRect, inset = 8) {
1440
1842
  const width = window.innerWidth || document.documentElement.clientWidth || 0;
1441
1843
  return {
@@ -1452,19 +1854,24 @@ var DoodleOverlay = class {
1452
1854
  this.element = element;
1453
1855
  this.options = options;
1454
1856
  this.seed = Math.floor(Math.random() * 1e9);
1857
+ this.startedAt = now();
1455
1858
  this.svg = null;
1456
1859
  this.resizeObserver = null;
1457
1860
  this.mutationObserver = null;
1458
1861
  this.pendingFrame = null;
1459
- this.scrollTargets = [];
1460
- this.onScroll = this.scheduleUpdate.bind(this);
1862
+ this.pendingRepositionFrame = null;
1863
+ this.basePosition = { x: 0, y: 0 };
1864
+ this.listeners = [];
1865
+ this.onScroll = this.scheduleReposition.bind(this);
1461
1866
  this.onResize = this.scheduleUpdate.bind(this);
1867
+ this.onLanguageChange = this.scheduleUpdate.bind(this);
1462
1868
  this.childElements = [];
1463
1869
  this.mount();
1464
1870
  }
1465
1871
  mount() {
1466
1872
  this.svg = createOverlaySvg(0, 0, 0, 0);
1467
- document.body.appendChild(this.svg);
1873
+ insertOverlaySvg(this.svg, this.element);
1874
+ syncOverlayStacking(this.svg, this.element, this.options.zIndex);
1468
1875
  this.resizeObserver = new ResizeObserver(this.onResize);
1469
1876
  this.resizeObserver.observe(this.element);
1470
1877
  if (this.options.children) {
@@ -1478,10 +1885,20 @@ var DoodleOverlay = class {
1478
1885
  });
1479
1886
  for (const target of getScrollableAncestors(this.element)) {
1480
1887
  target.addEventListener("scroll", this.onScroll, { passive: true });
1481
- this.scrollTargets.push({ target, type: "scroll" });
1888
+ this.listeners.push({ target, type: "scroll", handler: this.onScroll });
1482
1889
  }
1483
1890
  window.addEventListener("resize", this.onResize, { passive: true });
1484
- this.scrollTargets.push({ target: window, type: "resize" });
1891
+ this.listeners.push({
1892
+ target: window,
1893
+ type: "resize",
1894
+ handler: this.onResize
1895
+ });
1896
+ window.addEventListener("languagechange", this.onLanguageChange);
1897
+ this.listeners.push({
1898
+ target: window,
1899
+ type: "languagechange",
1900
+ handler: this.onLanguageChange
1901
+ });
1485
1902
  if (this.options.note && this.options.autoLoadFont && !this.options.fontFamily) {
1486
1903
  ensureHandwrittenFont();
1487
1904
  }
@@ -1505,6 +1922,46 @@ var DoodleOverlay = class {
1505
1922
  this.update();
1506
1923
  });
1507
1924
  }
1925
+ scheduleReposition() {
1926
+ if (this.pendingFrame != null || this.pendingRepositionFrame != null) {
1927
+ return;
1928
+ }
1929
+ this.pendingRepositionFrame = scheduleFrame(() => {
1930
+ this.pendingRepositionFrame = null;
1931
+ this.reposition();
1932
+ });
1933
+ }
1934
+ /**
1935
+ * Cheap scroll-driven path: slide the overlay to the element's new
1936
+ * viewport position without touching its contents. Scrolling can't change
1937
+ * an element's size, so the rects, viewBox and every hand-drawn stroke
1938
+ * inside them are still valid - only the overlay's position needs to move.
1939
+ *
1940
+ * That move is a `transform`, not `left`/`top`. `left`/`top` sit in the
1941
+ * same box-geometry pass as layout, so the browser re-checks layout on
1942
+ * every write; `transform` is composited, so the scroll path never
1943
+ * touches layout at all.
1944
+ */
1945
+ reposition() {
1946
+ if (!this.svg || !this.element.isConnected) return;
1947
+ const { padding, radius } = this.options;
1948
+ const targets = this.getTargets();
1949
+ const margin = this.overlayMargin();
1950
+ const absoluteRects = targets.map(
1951
+ (target) => getElementBounds(target, padding, radius)
1952
+ );
1953
+ const overlayRect = unionRects(absoluteRects, margin);
1954
+ const currentWidth = parseFloat(this.svg.style.width) || 0;
1955
+ const currentHeight = parseFloat(this.svg.style.height) || 0;
1956
+ const resized = Math.abs(overlayRect.width - currentWidth) > 0.5 || Math.abs(overlayRect.height - currentHeight) > 0.5;
1957
+ if (resized) {
1958
+ this.update();
1959
+ return;
1960
+ }
1961
+ const dx = overlayRect.x - this.basePosition.x;
1962
+ const dy = overlayRect.y - this.basePosition.y;
1963
+ this.svg.style.transform = `translate3d(${dx}px, ${dy}px, 0)`;
1964
+ }
1508
1965
  getTargets() {
1509
1966
  if (this.options.children) {
1510
1967
  const nodes = this.element.querySelectorAll(this.options.children);
@@ -1517,11 +1974,13 @@ var DoodleOverlay = class {
1517
1974
  const { padding, radius, color, strokeWidth, roughness, opacity } = this.options;
1518
1975
  const targets = this.getTargets();
1519
1976
  const margin = this.overlayMargin();
1977
+ const elapsed = now() - this.startedAt;
1520
1978
  const absoluteRects = targets.map(
1521
1979
  (target) => getElementBounds(target, padding, radius)
1522
1980
  );
1523
1981
  const overlayRect = unionRects(absoluteRects, margin);
1524
1982
  clearSvg(this.svg);
1983
+ syncOverlayStacking(this.svg, this.element, this.options.zIndex);
1525
1984
  this.svg.setAttribute("width", String(overlayRect.width));
1526
1985
  this.svg.setAttribute("height", String(overlayRect.height));
1527
1986
  this.svg.setAttribute("viewBox", `0 0 ${overlayRect.width} ${overlayRect.height}`);
@@ -1529,6 +1988,8 @@ var DoodleOverlay = class {
1529
1988
  this.svg.style.top = `${overlayRect.y}px`;
1530
1989
  this.svg.style.width = `${overlayRect.width}px`;
1531
1990
  this.svg.style.height = `${overlayRect.height}px`;
1991
+ this.svg.style.transform = "";
1992
+ this.basePosition = { x: overlayRect.x, y: overlayRect.y };
1532
1993
  const strokeStyle = { color, strokeWidth, roughness, opacity };
1533
1994
  const noteStyle = {
1534
1995
  color,
@@ -1568,7 +2029,8 @@ var DoodleOverlay = class {
1568
2029
  this.options.arrow,
1569
2030
  strokeStyle,
1570
2031
  seed,
1571
- note ? note.box : null
2032
+ note ? note.box : null,
2033
+ { elapsed }
1572
2034
  ) : null;
1573
2035
  if (this.options.decorations) {
1574
2036
  renderDecorations(
@@ -1595,7 +2057,7 @@ var DoodleOverlay = class {
1595
2057
  overlayMargin() {
1596
2058
  const { note, arrow, decorations } = this.options;
1597
2059
  if (!note && !arrow && !decorations) return 48;
1598
- const text = typeof note?.text === "string" ? note.text : "";
2060
+ const { text } = resolveLocalizedText(note?.text, note?.locale);
1599
2061
  return Math.max(96, Math.min(240, 80 + text.length * 8));
1600
2062
  }
1601
2063
  destroy() {
@@ -1603,6 +2065,10 @@ var DoodleOverlay = class {
1603
2065
  cancelFrame(this.pendingFrame);
1604
2066
  this.pendingFrame = null;
1605
2067
  }
2068
+ if (this.pendingRepositionFrame != null) {
2069
+ cancelFrame(this.pendingRepositionFrame);
2070
+ this.pendingRepositionFrame = null;
2071
+ }
1606
2072
  if (this.resizeObserver) {
1607
2073
  this.resizeObserver.disconnect();
1608
2074
  this.resizeObserver = null;
@@ -1615,10 +2081,10 @@ var DoodleOverlay = class {
1615
2081
  document.fonts?.removeEventListener?.("loadingdone", this.onFontsLoaded);
1616
2082
  this.onFontsLoaded = null;
1617
2083
  }
1618
- for (const { target, type } of this.scrollTargets) {
1619
- target.removeEventListener(type, type === "scroll" ? this.onScroll : this.onResize);
2084
+ for (const { target, type, handler } of this.listeners) {
2085
+ target.removeEventListener(type, handler);
1620
2086
  }
1621
- this.scrollTargets = [];
2087
+ this.listeners = [];
1622
2088
  if (this.svg?.parentNode) {
1623
2089
  this.svg.parentNode.removeChild(this.svg);
1624
2090
  }