@aznabee/freehand-ui 0.1.5 → 0.1.7

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,
@@ -17,6 +276,11 @@ var DEFAULT_OPTIONS = {
17
276
  fontFamily: null,
18
277
  /** Fetch Caveat when the page has not provided it. */
19
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,
20
284
  /**
21
285
  * Overlay stacking order. Omit to mirror the target element's z-index so
22
286
  * doodles stay above the annotated element but below modals and other UI.
@@ -35,8 +299,15 @@ function resolveElement(target) {
35
299
  }
36
300
  function mergeOptions(options = {}) {
37
301
  const merged = { ...DEFAULT_OPTIONS, ...options };
38
- if (merged.note && typeof merged.note === "string") {
39
- 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
+ };
40
311
  }
41
312
  if (merged.arrow) {
42
313
  const config = merged.arrow === true ? {} : merged.arrow;
@@ -46,7 +317,10 @@ function mergeOptions(options = {}) {
46
317
  // explicit `from` is honoured whether or not there is a note.
47
318
  from: config.from ?? (merged.note ? "note" : "top-right"),
48
319
  to: config.to ?? "edge",
49
- 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)
50
324
  };
51
325
  }
52
326
  if (merged.decorations === true) {
@@ -100,20 +374,6 @@ function isValidPosition(position) {
100
374
  "center"
101
375
  ].includes(position);
102
376
  }
103
- function getScrollableAncestors(element) {
104
- const ancestors = [];
105
- let node = element.parentElement;
106
- while (node && node !== document.documentElement) {
107
- const style = getComputedStyle(node);
108
- const overflow = style.overflow + style.overflowY + style.overflowX;
109
- if (/(auto|scroll|overlay)/.test(overflow)) {
110
- ancestors.push(node);
111
- }
112
- node = node.parentElement;
113
- }
114
- ancestors.push(window);
115
- return ancestors;
116
- }
117
377
  function resolveOverlayZIndex(element, override) {
118
378
  if (override != null) return override;
119
379
  const { zIndex, position } = getComputedStyle(element);
@@ -132,13 +392,45 @@ function resolveOverlayZIndex(element, override) {
132
392
  }
133
393
  return null;
134
394
  }
395
+ var ITEM_PLACEMENT_PROPERTIES = [
396
+ "order",
397
+ "flexGrow",
398
+ "flexShrink",
399
+ "flexBasis",
400
+ "alignSelf",
401
+ "justifySelf",
402
+ "gridColumn",
403
+ "gridRow",
404
+ "gridArea"
405
+ ];
406
+ function isInlineDisplay(computed) {
407
+ return computed.display.startsWith("inline");
408
+ }
135
409
  function insertOverlaySvg(svg, element) {
136
410
  const parent = element.parentNode;
137
411
  if (!parent) {
138
412
  document.body.appendChild(svg);
139
- return;
413
+ return null;
414
+ }
415
+ const wrapper = document.createElement("div");
416
+ wrapper.style.position = "relative";
417
+ const computed = getComputedStyle(element);
418
+ wrapper.style.display = isInlineDisplay(computed) ? "inline-block" : "block";
419
+ for (const property of ITEM_PLACEMENT_PROPERTIES) {
420
+ const value = computed[property];
421
+ if (value) wrapper.style[property] = value;
422
+ }
423
+ parent.replaceChild(wrapper, element);
424
+ wrapper.appendChild(element);
425
+ wrapper.appendChild(svg);
426
+ return wrapper;
427
+ }
428
+ function removeOverlaySvg(svg, wrapper, element) {
429
+ if (svg.parentNode) {
430
+ svg.parentNode.removeChild(svg);
140
431
  }
141
- parent.insertBefore(svg, element.nextSibling);
432
+ if (!wrapper || !wrapper.parentNode) return;
433
+ wrapper.parentNode.replaceChild(element, wrapper);
142
434
  }
143
435
  function syncOverlayStacking(svg, element, zIndexOverride) {
144
436
  const zIndex = resolveOverlayZIndex(element, zIndexOverride);
@@ -295,6 +587,7 @@ function annotationAnchor(position, rect, offset = 12) {
295
587
 
296
588
  // src/renderer.js
297
589
  var SVG_NS = "http://www.w3.org/2000/svg";
590
+ var XML_NS = "http://www.w3.org/XML/1998/namespace";
298
591
  var HANDWRITTEN_FONT = '"Caveat", "Kalam", "Patrick Hand", "Bradley Hand", "Segoe Script", "Comic Sans MS", cursive';
299
592
  var NOTE_FONT_SIZE = 19;
300
593
  function clearSvg(svg) {
@@ -540,6 +833,15 @@ function appendPath(svg, d, style) {
540
833
  svg.appendChild(path);
541
834
  return path;
542
835
  }
836
+ function appendGlow(svg, radius, style) {
837
+ const circle = document.createElementNS(SVG_NS, "circle");
838
+ circle.setAttribute("r", fmt(radius));
839
+ circle.setAttribute("fill", style.color);
840
+ circle.setAttribute("opacity", String(style.opacity));
841
+ circle.style.filter = `blur(${fmt(radius * 0.4)}px)`;
842
+ svg.appendChild(circle);
843
+ return circle;
844
+ }
543
845
  function rectPerimeter(width, height, radius) {
544
846
  const r = clamp(radius, 0, Math.min(width, height) / 2);
545
847
  return 2 * (width - 2 * r) + 2 * (height - 2 * r) + 2 * Math.PI * r;
@@ -626,20 +928,21 @@ function cubicTangentAt(p0, c1, c2, p3, t) {
626
928
  y: a * (c1.y - p0.y) + b * (c2.y - c1.y) + c * (p3.y - c2.y)
627
929
  };
628
930
  }
629
- function loopedCurvePoints(start, c1, c2, end, radius, random) {
931
+ var STRADDLE = 0.75;
932
+ function loopedCurvePoints(start, c1, c2, end, radius, random, side = 1) {
630
933
  const at = (t) => cubicAt(start, c1, c2, end, t);
631
- const tangentAt = (t) => normalize(cubicTangentAt(start, c1, c2, end, t));
632
934
  const knot = 0.46 + random() * 0.12;
633
935
  const points = [];
634
936
  const lead = Math.max(2, Math.round(knot * 16));
635
937
  for (let i = 0; i <= lead; i++) points.push(at(knot * i / lead));
636
938
  const pivot = at(knot);
637
- const heading = tangentAt(knot);
939
+ const derivative = cubicTangentAt(start, c1, c2, end, knot);
940
+ const speed = Math.hypot(derivative.x, derivative.y) || 1;
941
+ const heading = normalize(derivative);
638
942
  const normal = { x: -heading.y, y: heading.x };
639
- const side = random() < 0.5 ? 1 : -1;
640
943
  const centre = {
641
- x: pivot.x + normal.x * radius * side,
642
- y: pivot.y + normal.y * radius * side
944
+ x: pivot.x + normal.x * radius * STRADDLE * side,
945
+ y: pivot.y + normal.y * radius * STRADDLE * side
643
946
  };
644
947
  const from = Math.atan2(pivot.y - centre.y, pivot.x - centre.x);
645
948
  const turns = Math.PI * 2 * (0.97 + random() * 0.08);
@@ -647,15 +950,27 @@ function loopedCurvePoints(start, c1, c2, end, radius, random) {
647
950
  for (let i = 1; i <= steps; i++) {
648
951
  const progress = i / steps;
649
952
  const angle = from + turns * progress * side;
953
+ const spread = radius * (STRADDLE + (1 - STRADDLE) * progress);
650
954
  const drift = radius * 0.55 * progress;
651
955
  points.push({
652
- x: centre.x + Math.cos(angle) * radius + heading.x * drift,
653
- y: centre.y + Math.sin(angle) * radius + heading.y * drift
956
+ x: centre.x + Math.cos(angle) * spread + heading.x * drift,
957
+ y: centre.y + Math.sin(angle) * spread + heading.y * drift
654
958
  });
655
959
  }
960
+ const exit = points[points.length - 1];
961
+ const gained = (exit.x - pivot.x) * heading.x + (exit.y - pivot.y) * heading.y;
962
+ const resume = Math.min(knot + Math.max(0, gained) / speed, 0.985);
963
+ const rejoin = at(resume);
964
+ const residual = (exit.x - rejoin.x) * normal.x + (exit.y - rejoin.y) * normal.y;
965
+ const settle = 6;
656
966
  const tail = 14;
657
967
  for (let i = 1; i <= tail; i++) {
658
- points.push(at(knot + (1 - knot) * (i / tail)));
968
+ const point = at(resume + (1 - resume) * (i / tail));
969
+ const fade = Math.max(0, 1 - i / settle);
970
+ points.push({
971
+ x: point.x + normal.x * residual * fade,
972
+ y: point.y + normal.y * residual * fade
973
+ });
659
974
  }
660
975
  return points;
661
976
  }
@@ -729,21 +1044,29 @@ function arrowEndpoints(rect, arrowOptions, random, origin) {
729
1044
  function drawArrowHead(svg, tip, angle, style, random, shaftLength = Infinity) {
730
1045
  const length = Math.min(9 + style.strokeWidth * 1.4, shaftLength * 0.3);
731
1046
  const spread = 0.46 + random() * 0.1;
1047
+ const arms = [];
732
1048
  for (const side of [-1, 1]) {
733
1049
  const armAngle = angle + spread * side;
734
1050
  const tail = {
735
1051
  x: tip.x - length * Math.cos(armAngle),
736
1052
  y: tip.y - length * Math.sin(armAngle)
737
1053
  };
738
- appendPath(
739
- svg,
740
- roughLine(tail.x, tail.y, tip.x, tip.y, style.roughness * 0.5, random, {
741
- steps: 2,
742
- bow: 0.35 * side
743
- }),
744
- { ...style, dashed: false }
1054
+ arms.push(
1055
+ appendPath(
1056
+ svg,
1057
+ roughLine(tail.x, tail.y, tip.x, tip.y, style.roughness * 0.5, random, {
1058
+ steps: 2,
1059
+ bow: 0.35 * side
1060
+ }),
1061
+ { ...style, dashed: false }
1062
+ )
745
1063
  );
746
1064
  }
1065
+ return arms;
1066
+ }
1067
+ function bowOverTheTop(perpendicular) {
1068
+ if (Math.abs(perpendicular.y) < 0.02) return perpendicular.x > 0 ? 1 : -1;
1069
+ return perpendicular.y > 0 ? -1 : 1;
747
1070
  }
748
1071
  function boundsOf(points) {
749
1072
  const xs = points.map((p) => p.x);
@@ -755,7 +1078,7 @@ function boundsOf(points) {
755
1078
  height: Math.max(...ys) - Math.min(...ys)
756
1079
  };
757
1080
  }
758
- function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
1081
+ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null, context = {}) {
759
1082
  const random = createRandom(seed + 17);
760
1083
  const { start, end } = arrowEndpoints(rect, arrowOptions, random, origin);
761
1084
  const dx = end.x - start.x;
@@ -764,6 +1087,7 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
764
1087
  const dashed = arrowOptions.style === "dotted";
765
1088
  let tipAngle = Math.atan2(dy, dx);
766
1089
  let shaft;
1090
+ let ghost = null;
767
1091
  const hull = [start, end];
768
1092
  if (arrowOptions.style === "straight") {
769
1093
  shaft = roughLine(start.x, start.y, end.x, end.y, style.roughness, random, {
@@ -777,7 +1101,12 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
777
1101
  y: rect.y + rect.height / 2 - mid.y
778
1102
  };
779
1103
  const lateral = perpendicular.x * towardCenter.x + perpendicular.y * towardCenter.y;
780
- const sign = Math.abs(lateral) > distance * 0.12 ? lateral > 0 ? -1 : 1 : random() < 0.5 ? -1 : 1;
1104
+ const sign = Math.abs(lateral) > distance * 0.12 ? lateral > 0 ? -1 : 1 : (
1105
+ // Nothing to steer around, so sweep over the top: a hand arcs above
1106
+ // the straight line and drops onto the target, and a coin flip here
1107
+ // used to mirror the whole gesture from one redraw to the next.
1108
+ bowOverTheTop(perpendicular)
1109
+ );
781
1110
  const bow = Math.min(distance * (0.16 + random() * 0.08), 30) * sign;
782
1111
  const c1x = start.x + dx * 0.28 + perpendicular.x * bow;
783
1112
  const c1y = start.y + dy * 0.28 + perpendicular.y * bow;
@@ -792,34 +1121,46 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
792
1121
  { x: c2x, y: c2y },
793
1122
  end,
794
1123
  radius,
795
- random
1124
+ random,
1125
+ -sign
796
1126
  );
797
1127
  shaft = catmullRomPath(points, false);
798
1128
  hull.push(...points);
799
1129
  const last = points[points.length - 1];
800
1130
  const prior = points[points.length - 2] ?? start;
801
1131
  tipAngle = Math.atan2(last.y - prior.y, last.x - prior.x);
802
- appendPath(svg, shaft, { ...style, dashed });
803
- drawArrowHead(svg, end, tipAngle, style, random, distance);
804
- return { start, end, bounds: boundsOf(hull) };
1132
+ } else {
1133
+ shaft = `M ${fmt(start.x)} ${fmt(start.y)} C ${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(end.x)} ${fmt(end.y)}`;
1134
+ if (style.roughness >= 1.1) {
1135
+ const drift = () => (random() - 0.5) * 1.4;
1136
+ 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)}`;
1137
+ }
1138
+ tipAngle = Math.atan2(end.y - c2y, end.x - c2x);
805
1139
  }
806
- shaft = `M ${fmt(start.x)} ${fmt(start.y)} C ${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(end.x)} ${fmt(end.y)}`;
807
- if (style.roughness >= 1.1) {
808
- const drift = () => (random() - 0.5) * 1.4;
809
- 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)}`;
810
- appendPath(svg, ghost, {
1140
+ }
1141
+ const group = document.createElementNS(SVG_NS, "g");
1142
+ svg.appendChild(group);
1143
+ const shaftPaths = [];
1144
+ if (ghost) {
1145
+ shaftPaths.push(
1146
+ appendPath(group, ghost, {
811
1147
  ...style,
812
1148
  opacity: style.opacity * 0.3,
813
1149
  strokeWidth: style.strokeWidth * 0.7,
814
1150
  dashed
815
- });
816
- }
817
- tipAngle = Math.atan2(end.y - c2y, end.x - c2x);
1151
+ })
1152
+ );
818
1153
  }
819
- appendPath(svg, shaft, { ...style, dashed });
820
- drawArrowHead(svg, end, tipAngle, style, random, distance);
1154
+ shaftPaths.push(appendPath(group, shaft, { ...style, dashed }));
1155
+ const head = drawArrowHead(group, end, tipAngle, style, random, distance);
1156
+ animateArrow(
1157
+ group,
1158
+ { shaft: shaftPaths, head, start, end },
1159
+ arrowOptions.animate,
1160
+ context.elapsed
1161
+ );
821
1162
  const bounds = boundsOf(hull);
822
- return { start, end, bounds };
1163
+ return { start, end, bounds, group };
823
1164
  }
824
1165
  function drawUnderline(svg, x, y, width, style, random) {
825
1166
  if (width <= 4) return;
@@ -886,6 +1227,7 @@ function createNoteText(svg, text, style, options = {}) {
886
1227
  const fontSize = style.fontSize ?? NOTE_FONT_SIZE;
887
1228
  const lines = wrapWords(text, options.wordsPerLine ?? DEFAULT_WORDS_PER_LINE);
888
1229
  const lineHeight = fontSize * 1.05;
1230
+ const rtl = options.rtl ?? isRtlText(text);
889
1231
  const group = document.createElementNS(SVG_NS, "g");
890
1232
  const textEl = document.createElementNS(SVG_NS, "text");
891
1233
  textEl.setAttribute("x", "0");
@@ -898,6 +1240,13 @@ function createNoteText(svg, text, style, options = {}) {
898
1240
  textEl.setAttribute("letter-spacing", "0.4");
899
1241
  textEl.setAttribute("text-anchor", "start");
900
1242
  textEl.setAttribute("dominant-baseline", "auto");
1243
+ if (rtl) {
1244
+ textEl.setAttribute("direction", "rtl");
1245
+ textEl.setAttribute("unicode-bidi", "embed");
1246
+ }
1247
+ if (options.locale) {
1248
+ textEl.setAttributeNS(XML_NS, "xml:lang", options.locale);
1249
+ }
901
1250
  const tspans = lines.map((line, index) => {
902
1251
  const tspan = document.createElementNS(SVG_NS, "tspan");
903
1252
  tspan.textContent = line;
@@ -926,15 +1275,17 @@ function createNoteText(svg, text, style, options = {}) {
926
1275
  height,
927
1276
  ascent,
928
1277
  descent,
929
- fontSize
1278
+ fontSize,
1279
+ rtl
930
1280
  };
931
1281
  }
932
1282
  function placeNoteText(note, box, style, seed, options = {}) {
933
1283
  const random = createRandom(seed + 55);
934
1284
  const tilt = options.tilt ?? (random() - 0.5) * 7;
935
- note.textEl.setAttribute("x", fmt(box.x));
1285
+ const anchorX = note.rtl ? box.x + box.width : box.x;
1286
+ note.textEl.setAttribute("x", fmt(anchorX));
936
1287
  note.textEl.setAttribute("y", fmt(box.y + note.ascent));
937
- for (const tspan of note.tspans) tspan.setAttribute("x", fmt(box.x));
1288
+ for (const tspan of note.tspans) tspan.setAttribute("x", fmt(anchorX));
938
1289
  const cx = box.x + box.width / 2;
939
1290
  const cy = box.y + box.height / 2;
940
1291
  note.group.setAttribute(
@@ -943,9 +1294,10 @@ function placeNoteText(note, box, style, seed, options = {}) {
943
1294
  );
944
1295
  if (options.underline !== false) {
945
1296
  const lastWidth = note.lineWidths[note.lineWidths.length - 1] ?? box.width;
1297
+ const underlineX = note.rtl ? box.x + box.width - lastWidth + 1 : box.x + 1;
946
1298
  drawUnderline(
947
1299
  note.group,
948
- box.x + 1,
1300
+ underlineX,
949
1301
  box.y + box.height + 2,
950
1302
  lastWidth - 2,
951
1303
  {
@@ -966,7 +1318,7 @@ function createOverlaySvg(left, top, width, height) {
966
1318
  svg.setAttribute("width", String(width));
967
1319
  svg.setAttribute("height", String(height));
968
1320
  svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
969
- svg.style.position = "fixed";
1321
+ svg.style.position = "absolute";
970
1322
  svg.style.left = `${left}px`;
971
1323
  svg.style.top = `${top}px`;
972
1324
  svg.style.width = `${width}px`;
@@ -1019,12 +1371,17 @@ function nudgeIntoBand(box, guard, band) {
1019
1371
  return { ...box, x: box.x + dx };
1020
1372
  }
1021
1373
  function renderAnnotation(svg, rect, noteOptions, style, seed = 1, layout = {}) {
1022
- if (!noteOptions?.text) return null;
1374
+ const { text, locale } = resolveLocalizedText(
1375
+ noteOptions?.text,
1376
+ noteOptions?.locale
1377
+ );
1378
+ if (!text) return null;
1023
1379
  const band = layout.band ?? null;
1024
1380
  const position = isValidPosition(noteOptions.position) ? noteOptions.position : "top-right";
1025
1381
  const random = createRandom(seed + 9);
1026
- const note = createNoteText(svg, noteOptions.text, style, {
1027
- wordsPerLine: noteOptions.wordsPerLine
1382
+ const note = createNoteText(svg, text, style, {
1383
+ wordsPerLine: noteOptions.wordsPerLine,
1384
+ locale
1028
1385
  });
1029
1386
  const tilt = (random() - 0.45) * 7;
1030
1387
  const tiltPad = Math.abs(Math.sin(tilt * Math.PI / 180)) * note.width * 0.5;
@@ -1066,6 +1423,25 @@ function drawDot(parent, x, y, style, scale = 1.7) {
1066
1423
  strokeWidth: style.strokeWidth * scale
1067
1424
  });
1068
1425
  }
1426
+ function drawFleck(parent, x, y, radius, style, random) {
1427
+ const count = 6;
1428
+ const points = [];
1429
+ for (let i = 0; i < count; i++) {
1430
+ const angle = Math.PI * 2 * i / count + (random() - 0.5) * 0.5;
1431
+ const r = radius * (0.78 + random() * 0.34);
1432
+ points.push({
1433
+ x: x + Math.cos(angle) * r,
1434
+ y: y + Math.sin(angle) * r
1435
+ });
1436
+ }
1437
+ const blob = appendPath(parent, catmullRomPath(points, true), {
1438
+ ...style,
1439
+ fill: style.color,
1440
+ strokeWidth: 0
1441
+ });
1442
+ blob.style.filter = `blur(${(radius * 0.35).toFixed(2)}px)`;
1443
+ return blob;
1444
+ }
1069
1445
  function drawHeart(parent, size, style, random) {
1070
1446
  const s = size;
1071
1447
  const j = () => (random() - 0.5) * s * 0.12;
@@ -1089,26 +1465,61 @@ function twinkle(parent, radiusX, radiusY, pinch, style) {
1089
1465
  `Q ${(-c).toFixed(2)} ${(-c).toFixed(2)} 0 ${(-ry).toFixed(2)}`,
1090
1466
  "Z"
1091
1467
  ].join(" ");
1092
- appendPath(parent, path, style);
1468
+ return appendPath(parent, path, style);
1093
1469
  }
1094
1470
  function drawSparkle(parent, size, style, random) {
1095
1471
  twinkle(
1096
1472
  parent,
1097
- size * (0.34 + random() * 0.1),
1473
+ size * (0.62 + random() * 0.12),
1098
1474
  size,
1099
- size * (0.06 + random() * 0.06),
1475
+ size * (0.14 + random() * 0.08),
1100
1476
  style
1101
1477
  );
1102
1478
  }
1103
1479
  function drawStar(parent, size, style, random) {
1104
1480
  twinkle(
1105
1481
  parent,
1106
- size * (0.66 + random() * 0.12),
1107
- size,
1108
- size * (0.2 + random() * 0.08),
1482
+ size * (0.62 + random() * 0.12),
1483
+ size * (0.8 + random() * 0.12),
1484
+ size * (0.14 + random() * 0.08),
1109
1485
  style
1110
1486
  );
1111
1487
  }
1488
+ function drawGlimmer(parent, size, style, random) {
1489
+ appendGlow(parent, size * (1.2 + random() * 0.2), {
1490
+ color: style.color,
1491
+ opacity: style.opacity * 0.22
1492
+ });
1493
+ const armX = size * (0.5 + random() * 0.08);
1494
+ const armY = size * (0.45 + random() * 0.08);
1495
+ const bluntness = size * 0.19;
1496
+ const main = twinkle(parent, armX, armY, size * (0.08 + random() * 0.03), {
1497
+ ...style,
1498
+ fill: style.color,
1499
+ strokeWidth: bluntness
1500
+ });
1501
+ main.setAttribute("stroke-linejoin", "round");
1502
+ main.setAttribute("stroke-linecap", "round");
1503
+ main.style.filter = `blur(${(size * 0.05).toFixed(2)}px)`;
1504
+ const gap = bluntness / 2 + size * 0.2;
1505
+ const corners = [
1506
+ { x: 0, y: -(armY + gap) },
1507
+ { x: armX + gap, y: 0 },
1508
+ { x: 0, y: armY + gap },
1509
+ { x: -(armX + gap), y: 0 }
1510
+ ];
1511
+ for (const { x, y } of corners) {
1512
+ const jitter = 0.95 + random() * 0.1;
1513
+ drawFleck(
1514
+ parent,
1515
+ x * jitter,
1516
+ y * jitter,
1517
+ size * (0.1 + random() * 0.03),
1518
+ style,
1519
+ random
1520
+ );
1521
+ }
1522
+ }
1112
1523
  function drawTwinkle(parent, size, style, random) {
1113
1524
  const count = 5;
1114
1525
  const first = -Math.PI / 2 + (random() - 0.5) * 0.5;
@@ -1216,46 +1627,15 @@ function drawEmphasis(parent, size, style, random, opts = {}) {
1216
1627
  );
1217
1628
  }
1218
1629
  }
1219
- function drawDots(parent, size, style, random) {
1220
- let x = -size * 0.7;
1221
- for (let i = 0; i < 3; i++) {
1222
- drawDot(parent, x, (random() - 0.5) * size * 0.16, style);
1223
- x += size * (0.6 + random() * 0.25);
1224
- }
1225
- }
1226
- function drawStroke(parent, size, style, random) {
1227
- appendPath(
1228
- parent,
1229
- roughLine(-size, 0, size, 0, 1.2, random, {
1230
- bow: (random() - 0.5) * size * 0.35,
1231
- steps: 4
1232
- }),
1233
- style
1234
- );
1235
- }
1236
- function drawSteam(parent, size, style, random) {
1237
- const lean = random() < 0.5 ? -1 : 1;
1238
- for (let i = 0; i < 2; i++) {
1239
- const x = (i - 0.5) * size * 0.72;
1240
- const base = size * 0.62 - i * size * 0.14;
1241
- appendPath(
1242
- parent,
1243
- `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)}`,
1244
- style
1245
- );
1246
- }
1247
- }
1248
1630
  var DECORATION_DRAWERS = {
1249
1631
  heart: drawHeart,
1250
1632
  sparkle: drawSparkle,
1251
1633
  star: drawStar,
1252
1634
  twinkle: drawTwinkle,
1635
+ glimmer: drawGlimmer,
1253
1636
  smiley: drawSmiley,
1254
1637
  emphasis: drawEmphasis,
1255
- arcs: drawArcs,
1256
- dots: drawDots,
1257
- stroke: drawStroke,
1258
- steam: drawSteam
1638
+ arcs: drawArcs
1259
1639
  };
1260
1640
  var TEXT_TYPES = ["heart", "sparkle"];
1261
1641
  var CORNER_ACCENTS = ["arcs", "emphasis"];
@@ -1266,12 +1646,10 @@ var TYPE_TRAITS = {
1266
1646
  sparkle: { size: [6, 9], tilt: 20, standoff: 22 },
1267
1647
  star: { size: [8, 11], tilt: 25, standoff: 26 },
1268
1648
  twinkle: { size: [5, 7.5], tilt: 30, standoff: 26, brightness: 1.4 },
1649
+ glimmer: { size: [7, 10], tilt: 20, standoff: 28, brightness: 1.3 },
1269
1650
  smiley: { size: [8, 11], tilt: 10, standoff: 22 },
1270
1651
  emphasis: { size: [12, 17], tilt: 10, standoff: 13, outward: true },
1271
- arcs: { size: [11, 16], tilt: 12, standoff: 12, outward: true },
1272
- dots: { size: [6, 9], tilt: 8, standoff: 20 },
1273
- stroke: { size: [8, 12], tilt: 40, standoff: 20 },
1274
- steam: { size: [8, 11], tilt: 10, standoff: 20 }
1652
+ arcs: { size: [11, 16], tilt: 12, standoff: 12, outward: true }
1275
1653
  };
1276
1654
  var DEFAULT_TYPES = [
1277
1655
  "twinkle",
@@ -1474,6 +1852,9 @@ function ensureHandwrittenFont() {
1474
1852
 
1475
1853
  // src/doodle.js
1476
1854
  var instances = /* @__PURE__ */ new WeakMap();
1855
+ function now() {
1856
+ return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1857
+ }
1477
1858
  function localBand(overlayRect, inset = 8) {
1478
1859
  const width = window.innerWidth || document.documentElement.clientWidth || 0;
1479
1860
  return {
@@ -1490,19 +1871,21 @@ var DoodleOverlay = class {
1490
1871
  this.element = element;
1491
1872
  this.options = options;
1492
1873
  this.seed = Math.floor(Math.random() * 1e9);
1874
+ this.startedAt = now();
1493
1875
  this.svg = null;
1876
+ this.wrapper = null;
1494
1877
  this.resizeObserver = null;
1495
1878
  this.mutationObserver = null;
1496
1879
  this.pendingFrame = null;
1497
- this.scrollTargets = [];
1498
- this.onScroll = this.scheduleUpdate.bind(this);
1880
+ this.listeners = [];
1499
1881
  this.onResize = this.scheduleUpdate.bind(this);
1882
+ this.onLanguageChange = this.scheduleUpdate.bind(this);
1500
1883
  this.childElements = [];
1501
1884
  this.mount();
1502
1885
  }
1503
1886
  mount() {
1504
1887
  this.svg = createOverlaySvg(0, 0, 0, 0);
1505
- insertOverlaySvg(this.svg, this.element);
1888
+ this.wrapper = insertOverlaySvg(this.svg, this.element);
1506
1889
  syncOverlayStacking(this.svg, this.element, this.options.zIndex);
1507
1890
  this.resizeObserver = new ResizeObserver(this.onResize);
1508
1891
  this.resizeObserver.observe(this.element);
@@ -1515,12 +1898,18 @@ var DoodleOverlay = class {
1515
1898
  subtree: true,
1516
1899
  attributes: true
1517
1900
  });
1518
- for (const target of getScrollableAncestors(this.element)) {
1519
- target.addEventListener("scroll", this.onScroll, { passive: true });
1520
- this.scrollTargets.push({ target, type: "scroll" });
1521
- }
1522
1901
  window.addEventListener("resize", this.onResize, { passive: true });
1523
- this.scrollTargets.push({ target: window, type: "resize" });
1902
+ this.listeners.push({
1903
+ target: window,
1904
+ type: "resize",
1905
+ handler: this.onResize
1906
+ });
1907
+ window.addEventListener("languagechange", this.onLanguageChange);
1908
+ this.listeners.push({
1909
+ target: window,
1910
+ type: "languagechange",
1911
+ handler: this.onLanguageChange
1912
+ });
1524
1913
  if (this.options.note && this.options.autoLoadFont && !this.options.fontFamily) {
1525
1914
  ensureHandwrittenFont();
1526
1915
  }
@@ -1556,17 +1945,19 @@ var DoodleOverlay = class {
1556
1945
  const { padding, radius, color, strokeWidth, roughness, opacity } = this.options;
1557
1946
  const targets = this.getTargets();
1558
1947
  const margin = this.overlayMargin();
1948
+ const elapsed = now() - this.startedAt;
1559
1949
  const absoluteRects = targets.map(
1560
1950
  (target) => getElementBounds(target, padding, radius)
1561
1951
  );
1562
1952
  const overlayRect = unionRects(absoluteRects, margin);
1563
1953
  clearSvg(this.svg);
1564
1954
  syncOverlayStacking(this.svg, this.element, this.options.zIndex);
1955
+ const origin = this.wrapper ? this.wrapper.getBoundingClientRect() : { left: 0, top: 0 };
1565
1956
  this.svg.setAttribute("width", String(overlayRect.width));
1566
1957
  this.svg.setAttribute("height", String(overlayRect.height));
1567
1958
  this.svg.setAttribute("viewBox", `0 0 ${overlayRect.width} ${overlayRect.height}`);
1568
- this.svg.style.left = `${overlayRect.x}px`;
1569
- this.svg.style.top = `${overlayRect.y}px`;
1959
+ this.svg.style.left = `${overlayRect.x - origin.left}px`;
1960
+ this.svg.style.top = `${overlayRect.y - origin.top}px`;
1570
1961
  this.svg.style.width = `${overlayRect.width}px`;
1571
1962
  this.svg.style.height = `${overlayRect.height}px`;
1572
1963
  const strokeStyle = { color, strokeWidth, roughness, opacity };
@@ -1608,7 +1999,8 @@ var DoodleOverlay = class {
1608
1999
  this.options.arrow,
1609
2000
  strokeStyle,
1610
2001
  seed,
1611
- note ? note.box : null
2002
+ note ? note.box : null,
2003
+ { elapsed }
1612
2004
  ) : null;
1613
2005
  if (this.options.decorations) {
1614
2006
  renderDecorations(
@@ -1635,7 +2027,7 @@ var DoodleOverlay = class {
1635
2027
  overlayMargin() {
1636
2028
  const { note, arrow, decorations } = this.options;
1637
2029
  if (!note && !arrow && !decorations) return 48;
1638
- const text = typeof note?.text === "string" ? note.text : "";
2030
+ const { text } = resolveLocalizedText(note?.text, note?.locale);
1639
2031
  return Math.max(96, Math.min(240, 80 + text.length * 8));
1640
2032
  }
1641
2033
  destroy() {
@@ -1655,14 +2047,15 @@ var DoodleOverlay = class {
1655
2047
  document.fonts?.removeEventListener?.("loadingdone", this.onFontsLoaded);
1656
2048
  this.onFontsLoaded = null;
1657
2049
  }
1658
- for (const { target, type } of this.scrollTargets) {
1659
- target.removeEventListener(type, type === "scroll" ? this.onScroll : this.onResize);
2050
+ for (const { target, type, handler } of this.listeners) {
2051
+ target.removeEventListener(type, handler);
1660
2052
  }
1661
- this.scrollTargets = [];
1662
- if (this.svg?.parentNode) {
1663
- this.svg.parentNode.removeChild(this.svg);
2053
+ this.listeners = [];
2054
+ if (this.svg) {
2055
+ removeOverlaySvg(this.svg, this.wrapper, this.element);
1664
2056
  }
1665
2057
  this.svg = null;
2058
+ this.wrapper = null;
1666
2059
  instances.delete(this.element);
1667
2060
  }
1668
2061
  };