@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.
package/dist/react.js CHANGED
@@ -11,6 +11,265 @@ import {
11
11
  useRef
12
12
  } from "react";
13
13
 
14
+ // src/animation.js
15
+ var STYLE_MARKER = "data-freehand-animation";
16
+ var CSS = `
17
+ @keyframes freehand-arrow-dash {
18
+ from { stroke-dashoffset: var(--freehand-dash, 0px); }
19
+ to { stroke-dashoffset: 0; }
20
+ }
21
+ @keyframes freehand-arrow-drift {
22
+ 0%, 100% { transform: translate(0px, 0px); }
23
+ 50% { transform: translate(var(--freehand-drift-x, 0px), var(--freehand-drift-y, 0px)); }
24
+ }
25
+ @keyframes freehand-arrow-settle {
26
+ from { transform: translate(0px, 0px); }
27
+ to { transform: translate(var(--freehand-drift-x, 0px), var(--freehand-drift-y, 0px)); }
28
+ }
29
+ @media (prefers-reduced-motion: reduce) {
30
+ .freehand-arrow, .freehand-arrow path { animation: none !important; }
31
+ .freehand-arrow path { stroke-dashoffset: 0 !important; }
32
+ }
33
+ `;
34
+ var ARROW_ANIMATION_DEFAULTS = {
35
+ /** Reveal the stroke with a dash sweep. */
36
+ draw: true,
37
+ /** Lean the arrow toward its tip. */
38
+ drift: true,
39
+ /** Multiplier over every duration - 2 plays twice as fast. */
40
+ speed: 1,
41
+ /** How long the shaft takes to draw, ms, before `speed`. */
42
+ duration: 900,
43
+ /** Wait before the first stroke appears, ms. */
44
+ delay: 150,
45
+ /** How far the arrow leans, px. */
46
+ distance: 5,
47
+ /** One full lean-and-return, ms. */
48
+ driftDuration: 2200,
49
+ /** Keep leaning. `false` settles once and stays put. */
50
+ repeat: true
51
+ };
52
+ function positive(value, fallback) {
53
+ const n = Number(value);
54
+ return Number.isFinite(n) && n > 0 ? n : fallback;
55
+ }
56
+ function atLeastZero(value, fallback) {
57
+ const n = Number(value);
58
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
59
+ }
60
+ function normalizeArrowAnimation(config) {
61
+ if (config === false || config === null) return false;
62
+ const settings = config === true || config === void 0 || typeof config !== "object" ? {} : config;
63
+ const animation = {
64
+ draw: settings.draw !== false,
65
+ drift: settings.drift !== false,
66
+ speed: positive(settings.speed, ARROW_ANIMATION_DEFAULTS.speed),
67
+ duration: positive(settings.duration, ARROW_ANIMATION_DEFAULTS.duration),
68
+ delay: atLeastZero(settings.delay, ARROW_ANIMATION_DEFAULTS.delay),
69
+ distance: atLeastZero(settings.distance, ARROW_ANIMATION_DEFAULTS.distance),
70
+ driftDuration: positive(
71
+ settings.driftDuration,
72
+ ARROW_ANIMATION_DEFAULTS.driftDuration
73
+ ),
74
+ repeat: settings.repeat !== false
75
+ };
76
+ if (!animation.draw && (!animation.drift || !animation.distance)) return false;
77
+ return animation;
78
+ }
79
+ var FLOW_PERIOD = 260;
80
+ function arrowTiming(animation) {
81
+ const duration = animation.duration / animation.speed;
82
+ const delay = animation.delay / animation.speed;
83
+ const drawn = animation.draw ? duration : 0;
84
+ return {
85
+ shaft: { duration, delay },
86
+ head: { duration: duration * 0.22, delay: delay + drawn * 0.82 },
87
+ flow: { duration: FLOW_PERIOD / animation.speed, delay },
88
+ drift: {
89
+ duration: animation.driftDuration / animation.speed,
90
+ delay: delay + drawn * 1.04
91
+ }
92
+ };
93
+ }
94
+ var injected = false;
95
+ function ensureArrowAnimationStyles() {
96
+ if (injected) return;
97
+ if (typeof document === "undefined" || !document.head) return;
98
+ injected = true;
99
+ if (document.querySelector(`style[${STYLE_MARKER}]`)) return;
100
+ const style = document.createElement("style");
101
+ style.setAttribute(STYLE_MARKER, "");
102
+ style.textContent = CSS;
103
+ document.head.appendChild(style);
104
+ }
105
+ function time(ms) {
106
+ return `${Math.round(ms)}ms`;
107
+ }
108
+ function setAnimation(element, spec) {
109
+ const parts = [
110
+ spec.name,
111
+ time(spec.duration),
112
+ spec.easing,
113
+ time(spec.delay),
114
+ spec.iterations ?? "1"
115
+ ];
116
+ if (spec.fill) parts.push(spec.fill);
117
+ element.style.animation = parts.join(" ");
118
+ }
119
+ function pathLength(path) {
120
+ try {
121
+ const length = path.getTotalLength();
122
+ return Number.isFinite(length) && length > 0 ? length : 0;
123
+ } catch {
124
+ return 0;
125
+ }
126
+ }
127
+ function dashPeriod(path) {
128
+ const pattern = (path.getAttribute("stroke-dasharray") || "").split(/[\s,]+/).map(Number).filter((n) => Number.isFinite(n) && n > 0);
129
+ if (!pattern.length) return 0;
130
+ const sum = pattern.reduce((total, n) => total + n, 0);
131
+ return pattern.length % 2 === 0 ? sum : sum * 2;
132
+ }
133
+ function applyDash(path, timing, elapsed, flow) {
134
+ const length = pathLength(path);
135
+ if (!length) return;
136
+ const period = dashPeriod(path);
137
+ if (period) {
138
+ path.style.setProperty("--freehand-dash", `${period.toFixed(2)}px`);
139
+ setAnimation(path, {
140
+ name: "freehand-arrow-dash",
141
+ duration: flow.duration,
142
+ easing: "linear",
143
+ delay: flow.delay - elapsed,
144
+ iterations: "infinite"
145
+ });
146
+ return;
147
+ }
148
+ path.style.setProperty("--freehand-dash", `${length.toFixed(2)}px`);
149
+ path.style.strokeDasharray = `${length.toFixed(2)}px`;
150
+ setAnimation(path, {
151
+ name: "freehand-arrow-dash",
152
+ duration: timing.duration,
153
+ easing: "ease-out",
154
+ delay: timing.delay - elapsed,
155
+ fill: "both"
156
+ });
157
+ }
158
+ function applyDrift(group, parts, timing, elapsed, animation) {
159
+ const dx = parts.end.x - parts.start.x;
160
+ const dy = parts.end.y - parts.start.y;
161
+ const length = Math.hypot(dx, dy);
162
+ if (!length) return;
163
+ const scale = animation.distance / length;
164
+ group.style.setProperty("--freehand-drift-x", `${(dx * scale).toFixed(2)}px`);
165
+ group.style.setProperty("--freehand-drift-y", `${(dy * scale).toFixed(2)}px`);
166
+ setAnimation(
167
+ group,
168
+ animation.repeat ? {
169
+ name: "freehand-arrow-drift",
170
+ duration: timing.duration,
171
+ easing: "ease-in-out",
172
+ delay: timing.delay - elapsed,
173
+ iterations: "infinite"
174
+ } : {
175
+ // A single lean is a settle, not a round trip, so it takes half a cycle
176
+ name: "freehand-arrow-settle",
177
+ duration: timing.duration * 0.5,
178
+ easing: "ease-out",
179
+ delay: timing.delay - elapsed,
180
+ fill: "both"
181
+ }
182
+ );
183
+ }
184
+ function animateArrow(group, parts, animation, elapsed = 0) {
185
+ if (!animation || !group) return;
186
+ ensureArrowAnimationStyles();
187
+ group.setAttribute("class", "freehand-arrow");
188
+ const timing = arrowTiming(animation);
189
+ const age = Number.isFinite(elapsed) ? Math.max(0, elapsed) : 0;
190
+ if (animation.draw) {
191
+ for (const path of parts.shaft) {
192
+ applyDash(path, timing.shaft, age, timing.flow);
193
+ }
194
+ for (const path of parts.head) applyDash(path, timing.head, age, timing.flow);
195
+ }
196
+ if (animation.drift && animation.distance > 0) {
197
+ applyDrift(group, parts, timing.drift, age, animation);
198
+ }
199
+ }
200
+
201
+ // src/locale.js
202
+ var DEFAULT_KEY = "default";
203
+ var LANGUAGE_TAG = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i;
204
+ var RTL_STRONG = /[֑-߿יִ-﷿ﹰ-ﻼ]/;
205
+ var LTR_STRONG = /[A-Za-zÀ-֏ऀ-῿Ⰰ-퟿豈-ﬗA-Za-z]/;
206
+ function normalizeTag(tag) {
207
+ if (typeof tag !== "string") return "";
208
+ return tag.trim().replace(/_/g, "-").toLowerCase();
209
+ }
210
+ function isLocaleMap(value) {
211
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
212
+ const entries = Object.entries(value);
213
+ if (!entries.length) return false;
214
+ return entries.every(([key, text]) => {
215
+ if (typeof text !== "string") return false;
216
+ const tag = normalizeTag(key);
217
+ return tag === DEFAULT_KEY || LANGUAGE_TAG.test(tag);
218
+ });
219
+ }
220
+ function preferredLocales(override) {
221
+ const tags = [];
222
+ const add = (value) => {
223
+ const tag = normalizeTag(value);
224
+ if (tag && !tags.includes(tag)) tags.push(tag);
225
+ };
226
+ if (Array.isArray(override)) override.forEach(add);
227
+ else add(override);
228
+ const nav = typeof navigator === "undefined" ? null : navigator;
229
+ if (nav) {
230
+ if (Array.isArray(nav.languages)) nav.languages.forEach(add);
231
+ add(nav.language);
232
+ }
233
+ if (typeof document !== "undefined") add(document.documentElement?.lang);
234
+ return tags;
235
+ }
236
+ function matchLocale(tags, preferred) {
237
+ const available = tags.map(normalizeTag);
238
+ for (const want of preferred) {
239
+ let tag = want;
240
+ while (tag) {
241
+ const exact = available.indexOf(tag);
242
+ if (exact !== -1) return exact;
243
+ const cut = tag.lastIndexOf("-");
244
+ tag = cut > 0 ? tag.slice(0, cut) : "";
245
+ }
246
+ const base = want.split("-")[0];
247
+ const sibling = available.findIndex((key) => key.split("-")[0] === base);
248
+ if (sibling !== -1) return sibling;
249
+ }
250
+ return -1;
251
+ }
252
+ function resolveLocalizedText(text, locale = null) {
253
+ if (typeof text === "string") return { text, locale: null };
254
+ if (!isLocaleMap(text)) return { text: "", locale: null };
255
+ const keys = Object.keys(text);
256
+ const tags = keys.filter((key) => normalizeTag(key) !== DEFAULT_KEY);
257
+ const index = matchLocale(tags, preferredLocales(locale));
258
+ if (index !== -1) {
259
+ return { text: text[tags[index]], locale: normalizeTag(tags[index]) };
260
+ }
261
+ const fallback = keys.find((key) => normalizeTag(key) === DEFAULT_KEY);
262
+ if (fallback) return { text: text[fallback], locale: null };
263
+ return { text: text[keys[0]], locale: normalizeTag(keys[0]) };
264
+ }
265
+ function isRtlText(text) {
266
+ if (typeof text !== "string") return false;
267
+ const rtl = text.search(RTL_STRONG);
268
+ if (rtl === -1) return false;
269
+ const ltr = text.search(LTR_STRONG);
270
+ return ltr === -1 || rtl < ltr;
271
+ }
272
+
14
273
  // src/utils.js
