@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.
@@ -23,6 +23,265 @@ __export(index_exports, {
23
23
  });
24
24
  module.exports = __toCommonJS(index_exports);
25
25
 
26
+ // src/animation.js
27
+ var STYLE_MARKER = "data-freehand-animation";
28
+ var CSS = `
29
+ @keyframes freehand-arrow-dash {
30
+ from { stroke-dashoffset: var(--freehand-dash, 0px); }
31
+ to { stroke-dashoffset: 0; }
32
+ }
33
+ @keyframes freehand-arrow-drift {
34
+ 0%, 100% { transform: translate(0px, 0px); }
35
+ 50% { transform: translate(var(--freehand-drift-x, 0px), var(--freehand-drift-y, 0px)); }
36
+ }
37
+ @keyframes freehand-arrow-settle {
38
+ from { transform: translate(0px, 0px); }
39
+ to { transform: translate(var(--freehand-drift-x, 0px), var(--freehand-drift-y, 0px)); }
40
+ }
41
+ @media (prefers-reduced-motion: reduce) {
42
+ .freehand-arrow, .freehand-arrow path { animation: none !important; }
43
+ .freehand-arrow path { stroke-dashoffset: 0 !important; }
44
+ }
45
+ `;
46
+ var ARROW_ANIMATION_DEFAULTS = {
47
+ /** Reveal the stroke with a dash sweep. */
48
+ draw: true,
49
+ /** Lean the arrow toward its tip. */
50
+ drift: true,
51
+ /** Multiplier over every duration - 2 plays twice as fast. */
52
+ speed: 1,
53
+ /** How long the shaft takes to draw, ms, before `speed`. */
54
+ duration: 900,
55
+ /** Wait before the first stroke appears, ms. */
56
+ delay: 150,
57
+ /** How far the arrow leans, px. */
58
+ distance: 5,
59
+ /** One full lean-and-return, ms. */
60
+ driftDuration: 2200,
61
+ /** Keep leaning. `false` settles once and stays put. */
62
+ repeat: true
63
+ };
64
+ function positive(value, fallback) {
65
+ const n = Number(value);
66
+ return Number.isFinite(n) && n > 0 ? n : fallback;
67
+ }
68
+ function atLeastZero(value, fallback) {
69
+ const n = Number(value);
70
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
71
+ }
72
+ function normalizeArrowAnimation(config) {
73
+ if (config === false || config === null) return false;
74
+ const settings = config === true || config === void 0 || typeof config !== "object" ? {} : config;
75
+ const animation = {
76
+ draw: settings.draw !== false,
77
+ drift: settings.drift !== false,
78
+ speed: positive(settings.speed, ARROW_ANIMATION_DEFAULTS.speed),
79
+ duration: positive(settings.duration, ARROW_ANIMATION_DEFAULTS.duration),
80
+ delay: atLeastZero(settings.delay, ARROW_ANIMATION_DEFAULTS.delay),
81
+ distance: atLeastZero(settings.distance, ARROW_ANIMATION_DEFAULTS.distance),
82
+ driftDuration: positive(
83
+ settings.driftDuration,
84
+ ARROW_ANIMATION_DEFAULTS.driftDuration
85
+ ),
86
+ repeat: settings.repeat !== false
87
+ };
88
+ if (!animation.draw && (!animation.drift || !animation.distance)) return false;
89
+ return animation;
90
+ }
91
+ var FLOW_PERIOD = 260;
92
+ function arrowTiming(animation) {
93
+ const duration = animation.duration / animation.speed;
94
+ const delay = animation.delay / animation.speed;
95
+ const drawn = animation.draw ? duration : 0;
96
+ return {
97
+ shaft: { duration, delay },
98
+ head: { duration: duration * 0.22, delay: delay + drawn * 0.82 },
99
+ flow: { duration: FLOW_PERIOD / animation.speed, delay },
100
+ drift: {
101
+ duration: animation.driftDuration / animation.speed,
102
+ delay: delay + drawn * 1.04
103
+ }
104
+ };
105
+ }
106
+ var injected = false;
107
+ function ensureArrowAnimationStyles() {
108
+ if (injected) return;
109
+ if (typeof document === "undefined" || !document.head) return;
110
+ injected = true;
111
+ if (document.querySelector(`style[${STYLE_MARKER}]`)) return;
112
+ const style = document.createElement("style");
113
+ style.setAttribute(STYLE_MARKER, "");
114
+ style.textContent = CSS;
115
+ document.head.appendChild(style);
116
+ }
117
+ function time(ms) {
118
+ return `${Math.round(ms)}ms`;
119
+ }
120
+ function setAnimation(element, spec) {
121
+ const parts = [
122
+ spec.name,
123
+ time(spec.duration),
124
+ spec.easing,
125
+ time(spec.delay),
126
+ spec.iterations ?? "1"
127
+ ];
128
+ if (spec.fill) parts.push(spec.fill);
129
+ element.style.animation = parts.join(" ");
130
+ }
131
+ function pathLength(path) {
132
+ try {
133
+ const length = path.getTotalLength();
134
+ return Number.isFinite(length) && length > 0 ? length : 0;
135
+ } catch {
136
+ return 0;
137
+ }
138
+ }
139
+ function dashPeriod(path) {
140
+ const pattern = (path.getAttribute("stroke-dasharray") || "").split(/[\s,]+/).map(Number).filter((n) => Number.isFinite(n) && n > 0);
141
+ if (!pattern.length) return 0;
142
+ const sum = pattern.reduce((total, n) => total + n, 0);
143
+ return pattern.length % 2 === 0 ? sum : sum * 2;
144
+ }
145
+ function applyDash(path, timing, elapsed, flow) {
146
+ const length = pathLength(path);
147
+ if (!length) return;
148
+ const period = dashPeriod(path);
149
+ if (period) {
150
+ path.style.setProperty("--freehand-dash", `${period.toFixed(2)}px`);
151
+ setAnimation(path, {
152
+ name: "freehand-arrow-dash",
153
+ duration: flow.duration,
154
+ easing: "linear",
155
+ delay: flow.delay - elapsed,
156
+ iterations: "infinite"
157
+ });
158
+ return;
159
+ }
160
+ path.style.setProperty("--freehand-dash", `${length.toFixed(2)}px`);
161
+ path.style.strokeDasharray = `${length.toFixed(2)}px`;
162
+ setAnimation(path, {
163
+ name: "freehand-arrow-dash",
164
+ duration: timing.duration,
165
+ easing: "ease-out",
166
+ delay: timing.delay - elapsed,
167
+ fill: "both"
168
+ });
169
+ }
170
+ function applyDrift(group, parts, timing, elapsed, animation) {
171
+ const dx = parts.end.x - parts.start.x;
172
+ const dy = parts.end.y - parts.start.y;
173
+ const length = Math.hypot(dx, dy);
174
+ if (!length) return;
175
+ const scale = animation.distance / length;
176
+ group.style.setProperty("--freehand-drift-x", `${(dx * scale).toFixed(2)}px`);
177
+ group.style.setProperty("--freehand-drift-y", `${(dy * scale).toFixed(2)}px`);
178
+ setAnimation(
179
+ group,
180
+ animation.repeat ? {
181
+ name: "freehand-arrow-drift",
182
+ duration: timing.duration,
183
+ easing: "ease-in-out",
184
+ delay: timing.delay - elapsed,
185
+ iterations: "infinite"
186
+ } : {
187
+ // A single lean is a settle, not a round trip, so it takes half a cycle
188
+ name: "freehand-arrow-settle",
189
+ duration: timing.duration * 0.5,
190
+ easing: "ease-out",
191
+ delay: timing.delay - elapsed,
192
+ fill: "both"
193
+ }
194
+ );
195
+ }
196
+ function animateArrow(group, parts, animation, elapsed = 0) {
197
+ if (!animation || !group) return;
198
+ ensureArrowAnimationStyles();
199
+ group.setAttribute("class", "freehand-arrow");
200
+ const timing = arrowTiming(animation);
201
+ const age = Number.isFinite(elapsed) ? Math.max(0, elapsed) : 0;
202
+ if (animation.draw) {
203
+ for (const path of parts.shaft) {
204
+ applyDash(path, timing.shaft, age, timing.flow);
205
+ }
206
+ for (const path of parts.head) applyDash(path, timing.head, age, timing.flow);
207
+ }
208
+ if (animation.drift && animation.distance > 0) {
209
+ applyDrift(group, parts, timing.drift, age, animation);
210
+ }
211
+ }
212
+
213
+ // src/locale.js
214
+ var DEFAULT_KEY = "default";
215
+ var LANGUAGE_TAG = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i;
216
+ var RTL_STRONG = /[֑-߿יִ-﷿ﹰ-ﻼ]/;
217
+ var LTR_STRONG = /[A-Za-zÀ-֏ऀ-῿Ⰰ-퟿豈-ﬗA-Za-z]/;
218
+ function normalizeTag(tag) {
219
+ if (typeof tag !== "string") return "";
220
+ return tag.trim().replace(/_/g, "-").toLowerCase();
221
+ }
222
+ function isLocaleMap(value) {
223
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
224
+ const entries = Object.entries(value);
225
+ if (!entries.length) return false;
226
+ return entries.every(([key, text]) => {
227
+ if (typeof text !== "string") return false;
228
+ const tag = normalizeTag(key);
229
+ return tag === DEFAULT_KEY || LANGUAGE_TAG.test(tag);
230
+ });
231
+ }
232
+ function preferredLocales(override) {
233
+ const tags = [];
234
+ const add = (value) => {
235
+ const tag = normalizeTag(value);
236
+ if (tag && !tags.includes(tag)) tags.push(tag);
237
+ };
238
+ if (Array.isArray(override)) override.forEach(add);
239
+ else add(override);
240
+ const nav = typeof navigator === "undefined" ? null : navigator;
241
+ if (nav) {
242
+ if (Array.isArray(nav.languages)) nav.languages.forEach(add);
243
+ add(nav.language);
244
+ }
245
+ if (typeof document !== "undefined") add(document.documentElement?.lang);
246
+ return tags;
247
+ }
248
+ function matchLocale(tags, preferred) {
249
+ const available = tags.map(normalizeTag);
250
+ for (const want of preferred) {
251
+ let tag = want;
252
+ while (tag) {
253
+ const exact = available.indexOf(tag);
254
+ if (exact !== -1) return exact;
255
+ const cut = tag.lastIndexOf("-");
256
+ tag = cut > 0 ? tag.slice(0, cut) : "";
257
+ }
258
+ const base = want.split("-")[0];
259
+ const sibling = available.findIndex((key) => key.split("-")[0] === base);
260
+ if (sibling !== -1) return sibling;
261
+ }
262
+ return -1;
263
+ }
264
+ function resolveLocalizedText(text, locale = null) {
265
+ if (typeof text === "string") return { text, locale: null };
266
+ if (!isLocaleMap(text)) return { text: "", locale: null };
267
+ const keys = Object.keys(text);
268
+ const tags = keys.filter((key) => normalizeTag(key) !== DEFAULT_KEY);
269
+ const index = matchLocale(tags, preferredLocales(locale));
270
+ if (index !== -1) {
271
+ return { text: text[tags[index]], locale: normalizeTag(tags[index]) };
272
+ }
273
+ const fallback = keys.find((key) => normalizeTag(key) === DEFAULT_KEY);
274
+ if (fallback) return { text: text[fallback], locale: null };
275
+ return { text: text[keys[0]], locale: normalizeTag(keys[0]) };
276
+ }
277
+ function isRtlText(text) {
278
+ if (typeof text !== "string") return false;
279
+ const rtl = text.search(RTL_STRONG);
280
+ if (rtl === -1) return false;
281
+ const ltr = text.search(LTR_STRONG);
282
+ return ltr === -1 || rtl < ltr;
283
+ }
284
+
26
285
  // src/utils.js
