@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.
- package/LICENSE +21 -0
- package/README.md +261 -0
- package/dist/freehand-ui.cjs +1513 -0
- package/dist/freehand-ui.js +1491 -0
- package/dist/index.d.ts +89 -0
- package/dist/react.cjs +1634 -0
- package/dist/react.d.ts +66 -0
- package/dist/react.js +1622 -0
- package/package.json +67 -0
|
@@ -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
|
+
}
|