@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.cjs CHANGED
@@ -28,6 +28,265 @@ __export(react_exports, {
28
28
  module.exports = __toCommonJS(react_exports);
29
29
  var import_react = require("react");
30
30
 
31
+ // src/animation.js
32
+ var STYLE_MARKER = "data-freehand-animation";
33
+ var CSS = `
34
+ @keyframes freehand-arrow-dash {
35
+ from { stroke-dashoffset: var(--freehand-dash, 0px); }
36
+ to { stroke-dashoffset: 0; }
37
+ }
38
+ @keyframes freehand-arrow-drift {
39
+ 0%, 100% { transform: translate(0px, 0px); }
40
+ 50% { transform: translate(var(--freehand-drift-x, 0px), var(--freehand-drift-y, 0px)); }
41
+ }
42
+ @keyframes freehand-arrow-settle {
43
+ from { transform: translate(0px, 0px); }
44
+ to { transform: translate(var(--freehand-drift-x, 0px), var(--freehand-drift-y, 0px)); }
45
+ }
46
+ @media (prefers-reduced-motion: reduce) {
47
+ .freehand-arrow, .freehand-arrow path { animation: none !important; }
48
+ .freehand-arrow path { stroke-dashoffset: 0 !important; }
49
+ }
50
+ `;
51
+ var ARROW_ANIMATION_DEFAULTS = {
52
+ /** Reveal the stroke with a dash sweep. */
53
+ draw: true,
54
+ /** Lean the arrow toward its tip. */
55
+ drift: true,
56
+ /** Multiplier over every duration - 2 plays twice as fast. */
57
+ speed: 1,
58
+ /** How long the shaft takes to draw, ms, before `speed`. */
59
+ duration: 900,
60
+ /** Wait before the first stroke appears, ms. */
61
+ delay: 150,
62
+ /** How far the arrow leans, px. */
63
+ distance: 5,
64
+ /** One full lean-and-return, ms. */
65
+ driftDuration: 2200,
66
+ /** Keep leaning. `false` settles once and stays put. */
67
+ repeat: true
68
+ };
69
+ function positive(value, fallback) {
70
+ const n = Number(value);
71
+ return Number.isFinite(n) && n > 0 ? n : fallback;
72
+ }
73
+ function atLeastZero(value, fallback) {
74
+ const n = Number(value);
75
+ return Number.isFinite(n) && n >= 0 ? n : fallback;
76
+ }
77
+ function normalizeArrowAnimation(config) {
78
+ if (config === false || config === null) return false;
79
+ const settings = config === true || config === void 0 || typeof config !== "object" ? {} : config;
80
+ const animation = {
81
+ draw: settings.draw !== false,
82
+ drift: settings.drift !== false,
83
+ speed: positive(settings.speed, ARROW_ANIMATION_DEFAULTS.speed),
84
+ duration: positive(settings.duration, ARROW_ANIMATION_DEFAULTS.duration),
85
+ delay: atLeastZero(settings.delay, ARROW_ANIMATION_DEFAULTS.delay),
86
+ distance: atLeastZero(settings.distance, ARROW_ANIMATION_DEFAULTS.distance),
87
+ driftDuration: positive(
88
+ settings.driftDuration,
89
+ ARROW_ANIMATION_DEFAULTS.driftDuration
90
+ ),
91
+ repeat: settings.repeat !== false
92
+ };
93
+ if (!animation.draw && (!animation.drift || !animation.distance)) return false;
94
+ return animation;
95
+ }
96
+ var FLOW_PERIOD = 260;
97
+ function arrowTiming(animation) {
98
+ const duration = animation.duration / animation.speed;
99
+ const delay = animation.delay / animation.speed;
100
+ const drawn = animation.draw ? duration : 0;
101
+ return {
102
+ shaft: { duration, delay },
103
+ head: { duration: duration * 0.22, delay: delay + drawn * 0.82 },
104
+ flow: { duration: FLOW_PERIOD / animation.speed, delay },
105
+ drift: {
106
+ duration: animation.driftDuration / animation.speed,
107
+ delay: delay + drawn * 1.04
108
+ }
109
+ };
110
+ }
111
+ var injected = false;
112
+ function ensureArrowAnimationStyles() {
113
+ if (injected) return;
114
+ if (typeof document === "undefined" || !document.head) return;
115
+ injected = true;
116
+ if (document.querySelector(`style[${STYLE_MARKER}]`)) return;
117
+ const style = document.createElement("style");
118
+ style.setAttribute(STYLE_MARKER, "");
119
+ style.textContent = CSS;
120
+ document.head.appendChild(style);
121
+ }
122
+ function time(ms) {
123
+ return `${Math.round(ms)}ms`;
124
+ }
125
+ function setAnimation(element, spec) {
126
+ const parts = [
127
+ spec.name,
128
+ time(spec.duration),
129
+ spec.easing,
130
+ time(spec.delay),
131
+ spec.iterations ?? "1"
132
+ ];
133
+ if (spec.fill) parts.push(spec.fill);
134
+ element.style.animation = parts.join(" ");
135
+ }
136
+ function pathLength(path) {
137
+ try {
138
+ const length = path.getTotalLength();
139
+ return Number.isFinite(length) && length > 0 ? length : 0;
140
+ } catch {
141
+ return 0;
142
+ }
143
+ }
144
+ function dashPeriod(path) {
145
+ const pattern = (path.getAttribute("stroke-dasharray") || "").split(/[\s,]+/).map(Number).filter((n) => Number.isFinite(n) && n > 0);
146
+ if (!pattern.length) return 0;
147
+ const sum = pattern.reduce((total, n) => total + n, 0);
148
+ return pattern.length % 2 === 0 ? sum : sum * 2;
149
+ }
150
+ function applyDash(path, timing, elapsed, flow) {
151
+ const length = pathLength(path);
152
+ if (!length) return;
153
+ const period = dashPeriod(path);
154
+ if (period) {
155
+ path.style.setProperty("--freehand-dash", `${period.toFixed(2)}px`);
156
+ setAnimation(path, {
157
+ name: "freehand-arrow-dash",
158
+ duration: flow.duration,
159
+ easing: "linear",
160
+ delay: flow.delay - elapsed,
161
+ iterations: "infinite"
162
+ });
163
+ return;
164
+ }
165
+ path.style.setProperty("--freehand-dash", `${length.toFixed(2)}px`);
166
+ path.style.strokeDasharray = `${length.toFixed(2)}px`;
167
+ setAnimation(path, {
168
+ name: "freehand-arrow-dash",
169
+ duration: timing.duration,
170
+ easing: "ease-out",
171
+ delay: timing.delay - elapsed,
172
+ fill: "both"
173
+ });
174
+ }
175
+ function applyDrift(group, parts, timing, elapsed, animation) {
176
+ const dx = parts.end.x - parts.start.x;
177
+ const dy = parts.end.y - parts.start.y;
178
+ const length = Math.hypot(dx, dy);
179
+ if (!length) return;
180
+ const scale = animation.distance / length;
181
+ group.style.setProperty("--freehand-drift-x", `${(dx * scale).toFixed(2)}px`);
182
+ group.style.setProperty("--freehand-drift-y", `${(dy * scale).toFixed(2)}px`);
183
+ setAnimation(
184
+ group,
185
+ animation.repeat ? {
186
+ name: "freehand-arrow-drift",
187
+ duration: timing.duration,
188
+ easing: "ease-in-out",
189
+ delay: timing.delay - elapsed,
190
+ iterations: "infinite"
191
+ } : {
192
+ // A single lean is a settle, not a round trip, so it takes half a cycle
193
+ name: "freehand-arrow-settle",
194
+ duration: timing.duration * 0.5,
195
+ easing: "ease-out",
196
+ delay: timing.delay - elapsed,
197
+ fill: "both"
198
+ }
199
+ );
200
+ }
201
+ function animateArrow(group, parts, animation, elapsed = 0) {
202
+ if (!animation || !group) return;
203
+ ensureArrowAnimationStyles();
204
+ group.setAttribute("class", "freehand-arrow");
205
+ const timing = arrowTiming(animation);
206
+ const age = Number.isFinite(elapsed) ? Math.max(0, elapsed) : 0;
207
+ if (animation.draw) {
208
+ for (const path of parts.shaft) {
209
+ applyDash(path, timing.shaft, age, timing.flow);
210
+ }
211
+ for (const path of parts.head) applyDash(path, timing.head, age, timing.flow);
212
+ }
213
+ if (animation.drift && animation.distance > 0) {
214
+ applyDrift(group, parts, timing.drift, age, animation);
215
+ }
216
+ }
217
+
218
+ // src/locale.js
219
+ var DEFAULT_KEY = "default";
220
+ var LANGUAGE_TAG = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i;
221
+ var RTL_STRONG = /[֑-߿יִ-﷿ﹰ-ﻼ]/;
222
+ var LTR_STRONG = /[A-Za-zÀ-֏ऀ-῿Ⰰ-퟿豈-ﬗA-Za-z]/;
223
+ function normalizeTag(tag) {
224
+ if (typeof tag !== "string") return "";
225
+ return tag.trim().replace(/_/g, "-").toLowerCase();
226
+ }
227
+ function isLocaleMap(value) {
228
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
229
+ const entries = Object.entries(value);
230
+ if (!entries.length) return false;
231
+ return entries.every(([key, text]) => {
232
+ if (typeof text !== "string") return false;
233
+ const tag = normalizeTag(key);
234
+ return tag === DEFAULT_KEY || LANGUAGE_TAG.test(tag);
235
+ });
236
+ }
237
+ function preferredLocales(override) {
238
+ const tags = [];
239
+ const add = (value) => {
240
+ const tag = normalizeTag(value);
241
+ if (tag && !tags.includes(tag)) tags.push(tag);
242
+ };
243
+ if (Array.isArray(override)) override.forEach(add);
244
+ else add(override);
245
+ const nav = typeof navigator === "undefined" ? null : navigator;
246
+ if (nav) {
247
+ if (Array.isArray(nav.languages)) nav.languages.forEach(add);
248
+ add(nav.language);
249
+ }
250
+ if (typeof document !== "undefined") add(document.documentElement?.lang);
251
+ return tags;
252
+ }
253
+ function matchLocale(tags, preferred) {
254
+ const available = tags.map(normalizeTag);
255
+ for (const want of preferred) {
256
+ let tag = want;
257
+ while (tag) {
258
+ const exact = available.indexOf(tag);
259
+ if (exact !== -1) return exact;
260
+ const cut = tag.lastIndexOf("-");
261
+ tag = cut > 0 ? tag.slice(0, cut) : "";
262
+ }
263
+ const base = want.split("-")[0];
264
+ const sibling = available.findIndex((key) => key.split("-")[0] === base);
265
+ if (sibling !== -1) return sibling;
266
+ }
267
+ return -1;
268
+ }
269
+ function resolveLocalizedText(text, locale = null) {
270
+ if (typeof text === "string") return { text, locale: null };
271
+ if (!isLocaleMap(text)) return { text: "", locale: null };
272
+ const keys = Object.keys(text);
273
+ const tags = keys.filter((key) => normalizeTag(key) !== DEFAULT_KEY);
274
+ const index = matchLocale(tags, preferredLocales(locale));
275
+ if (index !== -1) {
276
+ return { text: text[tags[index]], locale: normalizeTag(tags[index]) };
277
+ }
278
+ const fallback = keys.find((key) => normalizeTag(key) === DEFAULT_KEY);
279
+ if (fallback) return { text: text[fallback], locale: null };
280
+ return { text: text[keys[0]], locale: normalizeTag(keys[0]) };
281
+ }
282
+ function isRtlText(text) {
283
+ if (typeof text !== "string") return false;
284
+ const rtl = text.search(RTL_STRONG);
285
+ if (rtl === -1) return false;
286
+ const ltr = text.search(LTR_STRONG);
287
+ return ltr === -1 || rtl < ltr;
288
+ }
289
+
31
290
  // src/utils.js
32
291
  var DEFAULT_OPTIONS = {
33
292
  border: true,
@@ -47,6 +306,11 @@ var DEFAULT_OPTIONS = {
47
306
  fontFamily: null,
48
307
  /** Fetch Caveat when the page has not provided it. */
49
308
  autoLoadFont: true,
309
+ /**
310
+ * Language for a note written in more than one. Omit to follow the visitor's
311
+ * own languages, which is what their operating system is set to.
312
+ */
313
+ locale: null,
50
314
  /**
51
315
  * Overlay stacking order. Omit to mirror the target element's z-index so
52
316
  * doodles stay above the annotated element but below modals and other UI.
@@ -65,8 +329,15 @@ function resolveElement(target) {
65
329
  }
66
330
  function mergeOptions(options = {}) {
67
331
  const merged = { ...DEFAULT_OPTIONS, ...options };
68
- if (merged.note && typeof merged.note === "string") {
69
- merged.note = { text: merged.note, position: "top-right" };
332
+ if (merged.note) {
333
+ const config = typeof merged.note === "string" || isLocaleMap(merged.note) ? { text: merged.note } : merged.note;
334
+ merged.note = {
335
+ ...config,
336
+ position: config.position ?? "top-right",
337
+ // The note carries the chosen language, so `renderAnnotation` can resolve
338
+ // a multilingual note on its own.
339
+ locale: config.locale ?? merged.locale ?? null
340
+ };
70
341
  }
71
342
  if (merged.arrow) {
72
343
  const config = merged.arrow === true ? {} : merged.arrow;
@@ -76,7 +347,10 @@ function mergeOptions(options = {}) {
76
347
  // explicit `from` is honoured whether or not there is a note.
77
348
  from: config.from ?? (merged.note ? "note" : "top-right"),
78
349
  to: config.to ?? "edge",
79
- style: config.style ?? "curved"
350
+ style: config.style ?? "curved",
351
+ // Arrows draw themselves on and lean toward their target by default;
352
+ // `animate: false` opts out, an object tunes speed and gestures
353
+ animate: normalizeArrowAnimation(config.animate)
80
354
  };
81
355
  }
82
356
  if (merged.decorations === true) {
@@ -130,20 +404,6 @@ function isValidPosition(position) {
130
404
  "center"
131
405
  ].includes(position);
132
406
  }
133
- function getScrollableAncestors(element) {
134
- const ancestors = [];
135
- let node = element.parentElement;
136
- while (node && node !== document.documentElement) {
137
- const style = getComputedStyle(node);
138
- const overflow = style.overflow + style.overflowY + style.overflowX;
139
- if (/(auto|scroll|overlay)/.test(overflow)) {
140
- ancestors.push(node);
141
- }
142
- node = node.parentElement;
143
- }
144
- ancestors.push(window);
145
- return ancestors;
146
- }
147
407
  function resolveOverlayZIndex(element, override) {
148
408
  if (override != null) return override;
149
409
  const { zIndex, position } = getComputedStyle(element);
@@ -162,13 +422,45 @@ function resolveOverlayZIndex(element, override) {
162
422
  }
163
423
  return null;
164
424
  }
425
+ var ITEM_PLACEMENT_PROPERTIES = [
426
+ "order",
427
+ "flexGrow",
428
+ "flexShrink",
429
+ "flexBasis",
430
+ "alignSelf",
431
+ "justifySelf",
432
+ "gridColumn",
433
+ "gridRow",
434
+ "gridArea"
435
+ ];
436
+ function isInlineDisplay(computed) {
437
+ return computed.display.startsWith("inline");
438
+ }
165
439
  function insertOverlaySvg(svg, element) {
166
440
  const parent = element.parentNode;
167
441
  if (!parent) {
168
442
  document.body.appendChild(svg);
169
- return;
443
+ return null;
444
+ }
445
+ const wrapper = document.createElement("div");
446
+ wrapper.style.position = "relative";
447
+ const computed = getComputedStyle(element);
448
+ wrapper.style.display = isInlineDisplay(computed) ? "inline-block" : "block";
449
+ for (const property of ITEM_PLACEMENT_PROPERTIES) {
450
+ const value = computed[property];
451
+ if (value) wrapper.style[property] = value;
452
+ }
453
+ parent.replaceChild(wrapper, element);
454
+ wrapper.appendChild(element);
455
+ wrapper.appendChild(svg);
456
+ return wrapper;
457
+ }
458
+ function removeOverlaySvg(svg, wrapper, element) {
459
+ if (svg.parentNode) {
460
+ svg.parentNode.removeChild(svg);
170
461
  }
171
- parent.insertBefore(svg, element.nextSibling);
462
+ if (!wrapper || !wrapper.parentNode) return;
463
+ wrapper.parentNode.replaceChild(element, wrapper);
172
464
  }
173
465
  function syncOverlayStacking(svg, element, zIndexOverride) {
174
466
  const zIndex = resolveOverlayZIndex(element, zIndexOverride);
@@ -325,6 +617,7 @@ function annotationAnchor(position, rect, offset = 12) {
325
617
 
326
618
  // src/renderer.js
327
619
  var SVG_NS = "http://www.w3.org/2000/svg";
620
+ var XML_NS = "http://www.w3.org/XML/1998/namespace";
328
621
  var HANDWRITTEN_FONT = '"Caveat", "Kalam", "Patrick Hand", "Bradley Hand", "Segoe Script", "Comic Sans MS", cursive';
329
622
  var NOTE_FONT_SIZE = 19;
330
623
  function clearSvg(svg) {
@@ -570,6 +863,15 @@ function appendPath(svg, d, style) {
570
863
  svg.appendChild(path);
571
864
  return path;
572
865
  }
866
+ function appendGlow(svg, radius, style) {
867
+ const circle = document.createElementNS(SVG_NS, "circle");
868
+ circle.setAttribute("r", fmt(radius));
869
+ circle.setAttribute("fill", style.color);
870
+ circle.setAttribute("opacity", String(style.opacity));
871
+ circle.style.filter = `blur(${fmt(radius * 0.4)}px)`;
872
+ svg.appendChild(circle);
873
+ return circle;
874
+ }
573
875
  function rectPerimeter(width, height, radius) {
574
876
  const r = clamp(radius, 0, Math.min(width, height) / 2);
575
877
  return 2 * (width - 2 * r) + 2 * (height - 2 * r) + 2 * Math.PI * r;
@@ -656,20 +958,21 @@ function cubicTangentAt(p0, c1, c2, p3, t) {
656
958
  y: a * (c1.y - p0.y) + b * (c2.y - c1.y) + c * (p3.y - c2.y)
657
959
  };
658
960
  }
659
- function loopedCurvePoints(start, c1, c2, end, radius, random) {
961
+ var STRADDLE = 0.75;
962
+ function loopedCurvePoints(start, c1, c2, end, radius, random, side = 1) {
660
963
  const at = (t) => cubicAt(start, c1, c2, end, t);
661
- const tangentAt = (t) => normalize(cubicTangentAt(start, c1, c2, end, t));
662
964
  const knot = 0.46 + random() * 0.12;
663
965
  const points = [];
664
966
  const lead = Math.max(2, Math.round(knot * 16));
665
967
  for (let i = 0; i <= lead; i++) points.push(at(knot * i / lead));
666
968
  const pivot = at(knot);
667
- const heading = tangentAt(knot);
969
+ const derivative = cubicTangentAt(start, c1, c2, end, knot);
970
+ const speed = Math.hypot(derivative.x, derivative.y) || 1;
971
+ const heading = normalize(derivative);
668
972
  const normal = { x: -heading.y, y: heading.x };
669
- const side = random() < 0.5 ? 1 : -1;
670
973
  const centre = {
671
- x: pivot.x + normal.x * radius * side,
672
- y: pivot.y + normal.y * radius * side
974
+ x: pivot.x + normal.x * radius * STRADDLE * side,
975
+ y: pivot.y + normal.y * radius * STRADDLE * side
673
976
  };
674
977
  const from = Math.atan2(pivot.y - centre.y, pivot.x - centre.x);
675
978
  const turns = Math.PI * 2 * (0.97 + random() * 0.08);
@@ -677,15 +980,27 @@ function loopedCurvePoints(start, c1, c2, end, radius, random) {
677
980
  for (let i = 1; i <= steps; i++) {
678
981
  const progress = i / steps;
679
982
  const angle = from + turns * progress * side;
983
+ const spread = radius * (STRADDLE + (1 - STRADDLE) * progress);
680
984
  const drift = radius * 0.55 * progress;
681
985
  points.push({
682
- x: centre.x + Math.cos(angle) * radius + heading.x * drift,
683
- y: centre.y + Math.sin(angle) * radius + heading.y * drift
986
+ x: centre.x + Math.cos(angle) * spread + heading.x * drift,
987
+ y: centre.y + Math.sin(angle) * spread + heading.y * drift
684
988
  });
685
989
  }
990
+ const exit = points[points.length - 1];
991
+ const gained = (exit.x - pivot.x) * heading.x + (exit.y - pivot.y) * heading.y;
992
+ const resume = Math.min(knot + Math.max(0, gained) / speed, 0.985);
993
+ const rejoin = at(resume);
994
+ const residual = (exit.x - rejoin.x) * normal.x + (exit.y - rejoin.y) * normal.y;
995
+ const settle = 6;
686
996
  const tail = 14;
687
997
  for (let i = 1; i <= tail; i++) {
688
- points.push(at(knot + (1 - knot) * (i / tail)));
998
+ const point = at(resume + (1 - resume) * (i / tail));
999
+ const fade = Math.max(0, 1 - i / settle);
1000
+ points.push({
1001
+ x: point.x + normal.x * residual * fade,
1002
+ y: point.y + normal.y * residual * fade
1003
+ });
689
1004
  }
690
1005
  return points;
691
1006
  }
@@ -759,21 +1074,29 @@ function arrowEndpoints(rect, arrowOptions, random, origin) {
759
1074
  function drawArrowHead(svg, tip, angle, style, random, shaftLength = Infinity) {
760
1075
  const length = Math.min(9 + style.strokeWidth * 1.4, shaftLength * 0.3);
761
1076
  const spread = 0.46 + random() * 0.1;
1077
+ const arms = [];
762
1078
  for (const side of [-1, 1]) {
763
1079
  const armAngle = angle + spread * side;
764
1080
  const tail = {
765
1081
  x: tip.x - length * Math.cos(armAngle),
766
1082
  y: tip.y - length * Math.sin(armAngle)
767
1083
  };
768
- appendPath(
769
- svg,
770
- roughLine(tail.x, tail.y, tip.x, tip.y, style.roughness * 0.5, random, {
771
- steps: 2,
772
- bow: 0.35 * side
773
- }),
774
- { ...style, dashed: false }
1084
+ arms.push(
1085
+ appendPath(
1086
+ svg,
1087
+ roughLine(tail.x, tail.y, tip.x, tip.y, style.roughness * 0.5, random, {
1088
+ steps: 2,
1089
+ bow: 0.35 * side
1090
+ }),
1091
+ { ...style, dashed: false }
1092
+ )
775
1093
  );
776
1094
  }
1095
+ return arms;
1096
+ }
1097
+ function bowOverTheTop(perpendicular) {
1098
+ if (Math.abs(perpendicular.y) < 0.02) return perpendicular.x > 0 ? 1 : -1;
1099
+ return perpendicular.y > 0 ? -1 : 1;
777
1100
  }
778
1101
  function boundsOf(points) {
779
1102
  const xs = points.map((p) => p.x);
@@ -785,7 +1108,7 @@ function boundsOf(points) {
785
1108
  height: Math.max(...ys) - Math.min(...ys)
786
1109
  };
787
1110
  }
788
- function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
1111
+ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null, context = {}) {
789
1112
  const random = createRandom(seed + 17);
790
1113
  const { start, end } = arrowEndpoints(rect, arrowOptions, random, origin);
791
1114
  const dx = end.x - start.x;
@@ -794,6 +1117,7 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
794
1117
  const dashed = arrowOptions.style === "dotted";
795
1118
  let tipAngle = Math.atan2(dy, dx);
796
1119
  let shaft;
1120
+ let ghost = null;
797
1121
  const hull = [start, end];
798
1122
  if (arrowOptions.style === "straight") {
799
1123
  shaft = roughLine(start.x, start.y, end.x, end.y, style.roughness, random, {
@@ -807,7 +1131,12 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
807
1131
  y: rect.y + rect.height / 2 - mid.y
808
1132
  };
809
1133
  const lateral = perpendicular.x * towardCenter.x + perpendicular.y * towardCenter.y;
810
- const sign = Math.abs(lateral) > distance * 0.12 ? lateral > 0 ? -1 : 1 : random() < 0.5 ? -1 : 1;
1134
+ const sign = Math.abs(lateral) > distance * 0.12 ? lateral > 0 ? -1 : 1 : (
1135
+ // Nothing to steer around, so sweep over the top: a hand arcs above
1136
+ // the straight line and drops onto the target, and a coin flip here
1137
+ // used to mirror the whole gesture from one redraw to the next.
1138
+ bowOverTheTop(perpendicular)
1139
+ );
811
1140
  const bow = Math.min(distance * (0.16 + random() * 0.08), 30) * sign;
812
1141
  const c1x = start.x + dx * 0.28 + perpendicular.x * bow;
813
1142
  const c1y = start.y + dy * 0.28 + perpendicular.y * bow;
@@ -822,34 +1151,46 @@ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
822
1151
  { x: c2x, y: c2y },
823
1152
  end,
824
1153
  radius,
825
- random
1154
+ random,
1155
+ -sign
826
1156
  );
827
1157
  shaft = catmullRomPath(points, false);
828
1158
  hull.push(...points);
829
1159
  const last = points[points.length - 1];
830
1160
  const prior = points[points.length - 2] ?? start;
831
1161
  tipAngle = Math.atan2(last.y - prior.y, last.x - prior.x);
832
- appendPath(svg, shaft, { ...style, dashed });
833
- drawArrowHead(svg, end, tipAngle, style, random, distance);
834
- return { start, end, bounds: boundsOf(hull) };
1162
+ } else {
1163
+ shaft = `M ${fmt(start.x)} ${fmt(start.y)} C ${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(end.x)} ${fmt(end.y)}`;
1164
+ if (style.roughness >= 1.1) {
1165
+ const drift = () => (random() - 0.5) * 1.4;
1166
+ 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)}`;
1167
+ }
1168
+ tipAngle = Math.atan2(end.y - c2y, end.x - c2x);
835
1169
  }
836
- shaft = `M ${fmt(start.x)} ${fmt(start.y)} C ${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(end.x)} ${fmt(end.y)}`;
837
- if (style.roughness >= 1.1) {
838
- const drift = () => (random() - 0.5) * 1.4;
839
- 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)}`;
840
- appendPath(svg, ghost, {
1170
+ }
1171
+ const group = document.createElementNS(SVG_NS, "g");
1172
+ svg.appendChild(group);
1173
+ const shaftPaths = [];
1174
+ if (ghost) {
1175
+ shaftPaths.push(
1176
+ appendPath(group, ghost, {
841
1177
  ...style,
842
1178
  opacity: style.opacity * 0.3,
843
1179
  strokeWidth: style.strokeWidth * 0.7,
844
1180
  dashed
845
- });
846
- }
847
- tipAngle = Math.atan2(end.y - c2y, end.x - c2x);
1181
+ })
1182
+ );
848
1183
  }
849
- appendPath(svg, shaft, { ...style, dashed });
850
- drawArrowHead(svg, end, tipAngle, style, random, distance);
1184
+ shaftPaths.push(appendPath(group, shaft, { ...style, dashed }));
1185
+ const head = drawArrowHead(group, end, tipAngle, style, random, distance);
1186
+ animateArrow(
1187
+ group,
1188
+ { shaft: shaftPaths, head, start, end },
1189
+ arrowOptions.animate,
1190
+ context.elapsed
1191
+ );
851
1192
  const bounds = boundsOf(hull);
852
- return { start, end, bounds };
1193
+ return { start, end, bounds, group };
853
1194
  }
854
1195
  function drawUnderline(svg, x, y, width, style, random) {
855
1196
  if (width <= 4) return;
@@ -916,6 +1257,7 @@ function createNoteText(svg, text, style, options = {}) {
916
1257
  const fontSize = style.fontSize ?? NOTE_FONT_SIZE;
917
1258
  const lines = wrapWords(text, options.wordsPerLine ?? DEFAULT_WORDS_PER_LINE);
918
1259
  const lineHeight = fontSize * 1.05;
1260
+ const rtl = options.rtl ?? isRtlText(text);
919
1261
  const group = document.createElementNS(SVG_NS, "g");
920
1262
  const textEl = document.createElementNS(SVG_NS, "text");
921
1263
  textEl.setAttribute("x", "0");
@@ -928,6 +1270,13 @@ function createNoteText(svg, text, style, options = {}) {
928
1270
  textEl.setAttribute("letter-spacing", "0.4");
929
1271
  textEl.setAttribute("text-anchor", "start");
930
1272
  textEl.setAttribute("dominant-baseline", "auto");
1273
+ if (rtl) {
1274
+ textEl.setAttribute("direction", "rtl");
1275
+ textEl.setAttribute("unicode-bidi", "embed");
1276
+ }
1277
+ if (options.locale) {
1278
+ textEl.setAttributeNS(XML_NS, "xml:lang", options.locale);
1279
+ }
931
1280
  const tspans = lines.map((line, index) => {
932
1281
  const tspan = document.createElementNS(SVG_NS, "tspan");
933
1282
  tspan.textContent = line;
@@ -956,15 +1305,17 @@ function createNoteText(svg, text, style, options = {}) {
956
1305
  height,
957
1306
  ascent,
958
1307
  descent,
959
- fontSize
1308
+ fontSize,
1309
+ rtl
960
1310
  };
961
1311
  }
962
1312
  function placeNoteText(note, box, style, seed, options = {}) {
963
1313
  const random = createRandom(seed + 55);
964
1314
  const tilt = options.tilt ?? (random() - 0.5) * 7;
965
- note.textEl.setAttribute("x", fmt(box.x));
1315
+ const anchorX = note.rtl ? box.x + box.width : box.x;
1316
+ note.textEl.setAttribute("x", fmt(anchorX));
966
1317
  note.textEl.setAttribute("y", fmt(box.y + note.ascent));
967
- for (const tspan of note.tspans) tspan.setAttribute("x", fmt(box.x));
1318
+ for (const tspan of note.tspans) tspan.setAttribute("x", fmt(anchorX));
968
1319
  const cx = box.x + box.width / 2;
969
1320
  const cy = box.y + box.height / 2;
970
1321
  note.group.setAttribute(
@@ -973,9 +1324,10 @@ function placeNoteText(note, box, style, seed, options = {}) {
973
1324
  );
974
1325
  if (options.underline !== false) {
975
1326
  const lastWidth = note.lineWidths[note.lineWidths.length - 1] ?? box.width;
1327
+ const underlineX = note.rtl ? box.x + box.width - lastWidth + 1 : box.x + 1;
976
1328
  drawUnderline(
977
1329
  note.group,
978
- box.x + 1,
1330
+ underlineX,
979
1331
  box.y + box.height + 2,
980
1332
  lastWidth - 2,
981
1333
  {
@@ -996,7 +1348,7 @@ function createOverlaySvg(left, top, width, height) {
996
1348
  svg.setAttribute("width", String(width));
997
1349
  svg.setAttribute("height", String(height));
998
1350
  svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
999
- svg.style.position = "fixed";
1351
+ svg.style.position = "absolute";
1000
1352
  svg.style.left = `${left}px`;
1001
1353
  svg.style.top = `${top}px`;
1002
1354
  svg.style.width = `${width}px`;
@@ -1049,12 +1401,17 @@ function nudgeIntoBand(box, guard, band) {
1049
1401
  return { ...box, x: box.x + dx };
1050
1402
  }
1051
1403
  function renderAnnotation(svg, rect, noteOptions, style, seed = 1, layout = {}) {
1052
- if (!noteOptions?.text) return null;
1404
+ const { text, locale } = resolveLocalizedText(
1405
+ noteOptions?.text,
1406
+ noteOptions?.locale
1407
+ );
1408
+ if (!text) return null;
1053
1409
  const band = layout.band ?? null;
1054
1410
  const position = isValidPosition(noteOptions.position) ? noteOptions.position : "top-right";
1055
1411
  const random = createRandom(seed + 9);
1056
- const note = createNoteText(svg, noteOptions.text, style, {
1057
- wordsPerLine: noteOptions.wordsPerLine
1412
+ const note = createNoteText(svg, text, style, {
1413
+ wordsPerLine: noteOptions.wordsPerLine,
1414
+ locale
1058
1415
  });
1059
1416
  const tilt = (random() - 0.45) * 7;
1060
1417
  const tiltPad = Math.abs(Math.sin(tilt * Math.PI / 180)) * note.width * 0.5;
@@ -1096,6 +1453,25 @@ function drawDot(parent, x, y, style, scale = 1.7) {
1096
1453
  strokeWidth: style.strokeWidth * scale
1097
1454
  });
1098
1455
  }
1456
+ function drawFleck(parent, x, y, radius, style, random) {
1457
+ const count = 6;
1458
+ const points = [];
1459
+ for (let i = 0; i < count; i++) {
1460
+ const angle = Math.PI * 2 * i / count + (random() - 0.5) * 0.5;
1461
+ const r = radius * (0.78 + random() * 0.34);
1462
+ points.push({
1463
+ x: x + Math.cos(angle) * r,
1464
+ y: y + Math.sin(angle) * r
1465
+ });
1466
+ }
1467
+ const blob = appendPath(parent, catmullRomPath(points, true), {
1468
+ ...style,
1469
+ fill: style.color,
1470
+ strokeWidth: 0
1471
+ });
1472
+ blob.style.filter = `blur(${(radius * 0.35).toFixed(2)}px)`;
1473
+ return blob;
1474
+ }
1099
1475
  function drawHeart(parent, size, style, random) {
1100
1476
  const s = size;
1101
1477
  const j = () => (random() - 0.5) * s * 0.12;
@@ -1119,26 +1495,61 @@ function twinkle(parent, radiusX, radiusY, pinch, style) {
1119
1495
  `Q ${(-c).toFixed(2)} ${(-c).toFixed(2)} 0 ${(-ry).toFixed(2)}`,
1120
1496
  "Z"
1121
1497
  ].join(" ");
1122
- appendPath(parent, path, style);
1498
+ return appendPath(parent, path, style);
1123
1499
  }
1124
1500
  function drawSparkle(parent, size, style, random) {
1125
1501
  twinkle(
1126
1502
  parent,
1127
- size * (0.34 + random() * 0.1),
1503
+ size * (0.62 + random() * 0.12),
1128
1504
  size,
1129
- size * (0.06 + random() * 0.06),
1505
+ size * (0.14 + random() * 0.08),
1130
1506
  style
1131
1507
  );
1132
1508
  }
1133
1509
  function drawStar(parent, size, style, random) {
1134
1510
  twinkle(
1135
1511
  parent,
1136
- size * (0.66 + random() * 0.12),
1137
- size,
1138
- size * (0.2 + random() * 0.08),
1512
+ size * (0.62 + random() * 0.12),
1513
+ size * (0.8 + random() * 0.12),
1514
+ size * (0.14 + random() * 0.08),
1139
1515
  style
1140
1516
  );
1141
1517
  }
1518
+ function drawGlimmer(parent, size, style, random) {
1519
+ appendGlow(parent, size * (1.2 + random() * 0.2), {
1520
+ color: style.color,
1521
+ opacity: style.opacity * 0.22
1522
+ });
1523
+ const armX = size * (0.5 + random() * 0.08);
1524
+ const armY = size * (0.45 + random() * 0.08);
1525
+ const bluntness = size * 0.19;
1526
+ const main = twinkle(parent, armX, armY, size * (0.08 + random() * 0.03), {
1527
+ ...style,
1528
+ fill: style.color,
1529
+ strokeWidth: bluntness
1530
+ });
1531
+ main.setAttribute("stroke-linejoin", "round");
1532
+ main.setAttribute("stroke-linecap", "round");
1533
+ main.style.filter = `blur(${(size * 0.05).toFixed(2)}px)`;
1534
+ const gap = bluntness / 2 + size * 0.2;
1535
+ const corners = [
1536
+ { x: 0, y: -(armY + gap) },
1537
+ { x: armX + gap, y: 0 },
1538
+ { x: 0, y: armY + gap },
1539
+ { x: -(armX + gap), y: 0 }
1540
+ ];
1541
+ for (const { x, y } of corners) {
1542
+ const jitter = 0.95 + random() * 0.1;
1543
+ drawFleck(
1544
+ parent,
1545
+ x * jitter,
1546
+ y * jitter,
1547
+ size * (0.1 + random() * 0.03),
1548
+ style,
1549
+ random
1550
+ );
1551
+ }
1552
+ }
1142
1553
  function drawTwinkle(parent, size, style, random) {
1143
1554
  const count = 5;
1144
1555
  const first = -Math.PI / 2 + (random() - 0.5) * 0.5;
@@ -1246,46 +1657,15 @@ function drawEmphasis(parent, size, style, random, opts = {}) {
1246
1657
  );
1247
1658
  }
1248
1659
  }
1249
- function drawDots(parent, size, style, random) {
1250
- let x = -size * 0.7;
1251
- for (let i = 0; i < 3; i++) {
1252
- drawDot(parent, x, (random() - 0.5) * size * 0.16, style);
1253
- x += size * (0.6 + random() * 0.25);
1254
- }
1255
- }
1256
- function drawStroke(parent, size, style, random) {
1257
- appendPath(
1258
- parent,
1259
- roughLine(-size, 0, size, 0, 1.2, random, {
1260
- bow: (random() - 0.5) * size * 0.35,
1261
- steps: 4
1262
- }),
1263
- style
1264
- );
1265
- }
1266
- function drawSteam(parent, size, style, random) {
1267
- const lean = random() < 0.5 ? -1 : 1;
1268
- for (let i = 0; i < 2; i++) {
1269
- const x = (i - 0.5) * size * 0.72;
1270
- const base = size * 0.62 - i * size * 0.14;
1271
- appendPath(
1272
- parent,
1273
- `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)}`,
1274
- style
1275
- );
1276
- }
1277
- }
1278
1660
  var DECORATION_DRAWERS = {
1279
1661
  heart: drawHeart,
1280
1662
  sparkle: drawSparkle,
1281
1663
  star: drawStar,
1282
1664
  twinkle: drawTwinkle,
1665
+ glimmer: drawGlimmer,
1283
1666
  smiley: drawSmiley,
1284
1667
  emphasis: drawEmphasis,
1285
- arcs: drawArcs,
1286
- dots: drawDots,
1287
- stroke: drawStroke,
1288
- steam: drawSteam
1668
+ arcs: drawArcs
1289
1669
  };
1290
1670
  var TEXT_TYPES = ["heart", "sparkle"];
1291
1671
  var CORNER_ACCENTS = ["arcs", "emphasis"];
@@ -1296,12 +1676,10 @@ var TYPE_TRAITS = {
1296
1676
  sparkle: { size: [6, 9], tilt: 20, standoff: 22 },
1297
1677
  star: { size: [8, 11], tilt: 25, standoff: 26 },
1298
1678
  twinkle: { size: [5, 7.5], tilt: 30, standoff: 26, brightness: 1.4 },
1679
+ glimmer: { size: [7, 10], tilt: 20, standoff: 28, brightness: 1.3 },
1299
1680
  smiley: { size: [8, 11], tilt: 10, standoff: 22 },
1300
1681
  emphasis: { size: [12, 17], tilt: 10, standoff: 13, outward: true },
1301
- arcs: { size: [11, 16], tilt: 12, standoff: 12, outward: true },
1302
- dots: { size: [6, 9], tilt: 8, standoff: 20 },
1303
- stroke: { size: [8, 12], tilt: 40, standoff: 20 },
1304
- steam: { size: [8, 11], tilt: 10, standoff: 20 }
1682
+ arcs: { size: [11, 16], tilt: 12, standoff: 12, outward: true }
1305
1683
  };
1306
1684
  var DEFAULT_TYPES = [
1307
1685
  "twinkle",
@@ -1504,6 +1882,9 @@ function ensureHandwrittenFont() {
1504
1882
 
1505
1883
  // src/doodle.js
1506
1884
  var instances = /* @__PURE__ */ new WeakMap();
1885
+ function now() {
1886
+ return typeof performance !== "undefined" && performance.now ? performance.now() : Date.now();
1887
+ }
1507
1888
  function localBand(overlayRect, inset = 8) {
1508
1889
  const width = window.innerWidth || document.documentElement.clientWidth || 0;
1509
1890
  return {
@@ -1520,19 +1901,21 @@ var DoodleOverlay = class {
1520
1901
  this.element = element;
1521
1902
  this.options = options;
1522
1903
  this.seed = Math.floor(Math.random() * 1e9);
1904
+ this.startedAt = now();
1523
1905
  this.svg = null;
1906
+ this.wrapper = null;
1524
1907
  this.resizeObserver = null;
1525
1908
  this.mutationObserver = null;
1526
1909
  this.pendingFrame = null;
1527
- this.scrollTargets = [];
1528
- this.onScroll = this.scheduleUpdate.bind(this);
1910
+ this.listeners = [];
1529
1911
  this.onResize = this.scheduleUpdate.bind(this);
1912
+ this.onLanguageChange = this.scheduleUpdate.bind(this);
1530
1913
  this.childElements = [];
1531
1914
  this.mount();
1532
1915
  }
1533
1916
  mount() {
1534
1917
  this.svg = createOverlaySvg(0, 0, 0, 0);
1535
- insertOverlaySvg(this.svg, this.element);
1918
+ this.wrapper = insertOverlaySvg(this.svg, this.element);
1536
1919
  syncOverlayStacking(this.svg, this.element, this.options.zIndex);
1537
1920
  this.resizeObserver = new ResizeObserver(this.onResize);
1538
1921
  this.resizeObserver.observe(this.element);
@@ -1545,12 +1928,18 @@ var DoodleOverlay = class {
1545
1928
  subtree: true,
1546
1929
  attributes: true
1547
1930
  });
1548
- for (const target of getScrollableAncestors(this.element)) {
1549
- target.addEventListener("scroll", this.onScroll, { passive: true });
1550
- this.scrollTargets.push({ target, type: "scroll" });
1551
- }
1552
1931
  window.addEventListener("resize", this.onResize, { passive: true });
1553
- this.scrollTargets.push({ target: window, type: "resize" });
1932
+ this.listeners.push({
1933
+ target: window,
1934
+ type: "resize",
1935
+ handler: this.onResize
1936
+ });
1937
+ window.addEventListener("languagechange", this.onLanguageChange);
1938
+ this.listeners.push({
1939
+ target: window,
1940
+ type: "languagechange",
1941
+ handler: this.onLanguageChange
1942
+ });
1554
1943
  if (this.options.note && this.options.autoLoadFont && !this.options.fontFamily) {
1555
1944
  ensureHandwrittenFont();
1556
1945
  }
@@ -1586,17 +1975,19 @@ var DoodleOverlay = class {
1586
1975
  const { padding, radius, color, strokeWidth, roughness, opacity } = this.options;
1587
1976
  const targets = this.getTargets();
1588
1977
  const margin = this.overlayMargin();
1978
+ const elapsed = now() - this.startedAt;
1589
1979
  const absoluteRects = targets.map(
1590
1980
  (target) => getElementBounds(target, padding, radius)
1591
1981
  );
1592
1982
  const overlayRect = unionRects(absoluteRects, margin);
1593
1983
  clearSvg(this.svg);
1594
1984
  syncOverlayStacking(this.svg, this.element, this.options.zIndex);
1985
+ const origin = this.wrapper ? this.wrapper.getBoundingClientRect() : { left: 0, top: 0 };
1595
1986
  this.svg.setAttribute("width", String(overlayRect.width));
1596
1987
  this.svg.setAttribute("height", String(overlayRect.height));
1597
1988
  this.svg.setAttribute("viewBox", `0 0 ${overlayRect.width} ${overlayRect.height}`);
1598
- this.svg.style.left = `${overlayRect.x}px`;
1599
- this.svg.style.top = `${overlayRect.y}px`;
1989
+ this.svg.style.left = `${overlayRect.x - origin.left}px`;
1990
+ this.svg.style.top = `${overlayRect.y - origin.top}px`;
1600
1991
  this.svg.style.width = `${overlayRect.width}px`;
1601
1992
  this.svg.style.height = `${overlayRect.height}px`;
1602
1993
  const strokeStyle = { color, strokeWidth, roughness, opacity };
@@ -1638,7 +2029,8 @@ var DoodleOverlay = class {
1638
2029
  this.options.arrow,
1639
2030
  strokeStyle,
1640
2031
  seed,
1641
- note ? note.box : null
2032
+ note ? note.box : null,
2033
+ { elapsed }
1642
2034
  ) : null;
1643
2035
  if (this.options.decorations) {
1644
2036
  renderDecorations(
@@ -1665,7 +2057,7 @@ var DoodleOverlay = class {
1665
2057
  overlayMargin() {
1666
2058
  const { note, arrow, decorations } = this.options;
1667
2059
  if (!note && !arrow && !decorations) return 48;
1668
- const text = typeof note?.text === "string" ? note.text : "";
2060
+ const { text } = resolveLocalizedText(note?.text, note?.locale);
1669
2061
  return Math.max(96, Math.min(240, 80 + text.length * 8));
1670
2062
  }
1671
2063
  destroy() {
@@ -1685,14 +2077,15 @@ var DoodleOverlay = class {
1685
2077
  document.fonts?.removeEventListener?.("loadingdone", this.onFontsLoaded);
1686
2078
  this.onFontsLoaded = null;
1687
2079
  }
1688
- for (const { target, type } of this.scrollTargets) {
1689
- target.removeEventListener(type, type === "scroll" ? this.onScroll : this.onResize);
2080
+ for (const { target, type, handler } of this.listeners) {
2081
+ target.removeEventListener(type, handler);
1690
2082
  }
1691
- this.scrollTargets = [];
1692
- if (this.svg?.parentNode) {
1693
- this.svg.parentNode.removeChild(this.svg);
2083
+ this.listeners = [];
2084
+ if (this.svg) {
2085
+ removeOverlaySvg(this.svg, this.wrapper, this.element);
1694
2086
  }
1695
2087
  this.svg = null;
2088
+ this.wrapper = null;
1696
2089
  instances.delete(this.element);
1697
2090
  }
1698
2091
  };
@@ -1729,7 +2122,8 @@ var OPTION_KEYS = [
1729
2122
  "decorations",
1730
2123
  "addBreaks",
1731
2124
  "fontFamily",
1732
- "autoLoadFont"
2125
+ "autoLoadFont",
2126
+ "locale"
1733
2127
  ];
1734
2128
  function collectOptions(props) {
1735
2129
  const options = {};
@@ -1781,6 +2175,7 @@ var Doodle = (0, import_react.forwardRef)(function Doodle2(props, forwardedRef)
1781
2175
  addBreaks,
1782
2176
  fontFamily,
1783
2177
  autoLoadFont,
2178
+ locale,
1784
2179
  childSelector,
1785
2180
  ...rest
1786
2181
  } = props;
@@ -1800,6 +2195,7 @@ var Doodle = (0, import_react.forwardRef)(function Doodle2(props, forwardedRef)
1800
2195
  addBreaks,
1801
2196
  fontFamily,
1802
2197
  autoLoadFont,
2198
+ locale,
1803
2199
  childSelector
1804
2200
  }),
1805
2201
  [
@@ -1817,6 +2213,7 @@ var Doodle = (0, import_react.forwardRef)(function Doodle2(props, forwardedRef)
1817
2213
  JSON.stringify(addBreaks ?? null),
1818
2214
  fontFamily,
1819
2215
  autoLoadFont,
2216
+ JSON.stringify(locale ?? null),
1820
2217
  childSelector
1821
2218
  ]
1822
2219
  );