@aznabee/freehand-ui 0.1.1

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.
@@ -0,0 +1,1513 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
5
+ var __export = (target, all) => {
6
+ for (var name in all)
7
+ __defProp(target, name, { get: all[name], enumerable: true });
8
+ };
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (let key of __getOwnPropNames(from))
12
+ if (!__hasOwnProp.call(to, key) && key !== except)
13
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
14
+ }
15
+ return to;
16
+ };
17
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
18
+
19
+ // src/index.js
20
+ var index_exports = {};
21
+ __export(index_exports, {
22
+ doodle: () => doodle
23
+ });
24
+ module.exports = __toCommonJS(index_exports);
25
+
26
+ // src/utils.js
27
+ var DEFAULT_OPTIONS = {
28
+ border: true,
29
+ color: "#ffffff",
30
+ strokeWidth: 1.5,
31
+ roughness: 1.5,
32
+ padding: 8,
33
+ radius: null,
34
+ opacity: 0.9,
35
+ children: null,
36
+ note: null,
37
+ arrow: false,
38
+ decorations: false,
39
+ addBreaks: false
40
+ };
41
+ function resolveElement(target) {
42
+ if (!target) return null;
43
+ if (typeof target === "string") {
44
+ return document.querySelector(target);
45
+ }
46
+ if (target instanceof Element) {
47
+ return target;
48
+ }
49
+ return null;
50
+ }
51
+ function mergeOptions(options = {}) {
52
+ const merged = { ...DEFAULT_OPTIONS, ...options };
53
+ if (merged.note && typeof merged.note === "string") {
54
+ merged.note = { text: merged.note, position: "top-right" };
55
+ }
56
+ if (merged.arrow === true) {
57
+ const from = merged.note && typeof merged.note === "object" && merged.note.position ? merged.note.position : "top-right";
58
+ merged.arrow = { from, to: "edge", style: "curved" };
59
+ }
60
+ if (merged.decorations === true) {
61
+ merged.decorations = { count: null, types: null };
62
+ }
63
+ if (merged.addBreaks) {
64
+ const config = typeof merged.addBreaks === "number" ? { count: merged.addBreaks } : merged.addBreaks === true ? {} : merged.addBreaks;
65
+ merged.addBreaks = {
66
+ count: config.count ?? null,
67
+ min: config.min ?? 6,
68
+ max: config.max ?? 30
69
+ };
70
+ }
71
+ return merged;
72
+ }
73
+ function createRandom(seed) {
74
+ let state = seed >>> 0;
75
+ return () => {
76
+ state = state + 1831565813 >>> 0;
77
+ let t = Math.imul(state ^ state >>> 15, 1 | state);
78
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
79
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
80
+ };
81
+ }
82
+ function scheduleFrame(fn) {
83
+ if (typeof requestAnimationFrame === "function") {
84
+ return requestAnimationFrame(fn);
85
+ }
86
+ return setTimeout(fn, 16);
87
+ }
88
+ function cancelFrame(id) {
89
+ if (typeof cancelAnimationFrame === "function") {
90
+ cancelAnimationFrame(id);
91
+ } else {
92
+ clearTimeout(id);
93
+ }
94
+ }
95
+ function clamp(value, min, max) {
96
+ return Math.min(max, Math.max(min, value));
97
+ }
98
+ function isValidPosition(position) {
99
+ return [
100
+ "top",
101
+ "top-right",
102
+ "right",
103
+ "bottom-right",
104
+ "bottom",
105
+ "bottom-left",
106
+ "left",
107
+ "top-left",
108
+ "center"
109
+ ].includes(position);
110
+ }
111
+ function getScrollableAncestors(element) {
112
+ const ancestors = [];
113
+ let node = element.parentElement;
114
+ while (node && node !== document.documentElement) {
115
+ const style = getComputedStyle(node);
116
+ const overflow = style.overflow + style.overflowY + style.overflowX;
117
+ if (/(auto|scroll|overlay)/.test(overflow)) {
118
+ ancestors.push(node);
119
+ }
120
+ node = node.parentElement;
121
+ }
122
+ ancestors.push(window);
123
+ return ancestors;
124
+ }
125
+
126
+ // src/geometry.js
127
+ function averageCornerRadius(element) {
128
+ const style = getComputedStyle(element);
129
+ const corners = [
130
+ style.borderTopLeftRadius,
131
+ style.borderTopRightRadius,
132
+ style.borderBottomRightRadius,
133
+ style.borderBottomLeftRadius
134
+ ];
135
+ const values = corners.map((value) => parseFloat(value) || 0).filter((value) => value > 0);
136
+ if (values.length === 0) return 0;
137
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
138
+ }
139
+ function detectBorderRadius(element) {
140
+ const own = averageCornerRadius(element);
141
+ if (own > 0) return own;
142
+ const child = element.children.length === 1 ? element.firstElementChild : null;
143
+ if (!child) return 0;
144
+ const outer = element.getBoundingClientRect();
145
+ const inner = child.getBoundingClientRect();
146
+ const fitsTightly = Math.abs(outer.width - inner.width) <= 2 && Math.abs(outer.height - inner.height) <= 2;
147
+ return fitsTightly ? averageCornerRadius(child) : 0;
148
+ }
149
+ function getElementBounds(element, padding = 0, radiusOverride = null) {
150
+ const rect = element.getBoundingClientRect();
151
+ const radius = radiusOverride != null ? radiusOverride : detectBorderRadius(element);
152
+ const width = rect.width + padding * 2;
153
+ const height = rect.height + padding * 2;
154
+ return {
155
+ x: rect.left - padding,
156
+ y: rect.top - padding,
157
+ width,
158
+ height,
159
+ // Grow the radius with the padding so the doodle stays concentric with the
160
+ // element instead of cutting its corners.
161
+ radius: clamp(radius + padding * 0.6, 0, Math.min(width, height) / 2)
162
+ };
163
+ }
164
+ function relativeRect(outer, inner) {
165
+ return {
166
+ x: inner.x - outer.x,
167
+ y: inner.y - outer.y,
168
+ width: inner.width,
169
+ height: inner.height,
170
+ radius: inner.radius
171
+ };
172
+ }
173
+ function unionRects(rects, margin = 0) {
174
+ if (rects.length === 0) {
175
+ return { x: 0, y: 0, width: 0, height: 0, radius: 0 };
176
+ }
177
+ const minX = Math.min(...rects.map((r) => r.x)) - margin;
178
+ const minY = Math.min(...rects.map((r) => r.y)) - margin;
179
+ const maxX = Math.max(...rects.map((r) => r.x + r.width)) + margin;
180
+ const maxY = Math.max(...rects.map((r) => r.y + r.height)) + margin;
181
+ return {
182
+ x: minX,
183
+ y: minY,
184
+ width: maxX - minX,
185
+ height: maxY - minY,
186
+ radius: 0
187
+ };
188
+ }
189
+ function inflateRect(rect, amount) {
190
+ return {
191
+ x: rect.x - amount,
192
+ y: rect.y - amount,
193
+ width: rect.width + amount * 2,
194
+ height: rect.height + amount * 2
195
+ };
196
+ }
197
+ function intersectionArea(a, b) {
198
+ if (!a || !b) return 0;
199
+ const w = Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x);
200
+ const h = Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y);
201
+ return w > 0 && h > 0 ? w * h : 0;
202
+ }
203
+ function outsideArea(box, bounds) {
204
+ if (!bounds) return 0;
205
+ return Math.max(0, box.width * box.height - intersectionArea(box, bounds));
206
+ }
207
+ function nearestPointOnRect(rect, point) {
208
+ return {
209
+ x: clamp(point.x, rect.x, rect.x + rect.width),
210
+ y: clamp(point.y, rect.y, rect.y + rect.height)
211
+ };
212
+ }
213
+ function distanceToRoundedRect(point, rect, radius = 0) {
214
+ const r = clamp(radius, 0, Math.min(rect.width, rect.height) / 2);
215
+ const cx = clamp(point.x, rect.x + r, rect.x + rect.width - r);
216
+ const cy = clamp(point.y, rect.y + r, rect.y + rect.height - r);
217
+ return Math.hypot(point.x - cx, point.y - cy) - r;
218
+ }
219
+ function directionFromPosition(position) {
220
+ return {
221
+ x: position.includes("right") ? 1 : position.includes("left") ? -1 : 0,
222
+ y: position.includes("top") ? -1 : position.includes("bottom") ? 1 : 0
223
+ };
224
+ }
225
+ function pointFromPosition(position, rect) {
226
+ const cx = rect.x + rect.width / 2;
227
+ const cy = rect.y + rect.height / 2;
228
+ switch (position) {
229
+ case "top":
230
+ return { x: cx, y: rect.y };
231
+ case "top-right":
232
+ return { x: rect.x + rect.width, y: rect.y };
233
+ case "right":
234
+ return { x: rect.x + rect.width, y: cy };
235
+ case "bottom-right":
236
+ return { x: rect.x + rect.width, y: rect.y + rect.height };
237
+ case "bottom":
238
+ return { x: cx, y: rect.y + rect.height };
239
+ case "bottom-left":
240
+ return { x: rect.x, y: rect.y + rect.height };
241
+ case "left":
242
+ return { x: rect.x, y: cy };
243
+ case "top-left":
244
+ return { x: rect.x, y: rect.y };
245
+ case "center":
246
+ default:
247
+ return { x: cx, y: cy };
248
+ }
249
+ }
250
+ function annotationAnchor(position, rect, offset = 12) {
251
+ const point = pointFromPosition(position, rect);
252
+ if (position.includes("top")) point.y -= offset;
253
+ if (position.includes("bottom")) point.y += offset;
254
+ if (position.includes("left")) point.x -= offset;
255
+ if (position.includes("right")) point.x += offset;
256
+ if (position === "top") point.y -= offset * 0.5;
257
+ if (position === "bottom") point.y += offset * 0.5;
258
+ if (position === "left") point.x -= offset * 0.5;
259
+ if (position === "right") point.x += offset * 0.5;
260
+ return point;
261
+ }
262
+
263
+ // src/renderer.js
264
+ var SVG_NS = "http://www.w3.org/2000/svg";
265
+ var HANDWRITTEN_FONT = '"Caveat", "Kalam", "Patrick Hand", cursive';
266
+ var NOTE_FONT_SIZE = 19;
267
+ function clearSvg(svg) {
268
+ while (svg.firstChild) {
269
+ svg.removeChild(svg.firstChild);
270
+ }
271
+ }
272
+ function fmt(n) {
273
+ return (Number.isFinite(n) ? n : 0).toFixed(2);
274
+ }
275
+ function catmullRomPath(points, closed = false) {
276
+ if (!points.length) return "";
277
+ if (points.length === 1) {
278
+ return `M ${fmt(points[0].x)} ${fmt(points[0].y)}`;
279
+ }
280
+ const n = points.length;
281
+ const at = (i) => closed ? points[(i % n + n) % n] : points[clamp(i, 0, n - 1)];
282
+ let path = `M ${fmt(points[0].x)} ${fmt(points[0].y)}`;
283
+ const segments = closed ? n : n - 1;
284
+ for (let i = 0; i < segments; i++) {
285
+ const p0 = at(i - 1);
286
+ const p1 = at(i);
287
+ const p2 = at(i + 1);
288
+ const p3 = at(i + 2);
289
+ const c1x = p1.x + (p2.x - p0.x) / 6;
290
+ const c1y = p1.y + (p2.y - p0.y) / 6;
291
+ const c2x = p2.x - (p3.x - p1.x) / 6;
292
+ const c2y = p2.y - (p3.y - p1.y) / 6;
293
+ path += ` C ${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(p2.x)} ${fmt(p2.y)}`;
294
+ }
295
+ if (closed) path += " Z";
296
+ return path;
297
+ }
298
+ function createWave(random, period, amplitude) {
299
+ const p = period || 1;
300
+ const tau = Math.PI * 2 / p;
301
+ const f1 = tau * (2 + Math.floor(random() * 2));
302
+ const f2 = tau * (5 + Math.floor(random() * 3));
303
+ const phase1 = random() * Math.PI * 2;
304
+ const phase2 = random() * Math.PI * 2;
305
+ return (distance) => Math.sin(distance * f1 + phase1) * amplitude + Math.sin(distance * f2 + phase2) * amplitude * 0.4;
306
+ }
307
+ function roughLine(x1, y1, x2, y2, roughness, random, opts = {}) {
308
+ const dx = x2 - x1;
309
+ const dy = y2 - y1;
310
+ const length = Math.hypot(dx, dy) || 1;
311
+ const steps = opts.steps ?? clamp(Math.round(length / 22), 2, 14);
312
+ const bow = opts.bow ?? 0;
313
+ const nx = -dy / length;
314
+ const ny = dx / length;
315
+ const amplitude = roughness * 0.32;
316
+ const frequency = 1 + random() * 1.4;
317
+ const phase = random() * Math.PI * 2;
318
+ const points = [];
319
+ for (let i = 0; i <= steps; i++) {
320
+ const t = i / steps;
321
+ const envelope = Math.sin(t * Math.PI);
322
+ const offset = bow * envelope + Math.sin(t * Math.PI * frequency + phase) * amplitude * envelope;
323
+ points.push({
324
+ x: x1 + dx * t + nx * offset,
325
+ y: y1 + dy * t + ny * offset
326
+ });
327
+ }
328
+ return catmullRomPath(points, false);
329
+ }
330
+ function roundedRectOutline(x, y, width, height, radius, spacing) {
331
+ const r = clamp(radius, 0, Math.min(width, height) / 2);
332
+ const right = x + width;
333
+ const bottom = y + height;
334
+ const HALF_PI = Math.PI / 2;
335
+ const line = (x1, y1, x2, y2, nx, ny) => ({
336
+ type: "line",
337
+ x1,
338
+ y1,
339
+ x2,
340
+ y2,
341
+ nx,
342
+ ny,
343
+ length: Math.hypot(x2 - x1, y2 - y1)
344
+ });
345
+ const arc = (cx, cy, start, end) => ({
346
+ type: "arc",
347
+ cx,
348
+ cy,
349
+ start,
350
+ end,
351
+ r,
352
+ length: Math.abs(end - start) * r
353
+ });
354
+ const segments = [
355
+ line(x + r, y, right - r, y, 0, -1),
356
+ arc(right - r, y + r, -HALF_PI, 0),
357
+ line(right, y + r, right, bottom - r, 1, 0),
358
+ arc(right - r, bottom - r, 0, HALF_PI),
359
+ line(right - r, bottom, x + r, bottom, 0, 1),
360
+ arc(x + r, bottom - r, HALF_PI, Math.PI),
361
+ line(x, bottom - r, x, y + r, -1, 0),
362
+ arc(x + r, y + r, Math.PI, Math.PI + HALF_PI)
363
+ ];
364
+ const total = segments.reduce((sum, seg) => sum + seg.length, 0);
365
+ const points = [];
366
+ const edgeStarts = [];
367
+ for (const seg of segments) {
368
+ if (seg.length <= 0.01) continue;
369
+ const steps = Math.max(1, Math.round(seg.length / spacing));
370
+ if (seg.type === "line") edgeStarts.push(points.length);
371
+ for (let i = 0; i < steps; i++) {
372
+ const t = i / steps;
373
+ if (seg.type === "line") {
374
+ points.push({
375
+ x: seg.x1 + (seg.x2 - seg.x1) * t,
376
+ y: seg.y1 + (seg.y2 - seg.y1) * t,
377
+ nx: seg.nx,
378
+ ny: seg.ny
379
+ });
380
+ } else {
381
+ const angle = seg.start + (seg.end - seg.start) * t;
382
+ const nx = Math.cos(angle);
383
+ const ny = Math.sin(angle);
384
+ points.push({
385
+ x: seg.cx + nx * seg.r,
386
+ y: seg.cy + ny * seg.r,
387
+ nx,
388
+ ny
389
+ });
390
+ }
391
+ }
392
+ }
393
+ return { points, total, edgeStarts };
394
+ }
395
+ function generateHandDrawnRectPaths(x, y, width, height, radius, roughness, random, opts = {}) {
396
+ if (width <= 0 || height <= 0) return [];
397
+ const spacing = opts.spacing ?? 11;
398
+ const { points, total, edgeStarts } = roundedRectOutline(
399
+ x,
400
+ y,
401
+ width,
402
+ height,
403
+ radius,
404
+ spacing
405
+ );
406
+ const count = points.length;
407
+ if (count < 3) return [];
408
+ const amplitude = opts.amplitude ?? roughness * 0.45;
409
+ const wave = createWave(random, total, amplitude);
410
+ const step = total / count;
411
+ const portion = clamp(opts.portion ?? 1, 0.05, 1);
412
+ const overshoot = opts.overshoot ?? 0;
413
+ let startIndex = Math.floor((opts.startAt ?? random()) * count);
414
+ if (opts.snapToEdge && edgeStarts.length) {
415
+ startIndex = edgeStarts.reduce(
416
+ (best, index) => Math.abs(index - startIndex) < Math.abs(best - startIndex) ? index : best
417
+ );
418
+ }
419
+ let drawn = Math.round(count * portion);
420
+ if (opts.snapToEdge && edgeStarts.length && portion < 1) {
421
+ const target = startIndex + drawn;
422
+ let snapped = target;
423
+ let bestDelta = Infinity;
424
+ for (let lap = 0; lap <= 2; lap++) {
425
+ for (const index of edgeStarts) {
426
+ const candidate = index + lap * count;
427
+ const delta = Math.abs(candidate - target);
428
+ if (candidate > startIndex + 2 && delta < bestDelta) {
429
+ bestDelta = delta;
430
+ snapped = candidate;
431
+ }
432
+ }
433
+ }
434
+ drawn = Math.min(snapped - startIndex, count);
435
+ }
436
+ const tail = portion >= 1 ? Math.max(1, Math.round(overshoot / step)) : 0;
437
+ const breaks = opts.breaks ?? [];
438
+ const runs = [];
439
+ let run = [];
440
+ for (let k = 0; k <= drawn + tail; k++) {
441
+ const index = (startIndex + k) % count;
442
+ if (breaks.length && isBroken(index / count, breaks)) {
443
+ if (run.length > 1) runs.push(run);
444
+ run = [];
445
+ continue;
446
+ }
447
+ const point = points[index];
448
+ let offset = wave((startIndex + k) * step);
449
+ if (tail > 0 && k > drawn) {
450
+ offset += (k - drawn) / tail * 0.5;
451
+ }
452
+ run.push({
453
+ x: point.x + point.nx * offset,
454
+ y: point.y + point.ny * offset
455
+ });
456
+ }
457
+ if (run.length > 1) runs.push(run);
458
+ return runs.map((points2) => catmullRomPath(points2, false));
459
+ }
460
+ function isBroken(fraction, breaks) {
461
+ return breaks.some(
462
+ (gap) => gap.end > gap.start ? fraction >= gap.start && fraction < gap.end : fraction >= gap.start || fraction < gap.end
463
+ );
464
+ }
465
+ function createBorderBreaks(perimeter, config, random) {
466
+ if (!config || perimeter <= 0) return [];
467
+ const settings = config === true ? {} : config;
468
+ const count = Math.max(
469
+ 1,
470
+ Math.round(settings.count ?? 2 + random() * 3)
471
+ );
472
+ const min = Math.max(2, settings.min ?? 6);
473
+ const max = Math.max(min, settings.max ?? 30);
474
+ const slice = perimeter / count;
475
+ let budget = perimeter * 0.35;
476
+ const breaks = [];
477
+ for (let i = 0; i < count; i++) {
478
+ const length = Math.min(
479
+ min + random() * (max - min),
480
+ slice * 0.7,
481
+ budget
482
+ );
483
+ if (length < 2) break;
484
+ budget -= length;
485
+ const start = i * slice + random() * Math.max(0, slice - length);
486
+ breaks.push({
487
+ start: start / perimeter,
488
+ end: (start + length) / perimeter
489
+ });
490
+ }
491
+ return breaks;
492
+ }
493
+ function createGroup(parent, x, y, rotation = 0) {
494
+ const group = document.createElementNS(SVG_NS, "g");
495
+ group.setAttribute(
496
+ "transform",
497
+ `translate(${fmt(x)} ${fmt(y)}) rotate(${rotation.toFixed(1)})`
498
+ );
499
+ parent.appendChild(group);
500
+ return group;
501
+ }
502
+ function appendPath(svg, d, style) {
503
+ const path = document.createElementNS(SVG_NS, "path");
504
+ path.setAttribute("d", d);
505
+ path.setAttribute("fill", style.fill ?? "none");
506
+ path.setAttribute("stroke", style.color);
507
+ path.setAttribute("stroke-width", String(style.strokeWidth));
508
+ path.setAttribute("stroke-linecap", "round");
509
+ path.setAttribute("stroke-linejoin", "round");
510
+ path.setAttribute("opacity", String(style.opacity));
511
+ if (style.dashed) {
512
+ path.setAttribute("stroke-dasharray", "2.5 4.5");
513
+ }
514
+ svg.appendChild(path);
515
+ return path;
516
+ }
517
+ function rectPerimeter(width, height, radius) {
518
+ const r = clamp(radius, 0, Math.min(width, height) / 2);
519
+ return 2 * (width - 2 * r) + 2 * (height - 2 * r) + 2 * Math.PI * r;
520
+ }
521
+ function drawBorder(svg, rect, options, seed) {
522
+ const random = createRandom(seed);
523
+ const roughness = clamp(options.roughness, 0, 3);
524
+ const radius = rect.radius || 0;
525
+ const breaks = options.breaks ? createBorderBreaks(
526
+ rectPerimeter(rect.width, rect.height, radius),
527
+ options.breaks,
528
+ random
529
+ ) : [];
530
+ const spacing = breaks.length ? 5.5 : 11;
531
+ const main = generateHandDrawnRectPaths(
532
+ rect.x,
533
+ rect.y,
534
+ rect.width,
535
+ rect.height,
536
+ radius,
537
+ roughness,
538
+ random,
539
+ {
540
+ amplitude: roughness * 0.5,
541
+ overshoot: 9 + roughness * 5,
542
+ startAt: random(),
543
+ snapToEdge: true,
544
+ spacing,
545
+ breaks
546
+ }
547
+ );
548
+ for (const d of main) {
549
+ appendPath(svg, d, {
550
+ color: options.color,
551
+ strokeWidth: options.strokeWidth,
552
+ opacity: options.opacity
553
+ });
554
+ }
555
+ if (roughness < 0.9) return;
556
+ const offset = 1.1 + random() * 0.9;
557
+ const ghost = generateHandDrawnRectPaths(
558
+ rect.x - offset,
559
+ rect.y - offset,
560
+ rect.width + offset * 2,
561
+ rect.height + offset * 2,
562
+ radius + offset,
563
+ roughness,
564
+ random,
565
+ {
566
+ amplitude: roughness * 0.45,
567
+ portion: 0.45 + random() * 0.25,
568
+ startAt: random(),
569
+ snapToEdge: true,
570
+ spacing,
571
+ breaks
572
+ }
573
+ );
574
+ for (const d of ghost) {
575
+ appendPath(svg, d, {
576
+ color: options.color,
577
+ strokeWidth: options.strokeWidth * 0.7,
578
+ opacity: options.opacity * 0.4
579
+ });
580
+ }
581
+ }
582
+ function normalize(vector) {
583
+ const length = Math.hypot(vector.x, vector.y);
584
+ if (!length) return { x: 0, y: 0 };
585
+ return { x: vector.x / length, y: vector.y / length };
586
+ }
587
+ function arrowStartFromNote(origin, target) {
588
+ const cx = origin.x + origin.width / 2;
589
+ const cy = origin.y + origin.height / 2;
590
+ const dx = target.x - cx;
591
+ const dy = target.y - cy;
592
+ const hw = origin.width / 2 || 1e-3;
593
+ const hh = origin.height / 2 || 1e-3;
594
+ const sx = Math.sign(dx) || 1;
595
+ const sy = Math.sign(dy) || 1;
596
+ if (Math.abs(dy) / hh >= Math.abs(dx) / hw) {
597
+ return { x: cx - sx * hw * 0.72, y: cy + sy * (hh + 7) };
598
+ }
599
+ return { x: cx + sx * (hw + 7), y: cy + sy * hh * 0.2 };
600
+ }
601
+ function arrowEndpoints(rect, arrowOptions, random, origin) {
602
+ const center = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 };
603
+ const anchored = Boolean(origin && origin.width > 0 && origin.height > 0);
604
+ let start = anchored ? arrowStartFromNote(origin, center) : annotationAnchor(
605
+ arrowOptions.from || "top-right",
606
+ rect,
607
+ 38 + random() * 12
608
+ );
609
+ const toPos = arrowOptions.to || "edge";
610
+ let end;
611
+ if (toPos === "center") {
612
+ end = center;
613
+ } else if (toPos === "edge") {
614
+ const near = nearestPointOnRect(rect, start);
615
+ let outward = normalize({ x: start.x - near.x, y: start.y - near.y });
616
+ if (!outward.x && !outward.y) outward = { x: 0, y: -1 };
617
+ const gap = 6 + random() * 3;
618
+ end = { x: near.x + outward.x * gap, y: near.y + outward.y * gap };
619
+ } else {
620
+ end = pointFromPosition(toPos, rect);
621
+ }
622
+ const distance = Math.hypot(end.x - start.x, end.y - start.y);
623
+ const minimum = 34;
624
+ if (!anchored && distance < minimum) {
625
+ const unit = distance > 0 ? { x: (start.x - end.x) / distance, y: (start.y - end.y) / distance } : { x: 0, y: -1 };
626
+ start = { x: end.x + unit.x * minimum, y: end.y + unit.y * minimum };
627
+ }
628
+ return { start, end };
629
+ }
630
+ function drawArrow(svg, rect, arrowOptions, style, seed, origin = null) {
631
+ const random = createRandom(seed + 17);
632
+ const { start, end } = arrowEndpoints(rect, arrowOptions, random, origin);
633
+ const dx = end.x - start.x;
634
+ const dy = end.y - start.y;
635
+ const distance = Math.hypot(dx, dy) || 1;
636
+ const dashed = arrowOptions.style === "dotted";
637
+ let tipAngle = Math.atan2(dy, dx);
638
+ let shaft;
639
+ const hull = [start, end];
640
+ if (arrowOptions.style === "straight") {
641
+ shaft = roughLine(start.x, start.y, end.x, end.y, style.roughness, random, {
642
+ bow: (random() - 0.5) * style.roughness
643
+ });
644
+ } else {
645
+ const perpendicular = { x: -dy / distance, y: dx / distance };
646
+ const mid = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
647
+ const towardCenter = {
648
+ x: rect.x + rect.width / 2 - mid.x,
649
+ y: rect.y + rect.height / 2 - mid.y
650
+ };
651
+ const sign = perpendicular.x * towardCenter.x + perpendicular.y * towardCenter.y > 0 ? -1 : 1;
652
+ const bow = Math.min(distance * (0.16 + random() * 0.08), 30) * sign;
653
+ const c1x = start.x + dx * 0.28 + perpendicular.x * bow;
654
+ const c1y = start.y + dy * 0.28 + perpendicular.y * bow;
655
+ const c2x = start.x + dx * 0.72 + perpendicular.x * bow * 0.85;
656
+ const c2y = start.y + dy * 0.72 + perpendicular.y * bow * 0.85;
657
+ hull.push({ x: c1x, y: c1y }, { x: c2x, y: c2y });
658
+ shaft = `M ${fmt(start.x)} ${fmt(start.y)} C ${fmt(c1x)} ${fmt(c1y)} ${fmt(c2x)} ${fmt(c2y)} ${fmt(end.x)} ${fmt(end.y)}`;
659
+ if (style.roughness >= 1.1) {
660
+ const drift = () => (random() - 0.5) * 1.4;
661
+ 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)}`;
662
+ appendPath(svg, ghost, {
663
+ ...style,
664
+ opacity: style.opacity * 0.3,
665
+ strokeWidth: style.strokeWidth * 0.7,
666
+ dashed
667
+ });
668
+ }
669
+ tipAngle = Math.atan2(end.y - c2y, end.x - c2x);
670
+ }
671
+ appendPath(svg, shaft, { ...style, dashed });
672
+ const headLength = 9 + style.strokeWidth * 1.4;
673
+ const spread = 0.46 + random() * 0.1;
674
+ for (const side of [-1, 1]) {
675
+ const angle = tipAngle + spread * side;
676
+ const tail = {
677
+ x: end.x - headLength * Math.cos(angle),
678
+ y: end.y - headLength * Math.sin(angle)
679
+ };
680
+ appendPath(
681
+ svg,
682
+ roughLine(tail.x, tail.y, end.x, end.y, style.roughness * 0.5, random, {
683
+ steps: 2,
684
+ bow: 0.35 * side
685
+ }),
686
+ { ...style, dashed: false }
687
+ );
688
+ }
689
+ const xs = hull.map((p) => p.x);
690
+ const ys = hull.map((p) => p.y);
691
+ const bounds = {
692
+ x: Math.min(...xs),
693
+ y: Math.min(...ys),
694
+ width: Math.max(...xs) - Math.min(...xs),
695
+ height: Math.max(...ys) - Math.min(...ys)
696
+ };
697
+ return { start, end, bounds };
698
+ }
699
+ function drawUnderline(svg, x, y, width, style, random) {
700
+ if (width <= 4) return;
701
+ const slope = (random() - 0.5) * 2.4;
702
+ appendPath(
703
+ svg,
704
+ roughLine(x, y, x + width, y + slope, style.roughness * 0.6, random, {
705
+ bow: (random() - 0.5) * 1.4,
706
+ steps: 5
707
+ }),
708
+ {
709
+ color: style.color,
710
+ strokeWidth: style.strokeWidth * 0.95,
711
+ opacity: style.opacity * 0.9
712
+ }
713
+ );
714
+ const inset = width * (0.06 + random() * 0.12);
715
+ const length = width * (0.62 + random() * 0.24);
716
+ appendPath(
717
+ svg,
718
+ roughLine(
719
+ x + inset,
720
+ y + 2.6,
721
+ x + Math.min(width, inset + length),
722
+ y + 2.6 + slope * 0.6,
723
+ style.roughness * 0.5,
724
+ random,
725
+ { bow: (random() - 0.5) * 1.1, steps: 4 }
726
+ ),
727
+ {
728
+ color: style.color,
729
+ strokeWidth: style.strokeWidth * 0.7,
730
+ opacity: style.opacity * 0.38
731
+ }
732
+ );
733
+ }
734
+ function createNoteText(svg, text, style) {
735
+ const fontSize = style.fontSize ?? NOTE_FONT_SIZE;
736
+ const group = document.createElementNS(SVG_NS, "g");
737
+ const textEl = document.createElementNS(SVG_NS, "text");
738
+ textEl.textContent = text;
739
+ textEl.setAttribute("x", "0");
740
+ textEl.setAttribute("y", "0");
741
+ textEl.setAttribute("fill", style.color);
742
+ textEl.setAttribute("opacity", String(style.opacity));
743
+ textEl.setAttribute("font-size", String(fontSize));
744
+ textEl.setAttribute("font-family", HANDWRITTEN_FONT);
745
+ textEl.setAttribute("font-weight", "600");
746
+ textEl.setAttribute("letter-spacing", "0.4");
747
+ textEl.setAttribute("text-anchor", "start");
748
+ textEl.setAttribute("dominant-baseline", "auto");
749
+ group.appendChild(textEl);
750
+ svg.appendChild(group);
751
+ let width = 0;
752
+ try {
753
+ const box = textEl.getBBox();
754
+ if (box && box.width > 0) width = box.width;
755
+ } catch {
756
+ }
757
+ if (!width) {
758
+ try {
759
+ const measured = textEl.getComputedTextLength();
760
+ if (Number.isFinite(measured) && measured > 0) width = measured;
761
+ } catch {
762
+ }
763
+ }
764
+ if (!width) width = text.length * fontSize * 0.45;
765
+ const ascent = fontSize * 0.74;
766
+ const descent = fontSize * 0.26;
767
+ return { group, textEl, width, ascent, descent, fontSize };
768
+ }
769
+ function placeNoteText(note, box, style, seed, options = {}) {
770
+ const random = createRandom(seed + 55);
771
+ const tilt = options.tilt ?? (random() - 0.5) * 7;
772
+ note.textEl.setAttribute("x", fmt(box.x));
773
+ note.textEl.setAttribute("y", fmt(box.y + note.ascent));
774
+ const cx = box.x + box.width / 2;
775
+ const cy = box.y + box.height / 2;
776
+ note.group.setAttribute(
777
+ "transform",
778
+ `rotate(${tilt.toFixed(2)} ${fmt(cx)} ${fmt(cy)})`
779
+ );
780
+ if (options.underline !== false) {
781
+ drawUnderline(
782
+ note.group,
783
+ box.x + 1,
784
+ box.y + box.height + 2,
785
+ box.width - 2,
786
+ {
787
+ color: style.color,
788
+ strokeWidth: style.strokeWidth || 1.4,
789
+ roughness: style.roughness || 1.4,
790
+ opacity: style.opacity
791
+ },
792
+ random
793
+ );
794
+ }
795
+ return note.group;
796
+ }
797
+ function createOverlaySvg(left, top, width, height) {
798
+ const svg = document.createElementNS(SVG_NS, "svg");
799
+ svg.setAttribute("class", "doodle-ui-overlay");
800
+ svg.setAttribute("xmlns", SVG_NS);
801
+ svg.setAttribute("width", String(width));
802
+ svg.setAttribute("height", String(height));
803
+ svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
804
+ svg.style.position = "fixed";
805
+ svg.style.left = `${left}px`;
806
+ svg.style.top = `${top}px`;
807
+ svg.style.width = `${width}px`;
808
+ svg.style.height = `${height}px`;
809
+ svg.style.pointerEvents = "none";
810
+ svg.style.overflow = "visible";
811
+ svg.style.zIndex = "2147483646";
812
+ return svg;
813
+ }
814
+
815
+ // src/annotations.js
816
+ var NOTE_GAP = 14;
817
+ function mirror(position, axis) {
818
+ if (axis === "x") {
819
+ if (position.includes("right")) return position.replace("right", "left");
820
+ if (position.includes("left")) return position.replace("left", "right");
821
+ return position;
822
+ }
823
+ if (position.includes("top")) return position.replace("top", "bottom");
824
+ if (position.includes("bottom")) return position.replace("bottom", "top");
825
+ return position;
826
+ }
827
+ function candidatePositions(position) {
828
+ const candidates = [
829
+ position,
830
+ mirror(position, "x"),
831
+ mirror(position, "y"),
832
+ mirror(mirror(position, "x"), "y")
833
+ ];
834
+ return candidates.filter((value, index) => candidates.indexOf(value) === index);
835
+ }
836
+ function boxForPosition(rect, position, size, gap) {
837
+ const direction = directionFromPosition(position);
838
+ const guard = inflateRect(rect, gap);
839
+ const cx = rect.x + rect.width / 2;
840
+ const cy = rect.y + rect.height / 2;
841
+ let x;
842
+ let y;
843
+ if (direction.y !== 0) {
844
+ y = direction.y > 0 ? guard.y + guard.height : guard.y - size.height;
845
+ if (direction.x > 0) x = guard.x + guard.width - size.width * 0.35;
846
+ else if (direction.x < 0) x = guard.x - size.width * 0.65;
847
+ else x = cx - size.width / 2;
848
+ } else {
849
+ x = direction.x > 0 ? guard.x + guard.width : guard.x - size.width;
850
+ y = cy - size.height / 2;
851
+ }
852
+ return { x, y, width: size.width, height: size.height };
853
+ }
854
+ function placeNote(rect, position, size, gap, viewport) {
855
+ const guard = inflateRect(rect, gap * 0.7);
856
+ let best = null;
857
+ candidatePositions(position).forEach((candidate, index) => {
858
+ const box = boxForPosition(rect, candidate, size, gap);
859
+ const penalty = intersectionArea(box, guard) * 6 + outsideArea(box, viewport) + index * 0.5;
860
+ if (!best || penalty < best.penalty) {
861
+ best = { box, penalty, position: candidate };
862
+ }
863
+ });
864
+ return best;
865
+ }
866
+ function clampToViewport(box, guard, viewport) {
867
+ if (!viewport || viewport.width <= 0 || viewport.height <= 0) return box;
868
+ const shifted = {
869
+ ...box,
870
+ x: clamp(
871
+ box.x,
872
+ viewport.x,
873
+ Math.max(viewport.x, viewport.x + viewport.width - box.width)
874
+ ),
875
+ y: clamp(
876
+ box.y,
877
+ viewport.y,
878
+ Math.max(viewport.y, viewport.y + viewport.height - box.height)
879
+ )
880
+ };
881
+ if (intersectionArea(shifted, guard) > intersectionArea(box, guard)) {
882
+ return box;
883
+ }
884
+ return shifted;
885
+ }
886
+ function renderAnnotation(svg, rect, noteOptions, style, seed = 1, layout = {}) {
887
+ if (!noteOptions?.text) return null;
888
+ const viewport = layout.viewport ?? null;
889
+ const position = isValidPosition(noteOptions.position) ? noteOptions.position : "top-right";
890
+ const random = createRandom(seed + 9);
891
+ const note = createNoteText(svg, noteOptions.text, style);
892
+ const tilt = (random() - 0.45) * 7;
893
+ const tiltPad = Math.abs(Math.sin(tilt * Math.PI / 180)) * note.width * 0.5;
894
+ const size = {
895
+ width: note.width,
896
+ height: note.ascent + note.descent + (noteOptions.underline === false ? 0 : 5)
897
+ };
898
+ const gap = (layout.gap ?? NOTE_GAP) + random() * 6;
899
+ const placed = placeNote(
900
+ rect,
901
+ position,
902
+ { width: size.width, height: size.height + tiltPad },
903
+ gap,
904
+ viewport
905
+ );
906
+ const guard = inflateRect(rect, gap * 0.7);
907
+ const outer = clampToViewport(
908
+ { ...placed.box, height: size.height + tiltPad },
909
+ guard,
910
+ viewport
911
+ );
912
+ const box = {
913
+ x: outer.x,
914
+ y: outer.y + tiltPad / 2,
915
+ width: size.width,
916
+ height: note.ascent + note.descent
917
+ };
918
+ placeNoteText(note, box, style, seed, {
919
+ tilt,
920
+ underline: noteOptions.underline !== false
921
+ });
922
+ return { group: note.group, box: inflateRect(outer, 4), position: placed.position };
923
+ }
924
+
925
+ // src/decorations.js
926
+ function drawDot(parent, x, y, style, scale = 1.7) {
927
+ appendPath(parent, `M ${x.toFixed(2)} ${y.toFixed(2)} l 0.01 0`, {
928
+ ...style,
929
+ strokeWidth: style.strokeWidth * scale
930
+ });
931
+ }
932
+ function drawHeart(parent, size, style, random) {
933
+ const s = size;
934
+ const j = () => (random() - 0.5) * s * 0.12;
935
+ const path = [
936
+ `M 0 ${(s * 0.95).toFixed(2)}`,
937
+ `C ${(-s * 1.16 + j()).toFixed(2)} ${(s * 0.08).toFixed(2)}, ${(-s * 0.94 + j()).toFixed(2)} ${(-s * 0.78).toFixed(2)}, 0 ${(-s * 0.3).toFixed(2)}`,
938
+ `C ${(s * 0.94 + j()).toFixed(2)} ${(-s * 0.78).toFixed(2)}, ${(s * 1.16 + j()).toFixed(2)} ${(s * 0.08).toFixed(2)}, 0 ${(s * 0.95).toFixed(2)}`,
939
+ "Z"
940
+ ].join(" ");
941
+ appendPath(parent, path, style);
942
+ }
943
+ function twinkle(parent, radiusX, radiusY, pinch, style) {
944
+ const rx = radiusX;
945
+ const ry = radiusY;
946
+ const c = pinch;
947
+ const path = [
948
+ `M 0 ${(-ry).toFixed(2)}`,
949
+ `Q ${c.toFixed(2)} ${(-c).toFixed(2)} ${rx.toFixed(2)} 0`,
950
+ `Q ${c.toFixed(2)} ${c.toFixed(2)} 0 ${ry.toFixed(2)}`,
951
+ `Q ${(-c).toFixed(2)} ${c.toFixed(2)} ${(-rx).toFixed(2)} 0`,
952
+ `Q ${(-c).toFixed(2)} ${(-c).toFixed(2)} 0 ${(-ry).toFixed(2)}`,
953
+ "Z"
954
+ ].join(" ");
955
+ appendPath(parent, path, style);
956
+ }
957
+ function drawSparkle(parent, size, style, random) {
958
+ twinkle(
959
+ parent,
960
+ size * (0.34 + random() * 0.1),
961
+ size,
962
+ size * (0.06 + random() * 0.06),
963
+ style
964
+ );
965
+ }
966
+ function drawStar(parent, size, style, random) {
967
+ twinkle(
968
+ parent,
969
+ size * (0.66 + random() * 0.12),
970
+ size,
971
+ size * (0.2 + random() * 0.08),
972
+ style
973
+ );
974
+ }
975
+ function drawTwinkle(parent, size, style, random) {
976
+ const count = 5;
977
+ const first = -Math.PI / 2 + (random() - 0.5) * 0.5;
978
+ const at = (angle, radius) => ({
979
+ x: Math.cos(angle) * radius,
980
+ y: Math.sin(angle) * radius
981
+ });
982
+ const tips = [];
983
+ for (let i = 0; i < count; i++) {
984
+ const angle = first + Math.PI * 2 * i / count + (random() - 0.5) * 0.24;
985
+ tips.push({ angle, ...at(angle, size * (0.8 + random() * 0.4)) });
986
+ }
987
+ let path = `M ${tips[0].x.toFixed(2)} ${tips[0].y.toFixed(2)}`;
988
+ for (let i = 0; i < count; i++) {
989
+ const from = tips[i];
990
+ const to = tips[(i + 1) % count];
991
+ let delta = to.angle - from.angle;
992
+ while (delta <= 0) delta += Math.PI * 2;
993
+ const control = at(
994
+ from.angle + delta / 2 + (random() - 0.5) * 0.16,
995
+ size * (0.02 + random() * 0.13)
996
+ );
997
+ path += ` Q ${control.x.toFixed(2)} ${control.y.toFixed(2)} ${to.x.toFixed(2)} ${to.y.toFixed(2)}`;
998
+ }
999
+ path += " Z";
1000
+ const filled = appendPath(parent, path, {
1001
+ ...style,
1002
+ fill: style.color,
1003
+ strokeWidth: style.strokeWidth * 0.45
1004
+ });
1005
+ filled.setAttribute("stroke-linejoin", "miter");
1006
+ filled.setAttribute("stroke-linecap", "butt");
1007
+ }
1008
+ function drawArcs(parent, size, style, random) {
1009
+ const count = 2 + Math.floor(random() * 2);
1010
+ const focus = size * 0.55;
1011
+ for (let i = 0; i < count; i++) {
1012
+ const radius = size * (0.75 + i * 0.4);
1013
+ const sweep = 0.8 + random() * 0.3 - i * 0.08;
1014
+ const steps = 7;
1015
+ const points = [];
1016
+ for (let s = 0; s <= steps; s++) {
1017
+ const angle = -Math.PI / 2 - sweep / 2 + sweep * (s / steps);
1018
+ const wobble = radius + (random() - 0.5) * size * 0.06;
1019
+ points.push({
1020
+ x: Math.cos(angle) * wobble,
1021
+ y: focus + Math.sin(angle) * wobble
1022
+ });
1023
+ }
1024
+ appendPath(parent, catmullRomPath(points, false), {
1025
+ ...style,
1026
+ strokeWidth: style.strokeWidth * (1 - i * 0.12),
1027
+ opacity: style.opacity * (1 - i * 0.14)
1028
+ });
1029
+ }
1030
+ }
1031
+ function drawSmiley(parent, size, style, random) {
1032
+ const r = size * 0.92;
1033
+ const circle = generateHandDrawnRectPaths(
1034
+ -r,
1035
+ -r,
1036
+ r * 2,
1037
+ r * 2,
1038
+ r,
1039
+ style.roughness * 0.7,
1040
+ random,
1041
+ {
1042
+ amplitude: style.roughness * 0.22,
1043
+ overshoot: r * 0.55,
1044
+ spacing: Math.max(3, r * 0.45),
1045
+ startAt: random()
1046
+ }
1047
+ );
1048
+ for (const d of circle) {
1049
+ appendPath(parent, d, { ...style, strokeWidth: style.strokeWidth * 0.9 });
1050
+ }
1051
+ drawDot(parent, -r * 0.36, -r * 0.22, style, 1.5);
1052
+ drawDot(parent, r * 0.36, -r * 0.22, style, 1.5);
1053
+ appendPath(
1054
+ parent,
1055
+ `M ${(-r * 0.44).toFixed(2)} ${(r * 0.18).toFixed(2)} Q 0 ${(r * 0.72).toFixed(2)} ${(r * 0.44).toFixed(2)} ${(r * 0.18).toFixed(2)}`,
1056
+ style
1057
+ );
1058
+ }
1059
+ function drawEmphasis(parent, size, style, random, opts = {}) {
1060
+ const count = opts.count ?? 3 + Math.floor(random() * 2);
1061
+ const spread = opts.spread ?? 0.62 + random() * 0.3;
1062
+ for (let i = 0; i < count; i++) {
1063
+ const t = count === 1 ? 0 : i / (count - 1) - 0.5;
1064
+ const angle = -Math.PI / 2 + t * spread * 2;
1065
+ const inner = size * (0.34 + random() * 0.12);
1066
+ const outer = size * (0.86 + random() * 0.36);
1067
+ appendPath(
1068
+ parent,
1069
+ roughLine(
1070
+ Math.cos(angle) * inner,
1071
+ Math.sin(angle) * inner,
1072
+ Math.cos(angle) * outer,
1073
+ Math.sin(angle) * outer,
1074
+ 0.7,
1075
+ random,
1076
+ { steps: 2, bow: (random() - 0.5) * 0.9 }
1077
+ ),
1078
+ { ...style, strokeWidth: style.strokeWidth * (0.85 + random() * 0.4) }
1079
+ );
1080
+ }
1081
+ }
1082
+ function drawDots(parent, size, style, random) {
1083
+ let x = -size * 0.7;
1084
+ for (let i = 0; i < 3; i++) {
1085
+ drawDot(parent, x, (random() - 0.5) * size * 0.16, style);
1086
+ x += size * (0.6 + random() * 0.25);
1087
+ }
1088
+ }
1089
+ function drawStroke(parent, size, style, random) {
1090
+ appendPath(
1091
+ parent,
1092
+ roughLine(-size, 0, size, 0, 1.2, random, {
1093
+ bow: (random() - 0.5) * size * 0.35,
1094
+ steps: 4
1095
+ }),
1096
+ style
1097
+ );
1098
+ }
1099
+ function drawSteam(parent, size, style, random) {
1100
+ const lean = random() < 0.5 ? -1 : 1;
1101
+ for (let i = 0; i < 2; i++) {
1102
+ const x = (i - 0.5) * size * 0.72;
1103
+ const base = size * 0.62 - i * size * 0.14;
1104
+ appendPath(
1105
+ parent,
1106
+ `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)}`,
1107
+ style
1108
+ );
1109
+ }
1110
+ }
1111
+ var DECORATION_DRAWERS = {
1112
+ heart: drawHeart,
1113
+ sparkle: drawSparkle,
1114
+ star: drawStar,
1115
+ twinkle: drawTwinkle,
1116
+ smiley: drawSmiley,
1117
+ emphasis: drawEmphasis,
1118
+ arcs: drawArcs,
1119
+ dots: drawDots,
1120
+ stroke: drawStroke,
1121
+ steam: drawSteam
1122
+ };
1123
+ var TEXT_TYPES = ["heart", "sparkle"];
1124
+ var CORNER_ACCENTS = ["arcs", "emphasis"];
1125
+ var CORNER_STARS = ["twinkle", "star"];
1126
+ var MAX_BORDER_MARKS = 2;
1127
+ var TYPE_TRAITS = {
1128
+ heart: { size: [7, 10], tilt: 14, standoff: 22 },
1129
+ sparkle: { size: [6, 9], tilt: 20, standoff: 22 },
1130
+ star: { size: [8, 11], tilt: 25, standoff: 26 },
1131
+ twinkle: { size: [5, 7.5], tilt: 30, standoff: 26, brightness: 1.4 },
1132
+ smiley: { size: [8, 11], tilt: 10, standoff: 22 },
1133
+ emphasis: { size: [12, 17], tilt: 10, standoff: 13, outward: true },
1134
+ arcs: { size: [11, 16], tilt: 12, standoff: 12, outward: true },
1135
+ dots: { size: [6, 9], tilt: 8, standoff: 20 },
1136
+ stroke: { size: [8, 12], tilt: 40, standoff: 20 },
1137
+ steam: { size: [8, 11], tilt: 10, standoff: 20 }
1138
+ };
1139
+ var DEFAULT_TYPES = [
1140
+ "twinkle",
1141
+ "arcs",
1142
+ "emphasis",
1143
+ "heart",
1144
+ "sparkle"
1145
+ ];
1146
+ function isClear(point, radius, blockers) {
1147
+ const box = {
1148
+ x: point.x - radius,
1149
+ y: point.y - radius,
1150
+ width: radius * 2,
1151
+ height: radius * 2
1152
+ };
1153
+ return !blockers.some((blocker) => intersectionArea(box, blocker) > 0);
1154
+ }
1155
+ function pickFrom(types, family, random) {
1156
+ const pool = types.filter(
1157
+ (type) => family.includes(type) && DECORATION_DRAWERS[type]
1158
+ );
1159
+ if (!pool.length) return null;
1160
+ return pool[Math.floor(random() * pool.length)];
1161
+ }
1162
+ function sizeFrom(range, random) {
1163
+ return range[0] + random() * (range[1] - range[0]);
1164
+ }
1165
+ function markOpacity(style, traits, variation) {
1166
+ return clamp(style.opacity * variation * (traits.brightness ?? 1), 0, 1);
1167
+ }
1168
+ function cornerAnchors(rect) {
1169
+ const radius = clamp(
1170
+ rect.radius ?? 0,
1171
+ 0,
1172
+ Math.min(rect.width, rect.height) / 2
1173
+ );
1174
+ const d = Math.SQRT1_2;
1175
+ const right = rect.x + rect.width - radius;
1176
+ const bottom = rect.y + rect.height - radius;
1177
+ const left = rect.x + radius;
1178
+ const top = rect.y + radius;
1179
+ return [
1180
+ { cx: left, cy: top, dir: { x: -d, y: -d } },
1181
+ { cx: right, cy: top, dir: { x: d, y: -d } },
1182
+ { cx: right, cy: bottom, dir: { x: d, y: d } },
1183
+ { cx: left, cy: bottom, dir: { x: -d, y: d } }
1184
+ ].map((anchor) => ({ ...anchor, radius }));
1185
+ }
1186
+ function cornerPoint(anchor, standoff) {
1187
+ return {
1188
+ x: anchor.cx + anchor.dir.x * (anchor.radius + standoff),
1189
+ y: anchor.cy + anchor.dir.y * (anchor.radius + standoff)
1190
+ };
1191
+ }
1192
+ function outwardTilt(dir) {
1193
+ return Math.atan2(dir.y, dir.x) * 180 / Math.PI + 90;
1194
+ }
1195
+ function placeAtCorner(svg, type, anchor, style, random, hasRoom) {
1196
+ const traits = TYPE_TRAITS[type] ?? { size: [8, 12], tilt: 15 };
1197
+ const size = sizeFrom(traits.size, random);
1198
+ const point = cornerPoint(anchor, traits.standoff ?? 20);
1199
+ const needed = (traits.outward ? size * 0.35 : size) + 4;
1200
+ if (!hasRoom(point, needed)) return false;
1201
+ const tilt = traits.outward ? outwardTilt(anchor.dir) + (random() - 0.5) * traits.tilt : (random() - 0.5) * traits.tilt;
1202
+ DECORATION_DRAWERS[type](
1203
+ createGroup(svg, point.x, point.y, tilt),
1204
+ size,
1205
+ { ...style, opacity: markOpacity(style, traits, 0.8 + random() * 0.18) },
1206
+ random
1207
+ );
1208
+ return true;
1209
+ }
1210
+ function renderCornerMarks(svg, rect, types, budget, style, random, hasRoom) {
1211
+ const accent = pickFrom(types, CORNER_ACCENTS, random);
1212
+ const star = pickFrom(types, CORNER_STARS, random);
1213
+ const picks = [accent, star].filter(Boolean);
1214
+ if (picks.length < budget) {
1215
+ const rest = types.filter(
1216
+ (type) => DECORATION_DRAWERS[type] && !TEXT_TYPES.includes(type) && !CORNER_ACCENTS.includes(type) && !CORNER_STARS.includes(type)
1217
+ );
1218
+ picks.push(...rest);
1219
+ }
1220
+ if (!picks.length) return;
1221
+ for (let i = 0; picks.length < budget; i++) {
1222
+ picks.push(picks[i % picks.length]);
1223
+ }
1224
+ const anchors = cornerAnchors(rect).map((anchor) => ({ anchor, rank: random() })).sort((a, b) => a.rank - b.rank).map((entry) => entry.anchor);
1225
+ let placed = 0;
1226
+ let previous = null;
1227
+ for (const type of picks.slice(0, budget)) {
1228
+ const ordered = previous ? [...anchors].sort(
1229
+ (a, b) => Math.hypot(b.cx - previous.cx, b.cy - previous.cy) - Math.hypot(a.cx - previous.cx, a.cy - previous.cy)
1230
+ ) : anchors;
1231
+ const anchor = ordered.find(
1232
+ (candidate) => candidate !== previous && placeAtCorner(svg, type, candidate, style, random, hasRoom)
1233
+ );
1234
+ if (anchor) {
1235
+ previous = anchor;
1236
+ placed += 1;
1237
+ if (placed >= budget) break;
1238
+ }
1239
+ }
1240
+ }
1241
+ function renderSideMarks(svg, rect, style, random, hasRoom) {
1242
+ const cy = rect.y + rect.height / 2;
1243
+ for (const side of [-1, 1]) {
1244
+ const size = 10 + random() * 4;
1245
+ const dir = { x: side, y: 0 };
1246
+ const point = {
1247
+ x: side < 0 ? rect.x - (13 + size * 0.35) : rect.x + rect.width + (13 + size * 0.35),
1248
+ y: cy + (random() - 0.5) * rect.height * 0.45
1249
+ };
1250
+ if (!hasRoom(point, size * 0.35 + 4)) continue;
1251
+ drawEmphasis(
1252
+ createGroup(
1253
+ svg,
1254
+ point.x,
1255
+ point.y,
1256
+ outwardTilt(dir) + (random() - 0.5) * 12
1257
+ ),
1258
+ size,
1259
+ { ...style, opacity: style.opacity * (0.82 + random() * 0.15) },
1260
+ random,
1261
+ { count: 2, spread: 0.3 + random() * 0.14 }
1262
+ );
1263
+ }
1264
+ }
1265
+ function renderTextMark(svg, rect, note, types, style, random) {
1266
+ if (!note) return null;
1267
+ const type = pickFrom(types, TEXT_TYPES, random);
1268
+ if (!type) return null;
1269
+ const traits = TYPE_TRAITS[type];
1270
+ const size = sizeFrom(traits.size, random);
1271
+ const trailing = note.x + note.width / 2 >= rect.x + rect.width / 2 ? 1 : -1;
1272
+ const x = trailing > 0 ? note.x + note.width + size + 2 : note.x - size - 2;
1273
+ const y = note.y + note.height * 0.44;
1274
+ DECORATION_DRAWERS[type](
1275
+ createGroup(svg, x, y, (random() - 0.5) * traits.tilt),
1276
+ size,
1277
+ { ...style, opacity: markOpacity(style, traits, 0.85 + random() * 0.15) },
1278
+ random
1279
+ );
1280
+ return {
1281
+ x: x - size - 2,
1282
+ y: y - size - 2,
1283
+ width: size * 2 + 4,
1284
+ height: size * 2 + 4
1285
+ };
1286
+ }
1287
+ function isSmall(rect) {
1288
+ return Math.min(rect.width, rect.height) < 48 || rect.width < 130;
1289
+ }
1290
+ function renderDecorations(svg, rect, decorationOptions, style, seed, context = {}) {
1291
+ const random = createRandom(seed + 101);
1292
+ const types = decorationOptions.types?.length ? decorationOptions.types : DEFAULT_TYPES;
1293
+ const avoid = [...context.avoid ?? []];
1294
+ const textMark = renderTextMark(
1295
+ svg,
1296
+ rect,
1297
+ context.note ?? null,
1298
+ types,
1299
+ style,
1300
+ random
1301
+ );
1302
+ if (textMark) avoid.push(textMark);
1303
+ const budget = Math.min(
1304
+ decorationOptions.count ?? MAX_BORDER_MARKS,
1305
+ MAX_BORDER_MARKS
1306
+ );
1307
+ if (budget < 1) return;
1308
+ const hasRoom = (point, radius) => distanceToRoundedRect(point, rect, rect.radius) >= radius && isClear(point, radius, avoid);
1309
+ const layout = decorationOptions.style ?? (isSmall(rect) && types.includes("emphasis") ? "sides" : "corners");
1310
+ if (layout === "sides") {
1311
+ renderSideMarks(svg, rect, style, random, hasRoom);
1312
+ } else {
1313
+ renderCornerMarks(svg, rect, types, budget, style, random, hasRoom);
1314
+ }
1315
+ }
1316
+
1317
+ // src/doodle.js
1318
+ var instances = /* @__PURE__ */ new WeakMap();
1319
+ function localViewport(overlayRect, inset = 8) {
1320
+ const width = window.innerWidth || document.documentElement.clientWidth || 0;
1321
+ const height = window.innerHeight || document.documentElement.clientHeight || 0;
1322
+ return {
1323
+ x: -overlayRect.x + inset,
1324
+ y: -overlayRect.y + inset,
1325
+ width: Math.max(0, width - inset * 2),
1326
+ height: Math.max(0, height - inset * 2)
1327
+ };
1328
+ }
1329
+ var DoodleOverlay = class {
1330
+ /**
1331
+ * @param {Element} element
1332
+ * @param {ReturnType<typeof mergeOptions>} options
1333
+ */
1334
+ constructor(element, options) {
1335
+ this.element = element;
1336
+ this.options = options;
1337
+ this.seed = Math.floor(Math.random() * 1e9);
1338
+ this.svg = null;
1339
+ this.resizeObserver = null;
1340
+ this.mutationObserver = null;
1341
+ this.pendingFrame = null;
1342
+ this.scrollTargets = [];
1343
+ this.onScroll = this.scheduleUpdate.bind(this);
1344
+ this.onResize = this.scheduleUpdate.bind(this);
1345
+ this.childElements = [];
1346
+ this.mount();
1347
+ }
1348
+ mount() {
1349
+ this.svg = createOverlaySvg(0, 0, 0, 0);
1350
+ document.body.appendChild(this.svg);
1351
+ this.resizeObserver = new ResizeObserver(this.onResize);
1352
+ this.resizeObserver.observe(this.element);
1353
+ if (this.options.children) {
1354
+ this.observeChildren();
1355
+ }
1356
+ this.mutationObserver = new MutationObserver(this.scheduleUpdate.bind(this));
1357
+ this.mutationObserver.observe(this.element, {
1358
+ childList: true,
1359
+ subtree: true,
1360
+ attributes: true
1361
+ });
1362
+ for (const target of getScrollableAncestors(this.element)) {
1363
+ target.addEventListener("scroll", this.onScroll, { passive: true });
1364
+ this.scrollTargets.push({ target, type: "scroll" });
1365
+ }
1366
+ window.addEventListener("resize", this.onResize, { passive: true });
1367
+ this.scrollTargets.push({ target: window, type: "resize" });
1368
+ document.fonts?.ready?.then(() => this.scheduleUpdate()).catch(() => {
1369
+ });
1370
+ this.update();
1371
+ }
1372
+ observeChildren() {
1373
+ const children = this.element.querySelectorAll(this.options.children);
1374
+ this.childElements = Array.from(children);
1375
+ for (const child of this.childElements) {
1376
+ this.resizeObserver.observe(child);
1377
+ }
1378
+ }
1379
+ scheduleUpdate() {
1380
+ if (this.pendingFrame != null) return;
1381
+ this.pendingFrame = scheduleFrame(() => {
1382
+ this.pendingFrame = null;
1383
+ this.update();
1384
+ });
1385
+ }
1386
+ getTargets() {
1387
+ if (this.options.children) {
1388
+ const nodes = this.element.querySelectorAll(this.options.children);
1389
+ return Array.from(nodes);
1390
+ }
1391
+ return [this.element];
1392
+ }
1393
+ update() {
1394
+ if (!this.svg || !this.element.isConnected) return;
1395
+ const { padding, radius, color, strokeWidth, roughness, opacity } = this.options;
1396
+ const targets = this.getTargets();
1397
+ const margin = this.overlayMargin();
1398
+ const absoluteRects = targets.map(
1399
+ (target) => getElementBounds(target, padding, radius)
1400
+ );
1401
+ const overlayRect = unionRects(absoluteRects, margin);
1402
+ clearSvg(this.svg);
1403
+ this.svg.setAttribute("width", String(overlayRect.width));
1404
+ this.svg.setAttribute("height", String(overlayRect.height));
1405
+ this.svg.setAttribute("viewBox", `0 0 ${overlayRect.width} ${overlayRect.height}`);
1406
+ this.svg.style.left = `${overlayRect.x}px`;
1407
+ this.svg.style.top = `${overlayRect.y}px`;
1408
+ this.svg.style.width = `${overlayRect.width}px`;
1409
+ this.svg.style.height = `${overlayRect.height}px`;
1410
+ const strokeStyle = { color, strokeWidth, roughness, opacity };
1411
+ const noteStyle = { color, opacity, strokeWidth, roughness };
1412
+ const noteGroups = [];
1413
+ const noteLayout = {
1414
+ viewport: localViewport(overlayRect),
1415
+ gap: this.options.arrow ? 32 : 14
1416
+ };
1417
+ targets.forEach((target, index) => {
1418
+ const absolute = absoluteRects[index];
1419
+ const local = relativeRect(overlayRect, absolute);
1420
+ const seed = this.seed + index * 31;
1421
+ const note = this.options.note && index === 0 ? renderAnnotation(
1422
+ this.svg,
1423
+ local,
1424
+ this.options.note,
1425
+ noteStyle,
1426
+ seed,
1427
+ noteLayout
1428
+ ) : null;
1429
+ if (this.options.border) {
1430
+ drawBorder(
1431
+ this.svg,
1432
+ local,
1433
+ { ...strokeStyle, breaks: this.options.addBreaks },
1434
+ seed
1435
+ );
1436
+ }
1437
+ const arrow = this.options.arrow ? drawArrow(
1438
+ this.svg,
1439
+ local,
1440
+ this.options.arrow,
1441
+ strokeStyle,
1442
+ seed,
1443
+ note ? note.box : null
1444
+ ) : null;
1445
+ if (this.options.decorations) {
1446
+ renderDecorations(
1447
+ this.svg,
1448
+ local,
1449
+ this.options.decorations,
1450
+ strokeStyle,
1451
+ seed,
1452
+ {
1453
+ avoid: [note?.box, arrow?.bounds].filter(Boolean),
1454
+ note: note?.box ?? null
1455
+ }
1456
+ );
1457
+ }
1458
+ if (note) noteGroups.push(note.group);
1459
+ });
1460
+ for (const group of noteGroups) {
1461
+ this.svg.appendChild(group);
1462
+ }
1463
+ }
1464
+ /**
1465
+ * Room the overlay needs beyond the element for notes, arrows and decorations.
1466
+ */
1467
+ overlayMargin() {
1468
+ const { note, arrow, decorations } = this.options;
1469
+ if (!note && !arrow && !decorations) return 48;
1470
+ const text = typeof note?.text === "string" ? note.text : "";
1471
+ return Math.max(96, Math.min(240, 80 + text.length * 8));
1472
+ }
1473
+ destroy() {
1474
+ if (this.pendingFrame != null) {
1475
+ cancelFrame(this.pendingFrame);
1476
+ this.pendingFrame = null;
1477
+ }
1478
+ if (this.resizeObserver) {
1479
+ this.resizeObserver.disconnect();
1480
+ this.resizeObserver = null;
1481
+ }
1482
+ if (this.mutationObserver) {
1483
+ this.mutationObserver.disconnect();
1484
+ this.mutationObserver = null;
1485
+ }
1486
+ for (const { target, type } of this.scrollTargets) {
1487
+ target.removeEventListener(type, type === "scroll" ? this.onScroll : this.onResize);
1488
+ }
1489
+ this.scrollTargets = [];
1490
+ if (this.svg?.parentNode) {
1491
+ this.svg.parentNode.removeChild(this.svg);
1492
+ }
1493
+ this.svg = null;
1494
+ instances.delete(this.element);
1495
+ }
1496
+ };
1497
+ function doodle(target, options = {}) {
1498
+ const element = resolveElement(target);
1499
+ if (!element) {
1500
+ throw new Error("doodle-ui: target element not found");
1501
+ }
1502
+ const existing = instances.get(element);
1503
+ if (existing) {
1504
+ existing.destroy();
1505
+ }
1506
+ const merged = mergeOptions(options);
1507
+ const instance = new DoodleOverlay(element, merged);
1508
+ instances.set(element, instance);
1509
+ return {
1510
+ update: () => instance.update(),
1511
+ destroy: () => instance.destroy()
1512
+ };
1513
+ }