27
286
  var DEFAULT_OPTIONS = {
28
287
  border: true,
@@ -42,6 +301,11 @@ var DEFAULT_OPTIONS = {
42
301
  fontFamily: null,
43
302
  /** Fetch Caveat when the page has not provided it. */
44
303
  autoLoadFont: true,
304
+ /**
305
+ * Language for a note written in more than one. Omit to follow the visitor's
306
+ * own languages, which is what their operating system is set to.
307
+ */
308
+ locale: null,
45
309
  /**
46
310
  * Overlay stacking order. Omit to mirror the target element's z-index so
47
311
  * doodles stay above the annotated element but below modals and other UI.
@@ -60,8 +324,15 @@ function resolveElement(target) {
60
324
  }
61
325
  function mergeOptions(options = {}) {
62
326
  const merged = { ...DEFAULT_OPTIONS, ...options };
63
- if (merged.note && typeof merged.note === "string") {
64
- merged.note = { text: merged.note, position: "top-right" };
327
+ if (merged.note) {
328
+ const config = typeof merged.note === "string" || isLocaleMap(merged.note) ? { text: merged.note } : merged.note;
329
+ merged.note = {
330
+ ...config,
331
+ position: config.position ?? "top-right",
332
+ // The note carries the chosen language, so `renderAnnotation` can resolve
333
+ // a multilingual note on its own.
334
+ locale: config.locale ?? merged.locale ?? null
335
+ };
65
336
  }
66
337
  if (merged.arrow) {
67
338
  const config = merged.arrow === true ? {} : merged.arrow;
@@ -71,7 +342,10 @@ function mergeOptions(options = {}) {
71
342
  // explicit `from` is honoured whether or not there is a note.
72
343
  from: config.from ?? (merged.note ? "note" : "top-right"),
73
344
  to: config.to ?? "edge",
74
- style: config.style ?? "curved"
345
+ style: config.style ?? "curved",
346
+ // Arrows draw themselves on and lean toward their target by default;
347
+ // `animate: false` opts out, an object tunes speed and gestures
348
+ animate: normalizeArrowAnimation(config.animate)
75
349
  };
76
350
  }
77
351
  if (merged.decorations === true) {
@@ -125,20 +399,6 @@ function isValidPosition(position) {
125
399
  "center"
126
400
  ].includes(position);
127
401
  }
128
- function getScrollableAncestors(element) {
129
- const ancestors = [];
130
- let node = element.parentElement;
131
- while (node && node !== document.documentElement) {
132
- const style = getComputedStyle(node);
133
- const overflow = style.overflow + style.overflowY + style.overflowX;
134
- if (/(auto|scroll|overlay)/.test(overflow)) {
135
- ancestors.push(node);
136
- }
137
- node = node.parentElement;
138
- }
139
- ancestors.push(window);
140
- return ancestors;
141
- }
142
402
  function resolveOverlayZIndex(element, override) {
143
403
  if (override != null) return override;
144
404
  const { zIndex, position } = getComputedStyle(element);
@@ -157,13 +417,45 @@ function resolveOverlayZIndex(element, override) {
157
417
  }
158
418
  return null;
159
419
  }
420
+ var ITEM_PLACEMENT_PROPERTIES = [
421
+ "order",
422
+ "flexGrow",
423
+ "flexShrink",
424
+ "flexBasis",
425
+ "alignSelf",
426
+ "justifySelf",
427
+ "gridColumn",
428
+ "gridRow",
429
+ "gridArea"
430
+ ];
431
+ function isInlineDisplay(computed) {
432
+ return computed.display.startsWith("inline");
433
+ }
160
434
  function insertOverlaySvg(svg, element) {
161
435
  const parent = element.parentNode;
162
436
  if (!parent) {
163
437
  document.body.appendChild(svg);
164
- return;
438
+ return null;
439
+ }
440
+ const wrapper = document.createElement("div");
441
+ wrapper.style.position = "relative";
442
+ const computed = getComputedStyle(element);
443
+ wrapper.style.display = isInlineDisplay(computed) ? "inline-block" : "block";
444
+ for (const property of ITEM_PLACEMENT_PROPERTIES) {
445
+ const value = computed[property];
446
+ if (value) wrapper.style[property] = value;
447
+ }
448
+ parent.replaceChild(wrapper, element);
449
+ wrapper.appendChild(element);
450
+ wrapper.appendChild(svg);
451
+ return wrapper;
452
+ }
453
+ function removeOverlaySvg(svg, wrapper, element) {
454
+ if (svg.parentNode) {
455
+ svg.parentNode.removeChild(svg);
165
456
  }
166
- parent.insertBefore(svg, element.nextSibling);
457
+ if (!wrapper || !wrapper.parentNode) return;
458
+ wrapper.parentNode.replaceChild(element, wrapper);
167
459
  }
168
460
  function syncOverlayStacking(svg, element, zIndexOverride) {
169
461
  const zIndex = resolveOverlayZIndex(element, zIndexOverride);
@@ -320,6 +612,7 @@ function annotationAnchor(position, rect, offset = 12) {
320
612
 
321
613
  // src/renderer.js
322
614
  var SVG_NS = "http://www.w3.org/2000/svg";
615
+ var XML_NS = "http://www.w3.org/XML/1998/namespace";
323
616
  var HANDWRITTEN_FONT = '"Caveat", "Kalam", "Patrick Hand", "Bradley Hand", "Segoe Script", "Comic Sans MS", cursive';
324
617
  var NOTE_FONT_SIZE = 19;
325
618
  function clearSvg(svg) {
@@ -565,6 +858,15 @@ function appendPath(svg, d, style) {
565
858
  svg.appendChild(path);
566
859
  return path;
567
860
  }
861
+ function appendGlow(svg, radius, style) {
862
+ const circle = document.createElementNS(SVG_NS, "circle");
863
+ circle.setAttribute("r", fmt(radius));
864
+ circle.setAttribute("fill", style.color);
865
+ circle.setAttribute("opacity", String(style.opacity));
866
+ circle.style.filter = `blur(${fmt(radius * 0.4)}px)`;
867
+ svg.appendChild(circle);
868
+ return circle;
869
+ }
568
870
  function rectPerimeter(width, height, radius) {
569
871
  const r = clamp(radius, 0, Math.min(width, height) / 2);
570
872
  return 2 * (width - 2 * r) + 2 * (height - 2 * r) + 2 * Math.PI * r;
@@ -651,20 +953,21 @@ function cubicTangentAt(p0, c1, c2, p3, t) {
651
953
  y: a * (c1.y - p0.y) + b * (c2.y - c1.y) + c * (p3.y - c2.y)
652
954
  };
653
955
  }
654
- function loopedCurvePoints(start, c1, c2, end, radius, random) {
956
+ var STRADDLE = 0.75;
957
+ function loopedCurvePoints(start, c1, c2, end, radius, random, side = 1) {
655
958
  const at = (t) => cubicAt(start, c1, c2, end, t);
656
- const tangentAt = (t) => normalize(cubicTangentAt(start, c1, c2, end, t));
657
959
  const knot = 0.46 + random() * 0.12;
658
960
  const points = [];
659
961
  const lead = Math.max(2, Math.round(knot * 16));
660
962
  for (let i = 0; i <= lead; i++) points.push(at(knot * i / lead));
661
963
  const pivot = at(knot);
662
- const heading = tangentAt(knot);
964
+ const derivative = cubicTangentAt(start, c1, c2, end, knot);
965
+ const speed = Math.hypot(derivative.x, derivative.y) || 1;
966
+ const heading = normalize(derivative);
663
967
  const normal = { x: -heading.y, y: heading.x };
664
- const side = random() < 0.5 ? 1 : -1;
665
968
  const centre = {
666
- x: pivot.x + normal.x * radius * side,
667
- y: pivot.y + normal.y * radius * side
969
+ x: pivot.x + normal.x * radius * STRADDLE * side,
970
+ y: pivot.y + normal.y * radius * STRADDLE * side
668
971
  };
669
972
  const from = Math.atan2(pivot.y - centre.y, pivot.x - centre.x);
670
973
  const turns = Math.PI * 2 * (0.97 + random() * 0.08);
@@ -672,15 +975,27 @@ function loopedCurvePoints(start, c1, c2, end, radius, random) {
672
975
  for (let i = 1; i <= steps; i++) {
673
976
  const progress = i / steps;
674
977
  const angle = from + turns * progress * side;
978
+ const spread = radius * (STRADDLE + (1 - STRADDLE) * progress);
675
979
  const drift = radius * 0.55 * progress;
676
980
  points.push({
677
- x: centre.x + Math.cos(angle) * radius + heading.x * drift,
678
- y: centre.y + Math.sin(angle) * radius + heading.y * drift
981
+ x: centre.x + Math.cos(angle) * spread + heading.x * drift,
982
+ y: centre.y + Math.sin(angle) * spread + heading.y * drift
679
983
  });
680
984
  }
985
+ const exit = points[points.length - 1];
986
+ const gained = (exit.x - pivot.x) * heading.x + (exit.y - pivot.y) * heading.y;
987
+ const resume = Math.min(knot + Math.max(0, gained) / speed, 0.985);
988
+ const rejoin = at(resume);
989
+ const residual = (exit.x - rejoin.x) * normal.x + (exit.y - rejoin.y) * normal.y;
990
+ const settle = 6;
681
991
  const tail = 14;
682
992
  for (let i = 1; i <= tail; i++) {
683
- points.push(at(knot + (1 - knot) * (i / tail)));
993
+ const point = at(resume + (1 - resume) * (i / tail));
994
+ const fade = Math.max(0, 1 - i / settle);
995
+ points.push({
996
+ x: point.x + normal.x * residual * fade,
997
+ y: point.y + normal.y * residual * fade
998
+ });
684
999
  }
685
1000
  return points;
686
1001
  }
@@ -754,21 +1069,29 @@ function arrowEndpoints(rect, arrowOptions, random, origin) {
754
1069
  function drawArrowHead(svg, tip, angle, style, random, shaftLength = Infinity) {
755
1070
  const length = Math.min(9 + style.strokeWidth * 1.4, shaftLength * 0.3);
756
1071
  const spread = 0.46 + random() * 0.1;
1072
+ const arms = [];
757
1073
  for (const side of [-1, 1]) {
758
1074
  const armAngle = angle + spread * side;
759
1075
  const tail = {
760
1076
  x: tip.x - length * Math.cos(armAngle),
761
1077
  y: tip.y - length * Math.sin(armAngle)
762
1078
  };
763
- appendPath(
764
- svg,
765
- roughLine(tail.x, tail.y, tip.x, tip.y, style.roughness * 0.5, random, {
766
- steps: 2,
767
- bow: 0.35 * side
768
- }),
769
- { ...style, dashed: false }
1079
+ arms.push(
1080
+ appendPath(
1081
+ svg,
1082
+ roughLine(tail.x, tail.y, tip.x, tip.y, style.roughness * 0.5, random, {
1083
+ steps: 2,
1084
+ bow: 0.35 * side
1085
+ }),
1086
+ { ...style, dashed: false }
1087
+ )
770
1088
  );
771
1089
  }
1090
+ return arms;
1091
+ }
1092
+ function bowOverTheTop(perpendicular) {
1093
+ if (Math.abs(perpendicular.y) < 0.02) return perpendicular.x > 0 ? 1 : -1;
1094
+ return perpendicular.y > 0 ? -1 : 1;
772
1095
  }
773
1096
  function boundsOf(points) {
774
1097
  const xs = points.map((p) => p.x);
@@ -780,7 +1103,7 @@ function boundsOf(points) {
780
1103
  height: Math.max(...ys) - Math.min(...ys)
781
1104
  };
782
1105
  }
783
- function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
1106
+ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null, context = {}) {
784
1107
  const random = createRandom(seed + 17);
785
1108
  const { start, end } = arrowEndpoints(rect, arrowOptions, random, origin);
786
1109
  const dx = end.x - start.x;
@@ -789,6 +1112,7 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
789
1112
  const dashed = arrowOptions.style === "dotted";
790
1113
  let tipAngle = Math.atan2(dy, dx);
791
1114
  let shaft;
1115
+ let ghost = null;
792
1116
  const hull = [start, end];
793
1117
  if (arrowOptions.style === "straight") {
794
1118
  shaft = roughLine(start.x, start.y, end.x, end.y, style.roughness, random, {
@@ -802,7 +1126,12 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
802
1126
  y: rect.y + rect.height / 2 - mid.y
803
1127
  };
804
1128
  const lateral = perpendicular.x * towardCenter.x + perpendicular.y * towardCenter.y;
805
- const sign = Math.abs(lateral) > distance * 0.12 ? lateral > 0 ? -1 : 1 : random() < 0.5 ? -1 : 1;
1129
+ const sign = Math.abs(lateral) > distance * 0.12 ? lateral > 0 ? -1 : 1 : (
1130
+ // Nothing to steer around, so sweep over the top: a hand arcs above
1131
+ // the straight line and drops onto the target, and a coin flip here
1132
+ // used to mirror the whole gesture from one redraw to the next.
1133
+ bowOverTheTop(perpendicular)
1134
+ );
806
1135
  const bow = Math.min(distance * (0.16 + random() * 0.08), 30) * sign;
807
1136
  const c1x = start.x + dx * 0.28 + perpendicular.x * bow;
808
1137
  const c1y = start.y + dy * 0.28 + perpendicular.y * bow;
@@ -817,34 +1146,46 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
817
1146
  { x: c2x, y: c2y },
818
1147
  end,
819
1148
  radius,
820
- random
1149
+ random,
1150
+ -sign
821
1151
  );
822
1152
  shaft = catmullRomPath(points, false);
823
1153
  hull.push(...points);
824
1154
  const last = points[points.length - 1];
825
1155
  const prior = points[points.length - 2] ?? start;
826
1156
  tipAngle = Math.atan2(last.y - prior.y, last.x - prior.x);
827
- appendPath(svg, shaft, { ...style, dashed });
828
- drawArrowHead(svg, end, tipAngle, style, random, distance);
829
- return { start, end, bounds: boundsOf(hull) };
1157
+ } else {
1158
+ shaft = `M ${fmt(start.x)} ${fmt(start.y)} C ${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(end.x)} ${fmt(end.y)}`;
1159
+ if (style.roughness >= 1.1) {
1160
+ const drift = () => (random() - 0.5) * 1.4;
1161
+ 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)}`;
1162
+ }
1163
+ tipAngle = Math.atan2(end.y - c2y, end.x - c2x);
830
1164
  }
831
- shaft = `M ${fmt(start.x)} ${fmt(start.y)} C ${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(end.x)} ${fmt(end.y)}`;
832
- if (style.roughness >= 1.1) {
833
- const drift = () => (random() - 0.5) * 1.4;
834
- 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)}`;
835
- appendPath(svg, ghost, {
1165
+ }
1166
+ const group = document.createElementNS(SVG_NS, "g");
1167
+ svg.appendChild(group);
1168
+ const shaftPaths = [];
1169
+ if (ghost) {
1170
+ shaftPaths.push(
1171
+ appendPath(group, ghost, {
836
1172
  ...style,
837
1173
  opacity: style.opacity * 0.3,
838
1174
  strokeWidth: style.strokeWidth * 0.7,
839
1175
  dashed
840
- });
841
- }
842
- tipAngle = Math.atan2(end.y - c2y, end.x - c2x);
1176
+ })
1177
+ );
843
1178
  }
844
- appendPath(svg, shaft, { ...style, dashed });
845
- drawArrowHead(svg, end, tipAngle, style, random, distance);
1179
+ shaftPaths.push(appendPath(group, shaft, { ...style, dashed }));
1180
+ const head = drawArrowHead(group, end, tipAngle, style, random, distance);
1181
+ animateArrow(
1182
+ group,
1183
+ { shaft: shaftPaths, head, start, end },
1184
+ arrowOptions.animate,
1185
+ context.elapsed
1186
+ );
846
1187
  const bounds = boundsOf(hull);
847
- return { start, end, bounds };
1188
+ return { start, end, bounds, group };
848
1189
  }
849
1190
  function drawUnderline(svg, x, y, width, style, random) {
850
1191
  if (width <= 4) return;
@@ -911,6 +1252,7 @@ function createNoteText(svg, text, style, options = {}) {
911
1252
  const fontSize = style.fontSize ?? NOTE_FONT_SIZE;
912
1253
  const lines = wrapWords(text, options.wordsPerLine ?? DEFAULT_WORDS_PER_LINE);
913
1254
  const lineHeight = fontSize * 1.05;
1255
+ const rtl = options.rtl ?? isRtlText(text);
914
1256
  const group = document.createElementNS(SVG_NS, "g");
915
1257
  const textEl = document.createElementNS(SVG_NS, "text");
916
1258
  textEl.setAttribute("x", "0");
@@ -923,6 +1265,13 @@ function createNoteText(svg, text, style, options = {}) {
923
1265
  textEl.setAttribute("letter-spacing", "0.4");
924
1266
  textEl.setAttribute("text-anchor", "start");
925
1267
  textEl.setAttribute("dominant-baseline", "auto");
1268
+ if (rtl) {
1269
+ textEl.setAttribute("direction", "rtl");
1270
+ textEl.setAttribute("unicode-bidi", "embed");
1271
+ }
1272
+ if (options.locale) {
1273
+ textEl.setAttributeNS(XML_NS, "xml:lang", options.locale);
1274
+ }
926
1275
  const tspans = lines.map((line, index) => {
927
1276
  const tspan = document.createElementNS(SVG_NS, "tspan");
928
1277
  tspan.textContent = line;
@@ -951,15 +1300,17 @@ function createNoteText(svg, text, style, options = {}) {
951
1300
  height,
952
1301
  ascent,
953
1302
  descent,
954
- fontSize
1303
+ fontSize,
1304
+ rtl
955
1305
  };
956
1306
  }
957
1307
  function placeNoteText(note, box, style, seed, options = {}) {
958
1308
  const random = createRandom(seed + 55);
959
1309
  const tilt = options.tilt ?? (random() - 0.5) * 7;
960
- note.textEl.setAttribute("x", fmt(box.x));
1310
+ const anchorX = note.rtl ? box.x + box.width : box.x;
1311
+ note.textEl.setAttribute("x", fmt(anchorX));
961
1312
  note.textEl.setAttribute("y", fmt(box.y + note.ascent));
962
- for (const tspan of note.tspans) tspan.setAttribute("x", fmt(box.x));
1313
+ for (const tspan of note.tspans) tspan.setAttribute("x", fmt(anchorX));
963
1314
  const cx = box.x + box.width / 2;
964
1315
  const cy = box.y + box.height / 2;
965
1316
  note.group.setAttribute(
@@ -968,9 +1319,10 @@ function placeNoteText(note, box, style, seed, options = {}) {
968
1319
  );
969
1320
  if (options.underline !== false) {
970
1321
  const lastWidth = note.lineWidths[note.lineWidths.length - 1] ?? box.width;
1322
+ const underlineX = note.rtl ? box.x + box.width - lastWidth + 1 : box.x + 1;
971
1323
  drawUnderline(
972
1324
  note.group,
973
- box.x + 1,
1325
+ underlineX,
974
1326
  box.y + box.height + 2,
975
1327
  lastWidth - 2,
976
1328
  {
@@ -991,7 +1343,7 @@ function createOverlaySvg(left, top, width, height) {
991
1343
  svg.setAttribute("width", String(width));
992
1344
  svg.setAttribute("height", String(height));
993
1345
  svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
994
- svg.style.position = "fixed";
1346
+ svg.style.position = "absolute";
995
1347
  svg.style.left = `${left}px`;
996
1348
  svg.style.top = `${top}px`;
997
1349
  svg.style.width = `${width}px`;
@@ -1044,12 +1396,17 @@ function nudgeIntoBand(box, guard, band) {
1044
1396
  return { ...box, x: box.x + dx };
1045
1397
  }
1046
1398
  function renderAnnotation(svg, rect, noteOptions, style, seed = 1, layout = {}) {
1047
- if (!noteOptions?.text) return null;
1399
+ const { text, locale } = resolveLocalizedText(
1400
+ noteOptions?.text,
1401
+ noteOptions?.locale
1402
+ );
1403
+ if (!text) return null;
1048
1404
  const band = layout.band ?? null;
1049
1405
  const position = isValidPosition(noteOptions.position) ? noteOptions.position : "top-right";
1050
1406
  const random = createRandom(seed + 9);
1051
- const note = createNoteText(svg, noteOptions.text, style, {
1052
- wordsPerLine: noteOptions.wordsPerLine
1407
+ const note = createNoteText(svg, text, style, {
1408
+ wordsPerLine: noteOptions.wordsPerLine,
1409
+ locale
1053
1410
  });
1054
1411
  const tilt = (random() - 0.45) * 7;
1055
1412
  const tiltPad = Math.abs(Math.sin(tilt * Math.PI / 180)) * note.width * 0.5;
@@ -1091,6 +1448,25 @@ function drawDot(parent, x, y, style, scale = 1.7) {
1091
1448
  strokeWidth: style.strokeWidth * scale
1092
1449
  });
1093
1450
  }
1451
+ function drawFleck(parent, x, y, radius, style, random) {
1452
+ const count = 6;
1453
+ const points = [];
1454
+ for (let i = 0; i < count; i++) {
1455
+ const angle = Math.PI * 2 * i / count + (random() - 0.5) * 0.5;
1456
+ const r = radius * (0.78 + random() * 0.34);
1457
+ points.push({
1458
+ x: x + Math.cos(angle) * r,
1459
+ y: y + Math.sin(angle) * r
1460
+ });
1461
+ }
1462
+ const blob = appendPath(parent, catmullRomPath(points, true), {
1463
+ ...style,
1464
+ fill: style.color,
1465
+ strokeWidth: 0
1466
+ });
1467
+ blob.style.filter = `blur(${(radius * 0.35).toFixed(2)}px)`;
1468
+ return blob;
1469
+ }
1094
1470
  function drawHeart(parent, size, style, random) {
1095
1471
  const s = size;
1096
1472
  const j = () => (random() - 0.5) * s * 0.12;
@@ -1114,26 +1490,61 @@ function twinkle(parent, radiusX, radiusY, pinch, style) {
1114
1490
  `Q ${(-c).toFixed(2)} ${(-c).toFixed(2)} 0 ${(-ry).toFixed(2)}`,
1115
1491
  "Z"
1116
1492
  ].join(" ");
1117
- appendPath(parent, path, style);
1493
+ return appendPath(parent, path, style);
1118
1494
  }
1119
1495
  function drawSparkle(parent, size, style, random) {
1120
1496
  twinkle(
1121
1497
  parent,
1122
- size * (0.34 + random() * 0.1),
1498
+ size * (0.62 + random() * 0.12),
1123
1499
  size,
1124
- size * (0.06 + random() * 0.06),
1500
+ size * (0.14 + random() * 0.08),
1125
1501
  style
1126
1502
  );
1127
1503
  }
1128
1504
  function drawStar(parent, size, style, random) {
1129
1505
  twinkle(
1130
1506
  parent,
1131
- size * (0.66 + random() * 0.12),
1132
- size,
1133
- size * (0.2 + random() * 0.08),
1507
+ size * (0.62 + random() * 0.12),
1508
+ size * (0.8 + random() * 0.12),
1509
+ size * (0.14 + random() * 0.08),
1134
1510
  style
1135
1511
  );
1136
1512
  }
1513
+ function drawGlimmer(parent, size, style, random) {
1514
+ appendGlow(parent, size * (1.2 + random() * 0.2), {
1515
+ color: style.color,
1516
+ opacity: style.opacity * 0.22
1517
+ });
1518
+ const armX = size * (0.5 + random() * 0.08);
1519
+ const armY = size * (0.45 + random() * 0.08);
1520
+ const bluntness = size * 0.19;
1521
+ const main = twinkle(parent, armX, armY, size * (0.08 + random() * 0.03), {
1522
+ ...style,
1523
+ fill: style.color,
1524
+ strokeWidth: bluntness
1525
+ });
1526
+ main.setAttribute("stroke-linejoin", "round");
1527
+ main.setAttribute("stroke-linecap", "round");
1528
+ main.style.filter = `blur(${(size * 0.05).toFixed(2)}px)`;
1529
+ const gap = bluntness / 2 + size * 0.2;
1530
+ const corners = [
1531
+ { x: 0, y: -(armY + gap) },
1532
+ { x: armX + gap, y: 0 },
1533
+ { x: 0, y: armY + gap },
1534
+ { x: -(armX + gap), y: 0 }
1535
+ ];
1536
+ for (const { x, y } of corners) {
1537
+ const jitter = 0.95 + random() * 0.1;
1538
+ drawFleck(
1539
+ parent,
1540
+ x * jitter,
1541
+ y * jitter,
1542
+ size * (0.1 + random() * 0.03),
1543
+ style,
1544
+ random
1545
+ );
1546
+ }
1547
+ }
1137
1548
  function drawTwinkle(parent, size, style, random) {
1138
1549
  const count = 5;
1139
1550
  const first = -Math.PI / 2 + (random() - 0.5) * 0.5;
@@ -1241,46 +1652,15 @@ function drawEmphasis(parent, size, style, random, opts = {}) {
1241
1652
  );
1242
1653
  }
1243
1654
  }
1244
- function drawDots(parent, size, style, random) {
1245
- let x = -size * 0.7;
1246
- for (let i = 0; i < 3; i++) {
1247
- drawDot(parent, x, (random() - 0.5) * size * 0.16, style);
1248
- x += size * (0.6 + random() * 0.25);
1249
- }
1250
- }
1251
- function drawStroke(parent, size, style, random) {
1252
- appendPath(
1253
- parent,
1254
- roughLine(-size, 0, size, 0, 1.2, random, {
1255
- bow: (random() - 0.5) * size * 0.35,
1256
- steps: 4
1257
- }),
1258
- style
1259
- );
1260
- }
1261
- function drawSteam(parent, size, style, random) {
1262
- const lean = random() < 0.5 ? -1 : 1;
1263
- for (let i = 0; i < 2; i++) {
1264
- const x = (i - 0.5) * size * 0.72;
1265
- const base = size * 0.62 - i * size * 0.14;
1266
- appendPath(
1267
- parent,
1268
- `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)}`,
1269
- style
1270
- );
1271
- }
1272
- }
1273
1655
  var DECORATION_DRAWERS = {
1274
1656
  heart: drawHeart,
1275
1657
  sparkle: drawSparkle,
1276
1658
  star: drawStar,
1277
1659
  twinkle: drawTwinkle,
1660
+ glimmer: drawGlimmer,
1278
1661
  smiley: drawSmiley,
1279
1662
  emphasis: drawEmphasis,
1280
- arcs: drawArcs,
1281
- dots: drawDots,
1282
- stroke: drawStroke,
1283
- steam: drawSteam
1663
+ arcs: drawArcs
1284
1664
  };
1285
1665
  var TEXT_TYPES = ["heart", "sparkle"];
1286
1666
  var CORNER_ACCENTS = ["arcs", "emphasis"];
@@ -1291,12 +1671,10 @@ var TYPE_TRAITS = {
1291
1671
  sparkle: { size: [6, 9], tilt: 20, standoff: 22 },
1292
1672
  star: { size: [8, 11], tilt: 25, standoff: 26 },
1293
1673
  twinkle: { size: [5, 7.5], tilt: 30, standoff: 26, brightness: 1.4 },
1674
+ glimmer: { size: [7, 10], tilt: 20, standoff: 28, brightness: 1.3 },
1294
1675
  smiley: { size: [8, 11], tilt: 10, standoff: 22 },
1295
1676
  emphasis: { size: [12, 17], tilt: 10, standoff: 13, outward: true },
1296
- arcs: { size: [11, 16], tilt: 12, standoff: 12, outward: true },
1297
- dots: { size: [6, 9], tilt: 8, standoff: 20 },
1298
- stroke: { size: [8, 12], tilt: 40, standoff: 20 },
1299
- steam: { size: [8, 11], tilt: 10, standoff: 20 }
1677
+ arcs: { size: [11, 16], tilt: 12, standoff: 12, outward: true }
1300
1678
  };
1301
1679
  var DEFAULT_TYPES = [
1302
1680
  "twinkle",
@@ -1499,6 +1877,9 @@ function ensureHandwrittenFont() {
1499
1877
 
1500
1878
  // src/doodle.js
1501
1879
  var instances = /* @__PURE__ */ new WeakMap();
1880
+ function now() {
1881
+ return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1882
+ }
1502
1883
  function localBand(overlayRect, inset = 8) {
1503
1884
  const width = window.innerWidth || document.documentElement.clientWidth || 0;
1504
1885
  return {
@@ -1515,19 +1896,21 @@ var DoodleOverlay = class {
1515
1896
  this.element = element;
1516
1897
  this.options = options;
1517
1898
  this.seed = Math.floor(Math.random() * 1e9);
1899
+ this.startedAt = now();
1518
1900
  this.svg = null;
1901
+ this.wrapper = null;
1519
1902
  this.resizeObserver = null;
1520
1903
  this.mutationObserver = null;
1521
1904
  this.pendingFrame = null;
1522
- this.scrollTargets = [];
1523
- this.onScroll = this.scheduleUpdate.bind(this);
1905
+ this.listeners = [];
1524
1906
  this.onResize = this.scheduleUpdate.bind(this);
1907
+ this.onLanguageChange = this.scheduleUpdate.bind(this);
1525
1908
  this.childElements = [];
1526
1909
  this.mount();
1527
1910
  }
1528
1911
  mount() {
1529
1912
  this.svg = createOverlaySvg(0, 0, 0, 0);
1530
- insertOverlaySvg(this.svg, this.element);
1913
+ this.wrapper = insertOverlaySvg(this.svg, this.element);
1531
1914
  syncOverlayStacking(this.svg, this.element, this.options.zIndex);
1532
1915
  this.resizeObserver = new ResizeObserver(this.onResize);
1533
1916
  this.resizeObserver.observe(this.element);
@@ -1540,12 +1923,18 @@ var DoodleOverlay = class {
1540
1923
  subtree: true,
1541
1924
  attributes: true
1542
1925
  });
1543
- for (const target of getScrollableAncestors(this.element)) {
1544
- target.addEventListener("scroll", this.onScroll, { passive: true });
1545
- this.scrollTargets.push({ target, type: "scroll" });
1546
- }
1547
1926
  window.addEventListener("resize", this.onResize, { passive: true });
1548
- this.scrollTargets.push({ target: window, type: "resize" });
1927
+ this.listeners.push({
1928
+ target: window,
1929
+ type: "resize",
1930
+ handler: this.onResize
1931
+ });
1932
+ window.addEventListener("languagechange", this.onLanguageChange);
1933
+ this.listeners.push({
1934
+ target: window,
1935
+ type: "languagechange",
1936
+ handler: this.onLanguageChange
1937
+ });
1549
1938
  if (this.options.note && this.options.autoLoadFont && !this.options.fontFamily) {
1550
1939
  ensureHandwrittenFont();
1551
1940
  }
@@ -1581,17 +1970,19 @@ var DoodleOverlay = class {
1581
1970
  const { padding, radius, color, strokeWidth, roughness, opacity } = this.options;
1582
1971
  const targets = this.getTargets();
1583
1972
  const margin = this.overlayMargin();
1973
+ const elapsed = now() - this.startedAt;
1584
1974
  const absoluteRects = targets.map(
1585
1975
  (target) => getElementBounds(target, padding, radius)
1586
1976
  );
1587
1977
  const overlayRect = unionRects(absoluteRects, margin);
1588
1978
  clearSvg(this.svg);
1589
1979
  syncOverlayStacking(this.svg, this.element, this.options.zIndex);
1980
+ const origin = this.wrapper ? this.wrapper.getBoundingClientRect() : { left: 0, top: 0 };
1590
1981
  this.svg.setAttribute("width", String(overlayRect.width));
1591
1982
  this.svg.setAttribute("height", String(overlayRect.height));
1592
1983
  this.svg.setAttribute("viewBox", `0 0 ${overlayRect.width} ${overlayRect.height}`);
1593
- this.svg.style.left = `${overlayRect.x}px`;
1594
- this.svg.style.top = `${overlayRect.y}px`;
1984
+ this.svg.style.left = `${overlayRect.x - origin.left}px`;
1985
+ this.svg.style.top = `${overlayRect.y - origin.top}px`;
1595
1986
  this.svg.style.width = `${overlayRect.width}px`;
1596
1987
  this.svg.style.height = `${overlayRect.height}px`;
1597
1988
  const strokeStyle = { color, strokeWidth, roughness, opacity };
@@ -1633,7 +2024,8 @@ var DoodleOverlay = class {
1633
2024
  this.options.arrow,
1634
2025
  strokeStyle,
1635
2026
  seed,
1636
- note ? note.box : null
2027
+ note ? note.box : null,
2028
+ { elapsed }
1637
2029
  ) : null;
1638
2030
  if (this.options.decorations) {
1639
2031
  renderDecorations(
@@ -1660,7 +2052,7 @@ var DoodleOverlay = class {
1660
2052
  overlayMargin() {
1661
2053
  const { note, arrow, decorations } = this.options;
1662
2054
  if (!note && !arrow && !decorations) return 48;
1663
- const text = typeof note?.text === "string" ? note.text : "";
2055
+ const { text } = resolveLocalizedText(note?.text, note?.locale);
1664
2056
  return Math.max(96, Math.min(240, 80 + text.length * 8));
1665
2057
  }
1666
2058
  destroy() {
@@ -1680,14 +2072,15 @@ var DoodleOverlay = class {
1680
2072
  document.fonts?.removeEventListener?.("loadingdone", this.onFontsLoaded);
1681
2073
  this.onFontsLoaded = null;
1682
2074
  }
1683
- for (const { target, type } of this.scrollTargets) {
1684
- target.removeEventListener(type, type === "scroll" ? this.onScroll : this.onResize);
2075
+ for (const { target, type, handler } of this.listeners) {
2076
+ target.removeEventListener(type, handler);
1685
2077
  }
1686
- this.scrollTargets = [];
1687
- if (this.svg?.parentNode) {
1688
- this.svg.parentNode.removeChild(this.svg);
2078
+ this.listeners = [];
2079
+ if (this.svg) {
2080
+ removeOverlaySvg(this.svg, this.wrapper, this.element);
1689
2081
  }
1690
2082
  this.svg = null;
2083
+ this.wrapper = null;
1691
2084
  instances.delete(this.element);
1692
2085
  }
1693
2086
  };