15
274
  var DEFAULT_OPTIONS = {
16
275
  border: true,
@@ -30,6 +289,11 @@ var DEFAULT_OPTIONS = {
30
289
  fontFamily: null,
31
290
  /** Fetch Caveat when the page has not provided it. */
32
291
  autoLoadFont: true,
292
+ /**
293
+ * Language for a note written in more than one. Omit to follow the visitor's
294
+ * own languages, which is what their operating system is set to.
295
+ */
296
+ locale: null,
33
297
  /**
34
298
  * Overlay stacking order. Omit to mirror the target element's z-index so
35
299
  * doodles stay above the annotated element but below modals and other UI.
@@ -48,8 +312,15 @@ function resolveElement(target) {
48
312
  }
49
313
  function mergeOptions(options = {}) {
50
314
  const merged = { ...DEFAULT_OPTIONS, ...options };
51
- if (merged.note && typeof merged.note === "string") {
52
- merged.note = { text: merged.note, position: "top-right" };
315
+ if (merged.note) {
316
+ const config = typeof merged.note === "string" || isLocaleMap(merged.note) ? { text: merged.note } : merged.note;
317
+ merged.note = {
318
+ ...config,
319
+ position: config.position ?? "top-right",
320
+ // The note carries the chosen language, so `renderAnnotation` can resolve
321
+ // a multilingual note on its own.
322
+ locale: config.locale ?? merged.locale ?? null
323
+ };
53
324
  }
54
325
  if (merged.arrow) {
55
326
  const config = merged.arrow === true ? {} : merged.arrow;
@@ -59,7 +330,10 @@ function mergeOptions(options = {}) {
59
330
  // explicit `from` is honoured whether or not there is a note.
60
331
  from: config.from ?? (merged.note ? "note" : "top-right"),
61
332
  to: config.to ?? "edge",
62
- style: config.style ?? "curved"
333
+ style: config.style ?? "curved",
334
+ // Arrows draw themselves on and lean toward their target by default;
335
+ // `animate: false` opts out, an object tunes speed and gestures
336
+ animate: normalizeArrowAnimation(config.animate)
63
337
  };
64
338
  }
65
339
  if (merged.decorations === true) {
@@ -113,20 +387,6 @@ function isValidPosition(position) {
113
387
  "center"
114
388
  ].includes(position);
115
389
  }
116
- function getScrollableAncestors(element) {
117
- const ancestors = [];
118
- let node = element.parentElement;
119
- while (node && node !== document.documentElement) {
120
- const style = getComputedStyle(node);
121
- const overflow = style.overflow + style.overflowY + style.overflowX;
122
- if (/(auto|scroll|overlay)/.test(overflow)) {
123
- ancestors.push(node);
124
- }
125
- node = node.parentElement;
126
- }
127
- ancestors.push(window);
128
- return ancestors;
129
- }
130
390
  function resolveOverlayZIndex(element, override) {
131
391
  if (override != null) return override;
132
392
  const { zIndex, position } = getComputedStyle(element);
@@ -145,13 +405,45 @@ function resolveOverlayZIndex(element, override) {
145
405
  }
146
406
  return null;
147
407
  }
408
+ var ITEM_PLACEMENT_PROPERTIES = [
409
+ "order",
410
+ "flexGrow",
411
+ "flexShrink",
412
+ "flexBasis",
413
+ "alignSelf",
414
+ "justifySelf",
415
+ "gridColumn",
416
+ "gridRow",
417
+ "gridArea"
418
+ ];
419
+ function isInlineDisplay(computed) {
420
+ return computed.display.startsWith("inline");
421
+ }
148
422
  function insertOverlaySvg(svg, element) {
149
423
  const parent = element.parentNode;
150
424
  if (!parent) {
151
425
  document.body.appendChild(svg);
152
- return;
426
+ return null;
427
+ }
428
+ const wrapper = document.createElement("div");
429
+ wrapper.style.position = "relative";
430
+ const computed = getComputedStyle(element);
431
+ wrapper.style.display = isInlineDisplay(computed) ? "inline-block" : "block";
432
+ for (const property of ITEM_PLACEMENT_PROPERTIES) {
433
+ const value = computed[property];
434
+ if (value) wrapper.style[property] = value;
435
+ }
436
+ parent.replaceChild(wrapper, element);
437
+ wrapper.appendChild(element);
438
+ wrapper.appendChild(svg);
439
+ return wrapper;
440
+ }
441
+ function removeOverlaySvg(svg, wrapper, element) {
442
+ if (svg.parentNode) {
443
+ svg.parentNode.removeChild(svg);
153
444
  }
154
- parent.insertBefore(svg, element.nextSibling);
445
+ if (!wrapper || !wrapper.parentNode) return;
446
+ wrapper.parentNode.replaceChild(element, wrapper);
155
447
  }
156
448
  function syncOverlayStacking(svg, element, zIndexOverride) {
157
449
  const zIndex = resolveOverlayZIndex(element, zIndexOverride);
@@ -308,6 +600,7 @@ function annotationAnchor(position, rect, offset = 12) {
308
600
 
309
601
  // src/renderer.js
310
602
  var SVG_NS = "http://www.w3.org/2000/svg";
603
+ var XML_NS = "http://www.w3.org/XML/1998/namespace";
311
604
  var HANDWRITTEN_FONT = '"Caveat", "Kalam", "Patrick Hand", "Bradley Hand", "Segoe Script", "Comic Sans MS", cursive';
312
605
  var NOTE_FONT_SIZE = 19;
313
606
  function clearSvg(svg) {
@@ -553,6 +846,15 @@ function appendPath(svg, d, style) {
553
846
  svg.appendChild(path);
554
847
  return path;
555
848
  }
849
+ function appendGlow(svg, radius, style) {
850
+ const circle = document.createElementNS(SVG_NS, "circle");
851
+ circle.setAttribute("r", fmt(radius));
852
+ circle.setAttribute("fill", style.color);
853
+ circle.setAttribute("opacity", String(style.opacity));
854
+ circle.style.filter = `blur(${fmt(radius * 0.4)}px)`;
855
+ svg.appendChild(circle);
856
+ return circle;
857
+ }
556
858
  function rectPerimeter(width, height, radius) {
557
859
  const r = clamp(radius, 0, Math.min(width, height) / 2);
558
860
  return 2 * (width - 2 * r) + 2 * (height - 2 * r) + 2 * Math.PI * r;
@@ -639,20 +941,21 @@ function cubicTangentAt(p0, c1, c2, p3, t) {
639
941
  y: a * (c1.y - p0.y) + b * (c2.y - c1.y) + c * (p3.y - c2.y)
640
942
  };
641
943
  }
642
- function loopedCurvePoints(start, c1, c2, end, radius, random) {
944
+ var STRADDLE = 0.75;
945
+ function loopedCurvePoints(start, c1, c2, end, radius, random, side = 1) {
643
946
  const at = (t) => cubicAt(start, c1, c2, end, t);
644
- const tangentAt = (t) => normalize(cubicTangentAt(start, c1, c2, end, t));
645
947
  const knot = 0.46 + random() * 0.12;
646
948
  const points = [];
647
949
  const lead = Math.max(2, Math.round(knot * 16));
648
950
  for (let i = 0; i <= lead; i++) points.push(at(knot * i / lead));
649
951
  const pivot = at(knot);
650
- const heading = tangentAt(knot);
952
+ const derivative = cubicTangentAt(start, c1, c2, end, knot);
953
+ const speed = Math.hypot(derivative.x, derivative.y) || 1;
954
+ const heading = normalize(derivative);
651
955
  const normal = { x: -heading.y, y: heading.x };
652
- const side = random() < 0.5 ? 1 : -1;
653
956
  const centre = {
654
- x: pivot.x + normal.x * radius * side,
655
- y: pivot.y + normal.y * radius * side
957
+ x: pivot.x + normal.x * radius * STRADDLE * side,
958
+ y: pivot.y + normal.y * radius * STRADDLE * side
656
959
  };
657
960
  const from = Math.atan2(pivot.y - centre.y, pivot.x - centre.x);
658
961
  const turns = Math.PI * 2 * (0.97 + random() * 0.08);
@@ -660,15 +963,27 @@ function loopedCurvePoints(start, c1, c2, end, radius, random) {
660
963
  for (let i = 1; i <= steps; i++) {
661
964
  const progress = i / steps;
662
965
  const angle = from + turns * progress * side;
966
+ const spread = radius * (STRADDLE + (1 - STRADDLE) * progress);
663
967
  const drift = radius * 0.55 * progress;
664
968
  points.push({
665
- x: centre.x + Math.cos(angle) * radius + heading.x * drift,
666
- y: centre.y + Math.sin(angle) * radius + heading.y * drift
969
+ x: centre.x + Math.cos(angle) * spread + heading.x * drift,
970
+ y: centre.y + Math.sin(angle) * spread + heading.y * drift
667
971
  });
668
972
  }
973
+ const exit = points[points.length - 1];
974
+ const gained = (exit.x - pivot.x) * heading.x + (exit.y - pivot.y) * heading.y;
975
+ const resume = Math.min(knot + Math.max(0, gained) / speed, 0.985);
976
+ const rejoin = at(resume);
977
+ const residual = (exit.x - rejoin.x) * normal.x + (exit.y - rejoin.y) * normal.y;
978
+ const settle = 6;
669
979
  const tail = 14;
670
980
  for (let i = 1; i <= tail; i++) {
671
- points.push(at(knot + (1 - knot) * (i / tail)));
981
+ const point = at(resume + (1 - resume) * (i / tail));
982
+ const fade = Math.max(0, 1 - i / settle);
983
+ points.push({
984
+ x: point.x + normal.x * residual * fade,
985
+ y: point.y + normal.y * residual * fade
986
+ });
672
987
  }
673
988
  return points;
674
989
  }
@@ -742,21 +1057,29 @@ function arrowEndpoints(rect, arrowOptions, random, origin) {
742
1057
  function drawArrowHead(svg, tip, angle, style, random, shaftLength = Infinity) {
743
1058
  const length = Math.min(9 + style.strokeWidth * 1.4, shaftLength * 0.3);
744
1059
  const spread = 0.46 + random() * 0.1;
1060
+ const arms = [];
745
1061
  for (const side of [-1, 1]) {
746
1062
  const armAngle = angle + spread * side;
747
1063
  const tail = {
748
1064
  x: tip.x - length * Math.cos(armAngle),
749
1065
  y: tip.y - length * Math.sin(armAngle)
750
1066
  };
751
- appendPath(
752
- svg,
753
- roughLine(tail.x, tail.y, tip.x, tip.y, style.roughness * 0.5, random, {
754
- steps: 2,
755
- bow: 0.35 * side
756
- }),
757
- { ...style, dashed: false }
1067
+ arms.push(
1068
+ appendPath(
1069
+ svg,
1070
+ roughLine(tail.x, tail.y, tip.x, tip.y, style.roughness * 0.5, random, {
1071
+ steps: 2,
1072
+ bow: 0.35 * side
1073
+ }),
1074
+ { ...style, dashed: false }
1075
+ )
758
1076
  );
759
1077
  }
1078
+ return arms;
1079
+ }
1080
+ function bowOverTheTop(perpendicular) {
1081
+ if (Math.abs(perpendicular.y) < 0.02) return perpendicular.x > 0 ? 1 : -1;
1082
+ return perpendicular.y > 0 ? -1 : 1;
760
1083
  }
761
1084
  function boundsOf(points) {
762
1085
  const xs = points.map((p) => p.x);
@@ -768,7 +1091,7 @@ function boundsOf(points) {
768
1091
  height: Math.max(...ys) - Math.min(...ys)
769
1092
  };
770
1093
  }
771
- function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
1094
+ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null, context = {}) {
772
1095
  const random = createRandom(seed + 17);
773
1096
  const { start, end } = arrowEndpoints(rect, arrowOptions, random, origin);
774
1097
  const dx = end.x - start.x;
@@ -777,6 +1100,7 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
777
1100
  const dashed = arrowOptions.style === "dotted";
778
1101
  let tipAngle = Math.atan2(dy, dx);
779
1102
  let shaft;
1103
+ let ghost = null;
780
1104
  const hull = [start, end];
781
1105
  if (arrowOptions.style === "straight") {
782
1106
  shaft = roughLine(start.x, start.y, end.x, end.y, style.roughness, random, {
@@ -790,7 +1114,12 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
790
1114
  y: rect.y + rect.height / 2 - mid.y
791
1115
  };
792
1116
  const lateral = perpendicular.x * towardCenter.x + perpendicular.y * towardCenter.y;
793
- const sign = Math.abs(lateral) > distance * 0.12 ? lateral > 0 ? -1 : 1 : random() < 0.5 ? -1 : 1;
1117
+ const sign = Math.abs(lateral) > distance * 0.12 ? lateral > 0 ? -1 : 1 : (
1118
+ // Nothing to steer around, so sweep over the top: a hand arcs above
1119
+ // the straight line and drops onto the target, and a coin flip here
1120
+ // used to mirror the whole gesture from one redraw to the next.
1121
+ bowOverTheTop(perpendicular)
1122
+ );
794
1123
  const bow = Math.min(distance * (0.16 + random() * 0.08), 30) * sign;
795
1124
  const c1x = start.x + dx * 0.28 + perpendicular.x * bow;
796
1125
  const c1y = start.y + dy * 0.28 + perpendicular.y * bow;
@@ -805,34 +1134,46 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
805
1134
  { x: c2x, y: c2y },
806
1135
  end,
807
1136
  radius,
808
- random
1137
+ random,
1138
+ -sign
809
1139
  );
810
1140
  shaft = catmullRomPath(points, false);
811
1141
  hull.push(...points);
812
1142
  const last = points[points.length - 1];
813
1143
  const prior = points[points.length - 2] ?? start;
814
1144
  tipAngle = Math.atan2(last.y - prior.y, last.x - prior.x);
815
- appendPath(svg, shaft, { ...style, dashed });
816
- drawArrowHead(svg, end, tipAngle, style, random, distance);
817
- return { start, end, bounds: boundsOf(hull) };
1145
+ } else {
1146
+ shaft = `M ${fmt(start.x)} ${fmt(start.y)} C ${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(end.x)} ${fmt(end.y)}`;
1147
+ if (style.roughness >= 1.1) {
1148
+ const drift = () => (random() - 0.5) * 1.4;
1149
+ 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)}`;
1150
+ }
1151
+ tipAngle = Math.atan2(end.y - c2y, end.x - c2x);
818
1152
  }
819
- shaft = `M ${fmt(start.x)} ${fmt(start.y)} C ${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(end.x)} ${fmt(end.y)}`;
820
- if (style.roughness >= 1.1) {
821
- const drift = () => (random() - 0.5) * 1.4;
822
- 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)}`;
823
- appendPath(svg, ghost, {
1153
+ }
1154
+ const group = document.createElementNS(SVG_NS, "g");
1155
+ svg.appendChild(group);
1156
+ const shaftPaths = [];
1157
+ if (ghost) {
1158
+ shaftPaths.push(
1159
+ appendPath(group, ghost, {
824
1160
  ...style,
825
1161
  opacity: style.opacity * 0.3,
826
1162
  strokeWidth: style.strokeWidth * 0.7,
827
1163
  dashed
828
- });
829
- }
830
- tipAngle = Math.atan2(end.y - c2y, end.x - c2x);
1164
+ })
1165
+ );
831
1166
  }
832
- appendPath(svg, shaft, { ...style, dashed });
833
- drawArrowHead(svg, end, tipAngle, style, random, distance);
1167
+ shaftPaths.push(appendPath(group, shaft, { ...style, dashed }));
1168
+ const head = drawArrowHead(group, end, tipAngle, style, random, distance);
1169
+ animateArrow(
1170
+ group,
1171
+ { shaft: shaftPaths, head, start, end },
1172
+ arrowOptions.animate,
1173
+ context.elapsed
1174
+ );
834
1175
  const bounds = boundsOf(hull);
835
- return { start, end, bounds };
1176
+ return { start, end, bounds, group };
836
1177
  }
837
1178
  function drawUnderline(svg, x, y, width, style, random) {
838
1179
  if (width <= 4) return;
@@ -899,6 +1240,7 @@ function createNoteText(svg, text, style, options = {}) {
899
1240
  const fontSize = style.fontSize ?? NOTE_FONT_SIZE;
900
1241
  const lines = wrapWords(text, options.wordsPerLine ?? DEFAULT_WORDS_PER_LINE);
901
1242
  const lineHeight = fontSize * 1.05;
1243
+ const rtl = options.rtl ?? isRtlText(text);
902
1244
  const group = document.createElementNS(SVG_NS, "g");
903
1245
  const textEl = document.createElementNS(SVG_NS, "text");
904
1246
  textEl.setAttribute("x", "0");
@@ -911,6 +1253,13 @@ function createNoteText(svg, text, style, options = {}) {
911
1253
  textEl.setAttribute("letter-spacing", "0.4");
912
1254
  textEl.setAttribute("text-anchor", "start");
913
1255
  textEl.setAttribute("dominant-baseline", "auto");
1256
+ if (rtl) {
1257
+ textEl.setAttribute("direction", "rtl");
1258
+ textEl.setAttribute("unicode-bidi", "embed");
1259
+ }
1260
+ if (options.locale) {
1261
+ textEl.setAttributeNS(XML_NS, "xml:lang", options.locale);
1262
+ }
914
1263
  const tspans = lines.map((line, index) => {
915
1264
  const tspan = document.createElementNS(SVG_NS, "tspan");
916
1265
  tspan.textContent = line;
@@ -939,15 +1288,17 @@ function createNoteText(svg, text, style, options = {}) {
939
1288
  height,
940
1289
  ascent,
941
1290
  descent,
942
- fontSize
1291
+ fontSize,
1292
+ rtl
943
1293
  };
944
1294
  }
945
1295
  function placeNoteText(note, box, style, seed, options = {}) {
946
1296
  const random = createRandom(seed + 55);
947
1297
  const tilt = options.tilt ?? (random() - 0.5) * 7;
948
- note.textEl.setAttribute("x", fmt(box.x));
1298
+ const anchorX = note.rtl ? box.x + box.width : box.x;
1299
+ note.textEl.setAttribute("x", fmt(anchorX));
949
1300
  note.textEl.setAttribute("y", fmt(box.y + note.ascent));
950
- for (const tspan of note.tspans) tspan.setAttribute("x", fmt(box.x));
1301
+ for (const tspan of note.tspans) tspan.setAttribute("x", fmt(anchorX));
951
1302
  const cx = box.x + box.width / 2;
952
1303
  const cy = box.y + box.height / 2;
953
1304
  note.group.setAttribute(
@@ -956,9 +1307,10 @@ function placeNoteText(note, box, style, seed, options = {}) {
956
1307
  );
957
1308
  if (options.underline !== false) {
958
1309
  const lastWidth = note.lineWidths[note.lineWidths.length - 1] ?? box.width;
1310
+ const underlineX = note.rtl ? box.x + box.width - lastWidth + 1 : box.x + 1;
959
1311
  drawUnderline(
960
1312
  note.group,
961
- box.x + 1,
1313
+ underlineX,
962
1314
  box.y + box.height + 2,
963
1315
  lastWidth - 2,
964
1316
  {
@@ -979,7 +1331,7 @@ function createOverlaySvg(left, top, width, height) {
979
1331
  svg.setAttribute("width", String(width));
980
1332
  svg.setAttribute("height", String(height));
981
1333
  svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
982
- svg.style.position = "fixed";
1334
+ svg.style.position = "absolute";
983
1335
  svg.style.left = `${left}px`;
984
1336
  svg.style.top = `${top}px`;
985
1337
  svg.style.width = `${width}px`;
@@ -1032,12 +1384,17 @@ function nudgeIntoBand(box, guard, band) {
1032
1384
  return { ...box, x: box.x + dx };
1033
1385
  }
1034
1386
  function renderAnnotation(svg, rect, noteOptions, style, seed = 1, layout = {}) {
1035
- if (!noteOptions?.text) return null;
1387
+ const { text, locale } = resolveLocalizedText(
1388
+ noteOptions?.text,
1389
+ noteOptions?.locale
1390
+ );
1391
+ if (!text) return null;
1036
1392
  const band = layout.band ?? null;
1037
1393
  const position = isValidPosition(noteOptions.position) ? noteOptions.position : "top-right";
1038
1394
  const random = createRandom(seed + 9);
1039
- const note = createNoteText(svg, noteOptions.text, style, {
1040
- wordsPerLine: noteOptions.wordsPerLine
1395
+ const note = createNoteText(svg, text, style, {
1396
+ wordsPerLine: noteOptions.wordsPerLine,
1397
+ locale
1041
1398
  });
1042
1399
  const tilt = (random() - 0.45) * 7;
1043
1400
  const tiltPad = Math.abs(Math.sin(tilt * Math.PI / 180)) * note.width * 0.5;
@@ -1079,6 +1436,25 @@ function drawDot(parent, x, y, style, scale = 1.7) {
1079
1436
  strokeWidth: style.strokeWidth * scale
1080
1437
  });
1081
1438
  }
1439
+ function drawFleck(parent, x, y, radius, style, random) {
1440
+ const count = 6;
1441
+ const points = [];
1442
+ for (let i = 0; i < count; i++) {
1443
+ const angle = Math.PI * 2 * i / count + (random() - 0.5) * 0.5;
1444
+ const r = radius * (0.78 + random() * 0.34);
1445
+ points.push({
1446
+ x: x + Math.cos(angle) * r,
1447
+ y: y + Math.sin(angle) * r
1448
+ });
1449
+ }
1450
+ const blob = appendPath(parent, catmullRomPath(points, true), {
1451
+ ...style,
1452
+ fill: style.color,
1453
+ strokeWidth: 0
1454
+ });
1455
+ blob.style.filter = `blur(${(radius * 0.35).toFixed(2)}px)`;
1456
+ return blob;
1457
+ }
1082
1458
  function drawHeart(parent, size, style, random) {
1083
1459
  const s = size;
1084
1460
  const j = () => (random() - 0.5) * s * 0.12;
@@ -1102,26 +1478,61 @@ function twinkle(parent, radiusX, radiusY, pinch, style) {
1102
1478
  `Q ${(-c).toFixed(2)} ${(-c).toFixed(2)} 0 ${(-ry).toFixed(2)}`,
1103
1479
  "Z"
1104
1480
  ].join(" ");
1105
- appendPath(parent, path, style);
1481
+ return appendPath(parent, path, style);
1106
1482
  }
1107
1483
  function drawSparkle(parent, size, style, random) {
1108
1484
  twinkle(
1109
1485
  parent,
1110
- size * (0.34 + random() * 0.1),
1486
+ size * (0.62 + random() * 0.12),
1111
1487
  size,
1112
- size * (0.06 + random() * 0.06),
1488
+ size * (0.14 + random() * 0.08),
1113
1489
  style
1114
1490
  );
1115
1491
  }
1116
1492
  function drawStar(parent, size, style, random) {
1117
1493
  twinkle(
1118
1494
  parent,
1119
- size * (0.66 + random() * 0.12),
1120
- size,
1121
- size * (0.2 + random() * 0.08),
1495
+ size * (0.62 + random() * 0.12),
1496
+ size * (0.8 + random() * 0.12),
1497
+ size * (0.14 + random() * 0.08),
1122
1498
  style
1123
1499
  );
1124
1500
  }
1501
+ function drawGlimmer(parent, size, style, random) {
1502
+ appendGlow(parent, size * (1.2 + random() * 0.2), {
1503
+ color: style.color,
1504
+ opacity: style.opacity * 0.22
1505
+ });
1506
+ const armX = size * (0.5 + random() * 0.08);
1507
+ const armY = size * (0.45 + random() * 0.08);
1508
+ const bluntness = size * 0.19;
1509
+ const main = twinkle(parent, armX, armY, size * (0.08 + random() * 0.03), {
1510
+ ...style,
1511
+ fill: style.color,
1512
+ strokeWidth: bluntness
1513
+ });
1514
+ main.setAttribute("stroke-linejoin", "round");
1515
+ main.setAttribute("stroke-linecap", "round");
1516
+ main.style.filter = `blur(${(size * 0.05).toFixed(2)}px)`;
1517
+ const gap = bluntness / 2 + size * 0.2;
1518
+ const corners = [
1519
+ { x: 0, y: -(armY + gap) },
1520
+ { x: armX + gap, y: 0 },
1521
+ { x: 0, y: armY + gap },
1522
+ { x: -(armX + gap), y: 0 }
1523
+ ];
1524
+ for (const { x, y } of corners) {
1525
+ const jitter = 0.95 + random() * 0.1;
1526
+ drawFleck(
1527
+ parent,
1528
+ x * jitter,
1529
+ y * jitter,
1530
+ size * (0.1 + random() * 0.03),
1531
+ style,
1532
+ random
1533
+ );
1534
+ }
1535
+ }
1125
1536
  function drawTwinkle(parent, size, style, random) {
1126
1537
  const count = 5;
1127
1538
  const first = -Math.PI / 2 + (random() - 0.5) * 0.5;
@@ -1229,46 +1640,15 @@ function drawEmphasis(parent, size, style, random, opts = {}) {
1229
1640
  );
1230
1641
  }
1231
1642
  }
1232
- function drawDots(parent, size, style, random) {
1233
- let x = -size * 0.7;
1234
- for (let i = 0; i < 3; i++) {
1235
- drawDot(parent, x, (random() - 0.5) * size * 0.16, style);
1236
- x += size * (0.6 + random() * 0.25);
1237
- }
1238
- }
1239
- function drawStroke(parent, size, style, random) {
1240
- appendPath(
1241
- parent,
1242
- roughLine(-size, 0, size, 0, 1.2, random, {
1243
- bow: (random() - 0.5) * size * 0.35,
1244
- steps: 4
1245
- }),
1246
- style
1247
- );
1248
- }
1249
- function drawSteam(parent, size, style, random) {
1250
- const lean = random() < 0.5 ? -1 : 1;
1251
- for (let i = 0; i < 2; i++) {
1252
- const x = (i - 0.5) * size * 0.72;
1253
- const base = size * 0.62 - i * size * 0.14;
1254
- appendPath(
1255
- parent,
1256
- `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)}`,
1257
- style
1258
- );
1259
- }
1260
- }
1261
1643
  var DECORATION_DRAWERS = {
1262
1644
  heart: drawHeart,
1263
1645
  sparkle: drawSparkle,
1264
1646
  star: drawStar,
1265
1647
  twinkle: drawTwinkle,
1648
+ glimmer: drawGlimmer,
1266
1649
  smiley: drawSmiley,
1267
1650
  emphasis: drawEmphasis,
1268
- arcs: drawArcs,
1269
- dots: drawDots,
1270
- stroke: drawStroke,
1271
- steam: drawSteam
1651
+ arcs: drawArcs
1272
1652
  };
1273
1653
  var TEXT_TYPES = ["heart", "sparkle"];
1274
1654
  var CORNER_ACCENTS = ["arcs", "emphasis"];
@@ -1279,12 +1659,10 @@ var TYPE_TRAITS = {
1279
1659
  sparkle: { size: [6, 9], tilt: 20, standoff: 22 },
1280
1660
  star: { size: [8, 11], tilt: 25, standoff: 26 },
1281
1661
  twinkle: { size: [5, 7.5], tilt: 30, standoff: 26, brightness: 1.4 },
1662
+ glimmer: { size: [7, 10], tilt: 20, standoff: 28, brightness: 1.3 },
1282
1663
  smiley: { size: [8, 11], tilt: 10, standoff: 22 },
1283
1664
  emphasis: { size: [12, 17], tilt: 10, standoff: 13, outward: true },
1284
- arcs: { size: [11, 16], tilt: 12, standoff: 12, outward: true },
1285
- dots: { size: [6, 9], tilt: 8, standoff: 20 },
1286
- stroke: { size: [8, 12], tilt: 40, standoff: 20 },
1287
- steam: { size: [8, 11], tilt: 10, standoff: 20 }
1665
+ arcs: { size: [11, 16], tilt: 12, standoff: 12, outward: true }
1288
1666
  };
1289
1667
  var DEFAULT_TYPES = [
1290
1668
  "twinkle",
@@ -1487,6 +1865,9 @@ function ensureHandwrittenFont() {
1487
1865
 
1488
1866
  // src/doodle.js
1489
1867
  var instances = /* @__PURE__ */ new WeakMap();
1868
+ function now() {
1869
+ return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1870
+ }
1490
1871
  function localBand(overlayRect, inset = 8) {
1491
1872
  const width = window.innerWidth || document.documentElement.clientWidth || 0;
1492
1873
  return {
@@ -1503,19 +1884,21 @@ var DoodleOverlay = class {
1503
1884
  this.element = element;
1504
1885
  this.options = options;
1505
1886
  this.seed = Math.floor(Math.random() * 1e9);
1887
+ this.startedAt = now();
1506
1888
  this.svg = null;
1889
+ this.wrapper = null;
1507
1890
  this.resizeObserver = null;
1508
1891
  this.mutationObserver = null;
1509
1892
  this.pendingFrame = null;
1510
- this.scrollTargets = [];
1511
- this.onScroll = this.scheduleUpdate.bind(this);
1893
+ this.listeners = [];
1512
1894
  this.onResize = this.scheduleUpdate.bind(this);
1895
+ this.onLanguageChange = this.scheduleUpdate.bind(this);
1513
1896
  this.childElements = [];
1514
1897
  this.mount();
1515
1898
  }
1516
1899
  mount() {
1517
1900
  this.svg = createOverlaySvg(0, 0, 0, 0);
1518
- insertOverlaySvg(this.svg, this.element);
1901
+ this.wrapper = insertOverlaySvg(this.svg, this.element);
1519
1902
  syncOverlayStacking(this.svg, this.element, this.options.zIndex);
1520
1903
  this.resizeObserver = new ResizeObserver(this.onResize);
1521
1904
  this.resizeObserver.observe(this.element);
@@ -1528,12 +1911,18 @@ var DoodleOverlay = class {
1528
1911
  subtree: true,
1529
1912
  attributes: true
1530
1913
  });
1531
- for (const target of getScrollableAncestors(this.element)) {
1532
- target.addEventListener("scroll", this.onScroll, { passive: true });
1533
- this.scrollTargets.push({ target, type: "scroll" });
1534
- }
1535
1914
  window.addEventListener("resize", this.onResize, { passive: true });
1536
- this.scrollTargets.push({ target: window, type: "resize" });
1915
+ this.listeners.push({
1916
+ target: window,
1917
+ type: "resize",
1918
+ handler: this.onResize
1919
+ });
1920
+ window.addEventListener("languagechange", this.onLanguageChange);
1921
+ this.listeners.push({
1922
+ target: window,
1923
+ type: "languagechange",
1924
+ handler: this.onLanguageChange
1925
+ });
1537
1926
  if (this.options.note && this.options.autoLoadFont && !this.options.fontFamily) {
1538
1927
  ensureHandwrittenFont();
1539
1928
  }
@@ -1569,17 +1958,19 @@ var DoodleOverlay = class {
1569
1958
  const { padding, radius, color, strokeWidth, roughness, opacity } = this.options;
1570
1959
  const targets = this.getTargets();
1571
1960
  const margin = this.overlayMargin();
1961
+ const elapsed = now() - this.startedAt;
1572
1962
  const absoluteRects = targets.map(
1573
1963
  (target) => getElementBounds(target, padding, radius)
1574
1964
  );
1575
1965
  const overlayRect = unionRects(absoluteRects, margin);
1576
1966
  clearSvg(this.svg);
1577
1967
  syncOverlayStacking(this.svg, this.element, this.options.zIndex);
1968
+ const origin = this.wrapper ? this.wrapper.getBoundingClientRect() : { left: 0, top: 0 };
1578
1969
  this.svg.setAttribute("width", String(overlayRect.width));
1579
1970
  this.svg.setAttribute("height", String(overlayRect.height));
1580
1971
  this.svg.setAttribute("viewBox", `0 0 ${overlayRect.width} ${overlayRect.height}`);
1581
- this.svg.style.left = `${overlayRect.x}px`;
1582
- this.svg.style.top = `${overlayRect.y}px`;
1972
+ this.svg.style.left = `${overlayRect.x - origin.left}px`;
1973
+ this.svg.style.top = `${overlayRect.y - origin.top}px`;
1583
1974
  this.svg.style.width = `${overlayRect.width}px`;
1584
1975
  this.svg.style.height = `${overlayRect.height}px`;
1585
1976
  const strokeStyle = { color, strokeWidth, roughness, opacity };
@@ -1621,7 +2012,8 @@ var DoodleOverlay = class {
1621
2012
  this.options.arrow,
1622
2013
  strokeStyle,
1623
2014
  seed,
1624
- note ? note.box : null
2015
+ note ? note.box : null,
2016
+ { elapsed }
1625
2017
  ) : null;
1626
2018
  if (this.options.decorations) {
1627
2019
  renderDecorations(
@@ -1648,7 +2040,7 @@ var DoodleOverlay = class {
1648
2040
  overlayMargin() {
1649
2041
  const { note, arrow, decorations } = this.options;
1650
2042
  if (!note && !arrow && !decorations) return 48;
1651
- const text = typeof note?.text === "string" ? note.text : "";
2043
+ const { text } = resolveLocalizedText(note?.text, note?.locale);
1652
2044
  return Math.max(96, Math.min(240, 80 + text.length * 8));
1653
2045
  }
1654
2046
  destroy() {
@@ -1668,14 +2060,15 @@ var DoodleOverlay = class {
1668
2060
  document.fonts?.removeEventListener?.("loadingdone", this.onFontsLoaded);
1669
2061
  this.onFontsLoaded = null;
1670
2062
  }
1671
- for (const { target, type } of this.scrollTargets) {
1672
- target.removeEventListener(type, type === "scroll" ? this.onScroll : this.onResize);
2063
+ for (const { target, type, handler } of this.listeners) {
2064
+ target.removeEventListener(type, handler);
1673
2065
  }
1674
- this.scrollTargets = [];
1675
- if (this.svg?.parentNode) {
1676
- this.svg.parentNode.removeChild(this.svg);
2066
+ this.listeners = [];
2067
+ if (this.svg) {
2068
+ removeOverlaySvg(this.svg, this.wrapper, this.element);
1677
2069
  }
1678
2070
  this.svg = null;
2071
+ this.wrapper = null;
1679
2072
  instances.delete(this.element);
1680
2073
  }
1681
2074
  };
@@ -1712,7 +2105,8 @@ var OPTION_KEYS = [
1712
2105
  "decorations",
1713
2106
  "addBreaks",
1714
2107
  "fontFamily",
1715
- "autoLoadFont"
2108
+ "autoLoadFont",
2109
+ "locale"
1716
2110
  ];
1717
2111
  function collectOptions(props) {
1718
2112
  const options = {};
@@ -1764,6 +2158,7 @@ var Doodle = forwardRef(function Doodle2(props, forwardedRef) {
1764
2158
  addBreaks,
1765
2159
  fontFamily,
1766
2160
  autoLoadFont,
2161
+ locale,
1767
2162
  childSelector,
1768
2163
  ...rest
1769
2164
  } = props;
@@ -1783,6 +2178,7 @@ var Doodle = forwardRef(function Doodle2(props, forwardedRef) {
1783
2178
  addBreaks,
1784
2179
  fontFamily,
1785
2180
  autoLoadFont,
2181
+ locale,
1786
2182
  childSelector
1787
2183
  }),
1788
2184
  [
@@ -1800,6 +2196,7 @@ var Doodle = forwardRef(function Doodle2(props, forwardedRef) {
1800
2196
  JSON.stringify(addBreaks ?? null),
1801
2197
  fontFamily,
1802
2198
  autoLoadFont,
2199
+ JSON.stringify(locale ?? null),
1803
2200
  childSelector
1804
2201
  ]
1805
2202
  );