@vanduo-oss/vd3-cbun 1.1.0 → 1.2.0

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,2216 @@
1
+ // src/draw/vue.js
2
+ import { defineComponent, h, ref, onMounted, onBeforeUnmount, watch } from "vue";
3
+
4
+ // src/draw/shapes.js
5
+ var VD_DRAW_VERSION = "1.1.0";
6
+ var DRAW_TOOLS = Object.freeze([
7
+ "select",
8
+ "hand",
9
+ "draw",
10
+ "eraser",
11
+ "rectangle",
12
+ "ellipse",
13
+ "line",
14
+ "text",
15
+ "sticky"
16
+ ]);
17
+ var DRAW_SHAPE_TYPES = Object.freeze([
18
+ "rectangle",
19
+ "ellipse",
20
+ "line",
21
+ "freehand",
22
+ "text",
23
+ "sticky"
24
+ ]);
25
+ var BRUSH_PRESETS = Object.freeze({
26
+ pen: {
27
+ size: 6,
28
+ thinning: 0.55,
29
+ smoothing: 0.5,
30
+ streamline: 0.5,
31
+ taperStart: 0,
32
+ taperEnd: 14,
33
+ opacity: 1,
34
+ blend: "normal"
35
+ },
36
+ pencil: {
37
+ size: 4,
38
+ thinning: 0.7,
39
+ smoothing: 0.35,
40
+ streamline: 0.35,
41
+ taperStart: 4,
42
+ taperEnd: 10,
43
+ opacity: 0.92,
44
+ blend: "normal"
45
+ },
46
+ marker: {
47
+ size: 14,
48
+ thinning: 0.15,
49
+ smoothing: 0.55,
50
+ streamline: 0.5,
51
+ taperStart: 0,
52
+ taperEnd: 0,
53
+ opacity: 0.85,
54
+ blend: "normal"
55
+ },
56
+ highlighter: {
57
+ size: 22,
58
+ thinning: 0,
59
+ smoothing: 0.6,
60
+ streamline: 0.55,
61
+ taperStart: 0,
62
+ taperEnd: 0,
63
+ opacity: 0.4,
64
+ blend: "multiply"
65
+ },
66
+ calligraphy: {
67
+ size: 12,
68
+ thinning: 0.6,
69
+ smoothing: 0.4,
70
+ streamline: 0.3,
71
+ taperStart: 8,
72
+ taperEnd: 12,
73
+ opacity: 1,
74
+ nibAngle: -0.7,
75
+ blend: "normal"
76
+ }
77
+ });
78
+ var DEFAULT_BRUSH = "pen";
79
+ var POINT_TYPES = Object.freeze(["line", "freehand"]);
80
+ var MIN_SCALE = 0.2;
81
+ var MAX_SCALE = 4;
82
+ var DEFAULT_GRID_SIZE = 20;
83
+ var DEFAULT_STROKE_WIDTH = 2;
84
+ var idCounter = 0;
85
+ function createId(prefix = "sh") {
86
+ idCounter += 1;
87
+ return `${prefix}_${idCounter.toString(36)}${(idCounter * 2654435761).toString(36).slice(-4)}`;
88
+ }
89
+ function clamp(value, min, max) {
90
+ return Math.min(max, Math.max(min, value));
91
+ }
92
+ function round(value) {
93
+ return Math.round((Number(value) || 0) * 100) / 100;
94
+ }
95
+ function deepClone(value) {
96
+ if (typeof structuredClone === "function") {
97
+ try {
98
+ return structuredClone(value);
99
+ } catch {
100
+ }
101
+ }
102
+ return JSON.parse(JSON.stringify(value));
103
+ }
104
+ function roundPoint(p) {
105
+ const out = [round(p[0]), round(p[1])];
106
+ if (p.length >= 3 && p[2] != null) out.push(clamp(Number(p[2]) || 0, 0, 1));
107
+ return out;
108
+ }
109
+ function isPointShape(shape) {
110
+ return POINT_TYPES.includes(shape.type);
111
+ }
112
+ function shapeBounds(shape) {
113
+ if (isPointShape(shape)) {
114
+ const pts = shape.points || [];
115
+ if (!pts.length) return { x: shape.x || 0, y: shape.y || 0, w: 0, h: 0 };
116
+ let minX = Infinity;
117
+ let minY = Infinity;
118
+ let maxX = -Infinity;
119
+ let maxY = -Infinity;
120
+ for (const [px, py] of pts) {
121
+ if (px < minX) minX = px;
122
+ if (py < minY) minY = py;
123
+ if (px > maxX) maxX = px;
124
+ if (py > maxY) maxY = py;
125
+ }
126
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
127
+ }
128
+ return { x: shape.x || 0, y: shape.y || 0, w: shape.w || 0, h: shape.h || 0 };
129
+ }
130
+ function boundsOfShapes(shapes) {
131
+ if (!shapes || !shapes.length) return null;
132
+ let minX = Infinity;
133
+ let minY = Infinity;
134
+ let maxX = -Infinity;
135
+ let maxY = -Infinity;
136
+ for (const shape of shapes) {
137
+ const b = shapeBounds(shape);
138
+ minX = Math.min(minX, b.x);
139
+ minY = Math.min(minY, b.y);
140
+ maxX = Math.max(maxX, b.x + b.w);
141
+ maxY = Math.max(maxY, b.y + b.h);
142
+ }
143
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
144
+ }
145
+ function boundsIntersect(a, b) {
146
+ return !(a.x + a.w < b.x || b.x + b.w < a.x || a.y + a.h < b.y || b.y + b.h < a.y);
147
+ }
148
+ function distanceToShape(shape, x, y) {
149
+ if (isPointShape(shape)) {
150
+ const pts = shape.points || [];
151
+ if (pts.length === 1) return Math.hypot(pts[0][0] - x, pts[0][1] - y);
152
+ let min = Infinity;
153
+ for (let i = 1; i < pts.length; i += 1) {
154
+ min = Math.min(min, pointToSegment(x, y, pts[i - 1], pts[i]));
155
+ }
156
+ return min;
157
+ }
158
+ const b = shapeBounds(shape);
159
+ const dx = Math.max(b.x - x, 0, x - (b.x + b.w));
160
+ const dy = Math.max(b.y - y, 0, y - (b.y + b.h));
161
+ return Math.hypot(dx, dy);
162
+ }
163
+ function pointToSegment(px, py, a, b) {
164
+ const vx = b[0] - a[0];
165
+ const vy = b[1] - a[1];
166
+ const wx = px - a[0];
167
+ const wy = py - a[1];
168
+ const c1 = vx * wx + vy * wy;
169
+ if (c1 <= 0) return Math.hypot(px - a[0], py - a[1]);
170
+ const c2 = vx * vx + vy * vy;
171
+ if (c2 <= c1) return Math.hypot(px - b[0], py - b[1]);
172
+ const t = c1 / c2;
173
+ return Math.hypot(px - (a[0] + t * vx), py - (a[1] + t * vy));
174
+ }
175
+ function translateShape(shape, dx, dy) {
176
+ const next = deepClone(shape);
177
+ if (isPointShape(next)) {
178
+ next.points = (next.points || []).map((p) => roundPoint([p[0] + dx, p[1] + dy, p[2]]));
179
+ } else {
180
+ next.x = round((next.x || 0) + dx);
181
+ next.y = round((next.y || 0) + dy);
182
+ }
183
+ return next;
184
+ }
185
+ function scaleShape(shape, ox, oy, sx, sy) {
186
+ const next = deepClone(shape);
187
+ const avg = (Math.abs(sx) + Math.abs(sy)) * 0.5;
188
+ if (isPointShape(next)) {
189
+ next.points = (next.points || []).map(
190
+ (p) => roundPoint([ox + (p[0] - ox) * sx, oy + (p[1] - oy) * sy, p[2]])
191
+ );
192
+ if (typeof next.size === "number") next.size = round(Math.max(1, next.size * avg));
193
+ if (typeof next.strokeWidth === "number") next.strokeWidth = round(next.strokeWidth * avg);
194
+ } else {
195
+ next.x = round(ox + ((next.x || 0) - ox) * sx);
196
+ next.y = round(oy + ((next.y || 0) - oy) * sy);
197
+ next.w = round(Math.max(1, (next.w || 0) * sx));
198
+ next.h = round(Math.max(1, (next.h || 0) * sy));
199
+ }
200
+ return next;
201
+ }
202
+ function resizeHandlePositions(bounds) {
203
+ const { x, y, w, h: h2 } = bounds;
204
+ const mx = x + w / 2;
205
+ const my = y + h2 / 2;
206
+ return [
207
+ { key: "nw", x, y },
208
+ { key: "n", x: mx, y },
209
+ { key: "ne", x: x + w, y },
210
+ { key: "e", x: x + w, y: my },
211
+ { key: "se", x: x + w, y: y + h2 },
212
+ { key: "s", x: mx, y: y + h2 },
213
+ { key: "sw", x, y: y + h2 },
214
+ { key: "w", x, y: my }
215
+ ];
216
+ }
217
+ function pointsToPath(points) {
218
+ if (!points || !points.length) return "";
219
+ const [first, ...rest] = points;
220
+ let d = `M ${round(first[0])} ${round(first[1])}`;
221
+ for (const [px, py] of rest) d += ` L ${round(px)} ${round(py)}`;
222
+ return d;
223
+ }
224
+ function simplifyPoints(points, min = 1.2) {
225
+ if (!points || points.length <= 2) return (points || []).map(roundPoint);
226
+ const out = [roundPoint(points[0])];
227
+ for (let i = 1; i < points.length; i += 1) {
228
+ const p = points[i];
229
+ const last = out[out.length - 1];
230
+ if (Math.hypot(p[0] - last[0], p[1] - last[1]) >= min || i === points.length - 1)
231
+ out.push(roundPoint(p));
232
+ }
233
+ return out;
234
+ }
235
+ function streamlinePoints(points, streamline) {
236
+ const first = points[0];
237
+ const out = [[first[0], first[1], first[2] == null ? 0.5 : clamp(first[2], 0, 1)]];
238
+ if (points.length < 2) return out;
239
+ const factor = clamp(1 - (streamline == null ? 0.5 : streamline), 0.15, 1);
240
+ for (let i = 1; i < points.length; i += 1) {
241
+ const prev = out[out.length - 1];
242
+ const p = points[i];
243
+ out.push([
244
+ prev[0] + (p[0] - prev[0]) * factor,
245
+ prev[1] + (p[1] - prev[1]) * factor,
246
+ p[2] == null ? 0.5 : clamp(p[2], 0, 1)
247
+ ]);
248
+ }
249
+ return out;
250
+ }
251
+ function circlePolygon(cx, cy, r, segments = 16) {
252
+ const pts = [];
253
+ for (let i = 0; i < segments; i += 1) {
254
+ const a = i / segments * Math.PI * 2;
255
+ pts.push([cx + Math.cos(a) * r, cy + Math.sin(a) * r]);
256
+ }
257
+ return pts;
258
+ }
259
+ function velocityPressure(segmentLength) {
260
+ return clamp(1 - segmentLength / 28, 0.25, 1);
261
+ }
262
+ function strokeOutline(rawPoints, options = {}) {
263
+ const points = (rawPoints || []).filter((p) => Array.isArray(p) && p.length >= 2);
264
+ if (points.length === 0) return [];
265
+ const size = Math.max(1, options.size == null ? 8 : options.size);
266
+ const thinning = options.thinning == null ? 0.5 : options.thinning;
267
+ const taperStart = options.taperStart || 0;
268
+ const taperEnd = options.taperEnd || 0;
269
+ const nibAngle = options.nibAngle;
270
+ const pts = streamlinePoints(points, options.streamline);
271
+ if (pts.length === 1) return circlePolygon(pts[0][0], pts[0][1], size / 2);
272
+ const segLen = [];
273
+ let total = 0;
274
+ for (let i = 1; i < pts.length; i += 1) {
275
+ const d = Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]);
276
+ segLen.push(d);
277
+ total += d;
278
+ }
279
+ const hasPressure = points.some((p) => p.length >= 3 && p[2] != null && p[2] > 0 && p[2] !== 0.5);
280
+ const left = [];
281
+ const right = [];
282
+ let run = 0;
283
+ for (let i = 0; i < pts.length; i += 1) {
284
+ const p = pts[i];
285
+ const prev = pts[i - 1] || p;
286
+ const next = pts[i + 1] || p;
287
+ let dx = next[0] - prev[0];
288
+ let dy = next[1] - prev[1];
289
+ const len = Math.hypot(dx, dy) || 1;
290
+ dx /= len;
291
+ dy /= len;
292
+ const nx = -dy;
293
+ const ny = dx;
294
+ const pressure = hasPressure ? p[2] : velocityPressure(segLen[i - 1] == null ? segLen[i] || 0 : segLen[i - 1]);
295
+ let radius = size / 2 * (thinning === 0 ? 1 : 1 - thinning + thinning * pressure);
296
+ if (typeof nibAngle === "number") {
297
+ radius *= 0.35 + 0.65 * Math.abs(Math.sin(Math.atan2(dy, dx) - nibAngle));
298
+ }
299
+ if (i > 0) run += segLen[i - 1];
300
+ if (taperStart > 0) radius *= clamp(run / taperStart, 0, 1);
301
+ if (taperEnd > 0) radius *= clamp((total - run) / taperEnd, 0, 1);
302
+ radius = Math.max(0.1, radius);
303
+ left.push([p[0] + nx * radius, p[1] + ny * radius]);
304
+ right.push([p[0] - nx * radius, p[1] - ny * radius]);
305
+ }
306
+ return [...left, ...right.reverse()];
307
+ }
308
+ function pointsToBrushPath(outline) {
309
+ if (!outline || outline.length < 3) {
310
+ if (outline && outline.length) {
311
+ const [x, y] = outline[0];
312
+ return `M ${round(x - 1)} ${round(y)} a 1 1 0 1 0 2 0 a 1 1 0 1 0 -2 0 Z`;
313
+ }
314
+ return "";
315
+ }
316
+ let d = `M ${round(outline[0][0])} ${round(outline[0][1])}`;
317
+ for (let i = 1; i < outline.length; i += 1) {
318
+ const prev = outline[i - 1];
319
+ const cur = outline[i];
320
+ const mx = (prev[0] + cur[0]) / 2;
321
+ const my = (prev[1] + cur[1]) / 2;
322
+ d += ` Q ${round(prev[0])} ${round(prev[1])} ${round(mx)} ${round(my)}`;
323
+ }
324
+ return `${d} Z`;
325
+ }
326
+ function brushStrokePath(shape) {
327
+ const preset = BRUSH_PRESETS[shape.brush] || BRUSH_PRESETS[DEFAULT_BRUSH];
328
+ return pointsToBrushPath(
329
+ strokeOutline(shape.points || [], {
330
+ ...preset,
331
+ size: shape.size == null ? preset.size : shape.size
332
+ })
333
+ );
334
+ }
335
+ function coerceNumber(value, fallback = 0) {
336
+ const n = Number(value);
337
+ return Number.isFinite(n) ? n : fallback;
338
+ }
339
+ function coercePoints(raw) {
340
+ if (!Array.isArray(raw)) return [];
341
+ const pts = [];
342
+ for (const p of raw) {
343
+ if (Array.isArray(p) && p.length >= 2 && Number.isFinite(Number(p[0])) && Number.isFinite(Number(p[1]))) {
344
+ const pt = [round(Number(p[0])), round(Number(p[1]))];
345
+ if (p.length >= 3 && Number.isFinite(Number(p[2]))) pt.push(clamp(Number(p[2]), 0, 1));
346
+ pts.push(pt);
347
+ }
348
+ }
349
+ return pts;
350
+ }
351
+ function normalizeShape(raw, usedIds) {
352
+ if (!raw || typeof raw !== "object") return null;
353
+ const type = DRAW_SHAPE_TYPES.includes(raw.type) ? raw.type : "rectangle";
354
+ let id = typeof raw.id === "string" && raw.id ? raw.id : createId(type.slice(0, 2));
355
+ if (usedIds) {
356
+ while (usedIds.has(id)) id = createId(type.slice(0, 2));
357
+ usedIds.add(id);
358
+ }
359
+ const groupId = typeof raw.groupId === "string" && raw.groupId ? raw.groupId : void 0;
360
+ const color = typeof raw.color === "string" ? raw.color : typeof raw.stroke === "string" ? raw.stroke : void 0;
361
+ if (type === "freehand") {
362
+ const points = coercePoints(raw.points);
363
+ if (points.length < 1) return null;
364
+ const brush = BRUSH_PRESETS[raw.brush] ? raw.brush : DEFAULT_BRUSH;
365
+ const preset = BRUSH_PRESETS[brush];
366
+ const size = raw.size != null ? Math.max(1, coerceNumber(raw.size, preset.size)) : raw.strokeWidth != null ? Math.max(1, coerceNumber(raw.strokeWidth, DEFAULT_STROKE_WIDTH) * 2) : preset.size;
367
+ const shape2 = {
368
+ id,
369
+ type: "freehand",
370
+ brush,
371
+ points,
372
+ size: round(size),
373
+ opacity: raw.opacity == null ? preset.opacity : clamp(coerceNumber(raw.opacity, preset.opacity), 0, 1)
374
+ };
375
+ if (color) shape2.color = color;
376
+ if (groupId) shape2.groupId = groupId;
377
+ return shape2;
378
+ }
379
+ const base = {
380
+ id,
381
+ type,
382
+ strokeWidth: raw.strokeWidth == null ? DEFAULT_STROKE_WIDTH : coerceNumber(raw.strokeWidth, DEFAULT_STROKE_WIDTH),
383
+ opacity: raw.opacity == null ? 1 : clamp(coerceNumber(raw.opacity, 1), 0, 1)
384
+ };
385
+ if (color) base.color = color;
386
+ if (typeof raw.fill === "string") base.fill = raw.fill;
387
+ if (groupId) base.groupId = groupId;
388
+ if (type === "line") {
389
+ const points = coercePoints(raw.points);
390
+ if (points.length < 2) return null;
391
+ return {
392
+ ...base,
393
+ points,
394
+ arrowStart: Boolean(raw.arrowStart),
395
+ arrowEnd: raw.arrowEnd == null ? true : Boolean(raw.arrowEnd)
396
+ };
397
+ }
398
+ const shape = {
399
+ ...base,
400
+ x: round(coerceNumber(raw.x, 0)),
401
+ y: round(coerceNumber(raw.y, 0)),
402
+ w: round(Math.max(1, coerceNumber(raw.w, 100))),
403
+ h: round(Math.max(1, coerceNumber(raw.h, 80))),
404
+ rotation: coerceNumber(raw.rotation, 0)
405
+ };
406
+ if (type === "text" || type === "sticky")
407
+ shape.text = typeof raw.text === "string" ? raw.text : "";
408
+ return shape;
409
+ }
410
+ function normalizeViewport(raw) {
411
+ const vp = raw && typeof raw === "object" ? raw : {};
412
+ return {
413
+ x: coerceNumber(vp.x, 0),
414
+ y: coerceNumber(vp.y, 0),
415
+ scale: clamp(coerceNumber(vp.scale, 1), MIN_SCALE, MAX_SCALE)
416
+ };
417
+ }
418
+ function normalizeDocument(data) {
419
+ let raw = data;
420
+ if (typeof raw === "string") {
421
+ try {
422
+ raw = JSON.parse(raw);
423
+ } catch {
424
+ raw = {};
425
+ }
426
+ }
427
+ if (!raw || typeof raw !== "object") raw = {};
428
+ const usedIds = /* @__PURE__ */ new Set();
429
+ const rawShapes = Array.isArray(raw.shapes) ? raw.shapes : [];
430
+ const shapes = [];
431
+ for (const rawShape of rawShapes) {
432
+ const shape = normalizeShape(rawShape, usedIds);
433
+ if (shape) shapes.push(shape);
434
+ }
435
+ const groupCounts = /* @__PURE__ */ new Map();
436
+ for (const s of shapes)
437
+ if (s.groupId) groupCounts.set(s.groupId, (groupCounts.get(s.groupId) || 0) + 1);
438
+ for (const s of shapes) if (s.groupId && groupCounts.get(s.groupId) < 2) delete s.groupId;
439
+ return {
440
+ version: VD_DRAW_VERSION,
441
+ viewport: normalizeViewport(raw.viewport),
442
+ shapes
443
+ };
444
+ }
445
+
446
+ // src/draw/core.js
447
+ var SVG_NS = "http://www.w3.org/2000/svg";
448
+ var WORLD_EXTENT = 8e3;
449
+ var HANDLE_SIZE = 8;
450
+ var SNAP_THRESHOLD = 6;
451
+ var DRAG_THRESHOLD = 3;
452
+ var ERASER_RADIUS = 12;
453
+ var COALESCE_REASONS = /* @__PURE__ */ new Set(["shape:style", "shape:text", "shape:nudge"]);
454
+ var ICON_PATHS = {
455
+ select: "M168,132.69,214.08,115l.33-.13A16,16,0,0,0,213,85.07L52.92,32.8A15.95,15.95,0,0,0,32.8,52.92L85.07,213a15.82,15.82,0,0,0,14.41,11l.78,0a15.84,15.84,0,0,0,14.61-9.59l.13-.33L132.69,168,184,219.31a16,16,0,0,0,22.63,0l12.68-12.68a16,16,0,0,0,0-22.63ZM195.31,208,144,156.69a16,16,0,0,0-26,4.93c0,.11-.09.22-.13.32l-17.65,46L48,48l159.85,52.2-45.95,17.64-.32.13a16,16,0,0,0-4.93,26h0L208,195.31Z",
456
+ hand: "M188,48a27.75,27.75,0,0,0-12,2.71V44a28,28,0,0,0-54.65-8.6A28,28,0,0,0,80,60v64l-3.82-6.13a28,28,0,0,0-48.6,27.82c16,33.77,28.93,57.72,43.72,72.69C86.24,233.54,103.2,240,128,240a88.1,88.1,0,0,0,88-88V76A28,28,0,0,0,188,48Zm12,104a72.08,72.08,0,0,1-72,72c-20.38,0-33.51-4.88-45.33-16.85C69.44,193.74,57.26,171,41.9,138.58a6.36,6.36,0,0,0-.3-.58,12,12,0,0,1,20.79-12,1.76,1.76,0,0,0,.14.23l18.67,30A8,8,0,0,0,96,152V60a12,12,0,0,1,24,0v60a8,8,0,0,0,16,0V44a12,12,0,0,1,24,0v76a8,8,0,0,0,16,0V76a12,12,0,0,1,24,0Z",
457
+ draw: "M232,32a8,8,0,0,0-8-8c-44.08,0-89.31,49.71-114.43,82.63A60,60,0,0,0,32,164c0,30.88-19.54,44.73-20.47,45.37A8,8,0,0,0,16,224H92a60,60,0,0,0,57.37-77.57C182.3,121.31,232,76.08,232,32ZM92,208H34.63C41.38,198.41,48,183.92,48,164a44,44,0,1,1,44,44Zm32.42-94.45q5.14-6.66,10.09-12.55A76.23,76.23,0,0,1,155,121.49q-5.9,4.94-12.55,10.09A60.54,60.54,0,0,0,124.42,113.55Zm42.7-2.68a92.57,92.57,0,0,0-22-22c31.78-34.53,55.75-45,69.9-47.91C212.17,55.12,201.65,79.09,167.12,110.87Z",
458
+ eraser: "M225,80.4,183.6,39a24,24,0,0,0-33.94,0L31,157.66a24,24,0,0,0,0,33.94l30.06,30.06A8,8,0,0,0,66.74,224H216a8,8,0,0,0,0-16h-84.7L225,114.34A24,24,0,0,0,225,80.4ZM108.68,208H70.05L42.33,180.28a8,8,0,0,1,0-11.31L96,115.31,148.69,168Zm105-105L160,156.69,107.31,104,161,50.34a8,8,0,0,1,11.32,0l41.38,41.38a8,8,0,0,1,0,11.31Z",
459
+ rectangle: "M208,32H48A16,16,0,0,0,32,48V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32Zm0,176H48V48H208V208Z",
460
+ ellipse: "M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Z",
461
+ line: "M200,64V168a8,8,0,0,1-16,0V83.31L69.66,197.66a8,8,0,0,1-11.32-11.32L172.69,72H88a8,8,0,0,1,0-16H192A8,8,0,0,1,200,64Z",
462
+ text: "M208,56V88a8,8,0,0,1-16,0V64H136V192h24a8,8,0,0,1,0,16H96a8,8,0,0,1,0-16h24V64H64V88a8,8,0,0,1-16,0V56a8,8,0,0,1,8-8H200A8,8,0,0,1,208,56Z",
463
+ sticky: "M88,96a8,8,0,0,1,8-8h64a8,8,0,0,1,0,16H96A8,8,0,0,1,88,96Zm8,40h64a8,8,0,0,0,0-16H96a8,8,0,0,0,0,16Zm32,16H96a8,8,0,0,0,0,16h32a8,8,0,0,0,0-16ZM224,48V156.69A15.86,15.86,0,0,1,219.31,168L168,219.31A15.86,15.86,0,0,1,156.69,224H48a16,16,0,0,1-16-16V48A16,16,0,0,1,48,32H208A16,16,0,0,1,224,48ZM48,208H152V160a8,8,0,0,1,8-8h48V48H48Zm120-40v28.7L196.69,168Z",
464
+ undo: "M224,128a96,96,0,0,1-94.71,96H128A95.38,95.38,0,0,1,62.1,197.8a8,8,0,0,1,11-11.63A80,80,0,1,0,71.43,71.39a3.07,3.07,0,0,1-.26.25L44.59,96H72a8,8,0,0,1,0,16H24a8,8,0,0,1-8-8V56a8,8,0,0,1,16,0V85.8L60.25,60A96,96,0,0,1,224,128Z",
465
+ redo: "M240,56v48a8,8,0,0,1-8,8H184a8,8,0,0,1,0-16H211.4L184.81,71.64l-.25-.24a80,80,0,1,0-1.67,114.78,8,8,0,0,1,11,11.63A95.44,95.44,0,0,1,128,224h-1.32A96,96,0,1,1,195.75,60L224,85.8V56a8,8,0,1,1,16,0Z",
466
+ duplicate: "M216,32H88a8,8,0,0,0-8,8V80H40a8,8,0,0,0-8,8V216a8,8,0,0,0,8,8H168a8,8,0,0,0,8-8V176h40a8,8,0,0,0,8-8V40A8,8,0,0,0,216,32ZM160,208H48V96H160Zm48-48H176V88a8,8,0,0,0-8-8H96V48H208Z",
467
+ delete: "M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z",
468
+ grid: "M200,40H56A16,16,0,0,0,40,56V200a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V56A16,16,0,0,0,200,40Zm0,80H136V56h64ZM120,56v64H56V56ZM56,136h64v64H56Zm144,64H136V136h64v64Z"
469
+ };
470
+ var TOOL_BUTTONS = [
471
+ { tool: "select", label: "Select" },
472
+ { tool: "hand", label: "Pan" },
473
+ { tool: "draw", label: "Brush" },
474
+ { tool: "eraser", label: "Eraser" },
475
+ { tool: "rectangle", label: "Rectangle" },
476
+ { tool: "ellipse", label: "Ellipse" },
477
+ { tool: "line", label: "Arrow" },
478
+ { tool: "text", label: "Text" },
479
+ { tool: "sticky", label: "Sticky note" }
480
+ ];
481
+ var ACTION_BUTTONS = [
482
+ { action: "undo", label: "Undo" },
483
+ { action: "redo", label: "Redo" },
484
+ { action: "duplicate", label: "Duplicate" },
485
+ { action: "delete", label: "Delete / clear" }
486
+ ];
487
+ var BRUSH_ORDER = ["pen", "pencil", "marker", "highlighter", "calligraphy"];
488
+ var BRUSH_LABELS = {
489
+ pen: "Pen",
490
+ pencil: "Pencil",
491
+ marker: "Marker",
492
+ highlighter: "Highlighter",
493
+ calligraphy: "Calligraphy"
494
+ };
495
+ var SWATCHES = [
496
+ "#1f2720",
497
+ "#495057",
498
+ "#e03131",
499
+ "#f08c00",
500
+ "#f2c200",
501
+ "#2f9e44",
502
+ "#1971c2",
503
+ "#7048e8",
504
+ "#e64980",
505
+ "#ffffff"
506
+ ];
507
+ function createSvgEl(name, attrs) {
508
+ const el = document.createElementNS(SVG_NS, name);
509
+ if (attrs) for (const [k, v] of Object.entries(attrs)) el.setAttribute(k, String(v));
510
+ return el;
511
+ }
512
+ function createIcon(name) {
513
+ const svg = createSvgEl("svg", {
514
+ viewBox: "0 0 256 256",
515
+ "aria-hidden": "true",
516
+ focusable: "false"
517
+ });
518
+ svg.classList.add("vd-draw-icon");
519
+ svg.setAttribute("fill", "currentColor");
520
+ svg.appendChild(createSvgEl("path", { d: ICON_PATHS[name] || "" }));
521
+ return svg;
522
+ }
523
+ function clearChildren(node) {
524
+ if (node) node.replaceChildren();
525
+ }
526
+ function hasWindow() {
527
+ return typeof window !== "undefined" && typeof document !== "undefined";
528
+ }
529
+ function toHex6(color) {
530
+ if (typeof color !== "string") return null;
531
+ const value = color.trim();
532
+ if (/^#[0-9a-fA-F]{6}$/.test(value)) return value.toLowerCase();
533
+ const short = /^#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])$/.exec(value);
534
+ if (short)
535
+ return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`.toLowerCase();
536
+ return null;
537
+ }
538
+ var VdDraw = class {
539
+ constructor(options = {}) {
540
+ const opts = options || {};
541
+ this.element = this._resolveElement(opts.element ?? opts.target);
542
+ if (!this.element) throw new Error("VdDraw: a target `element` is required");
543
+ this.readonly = Boolean(opts.readonly);
544
+ this.tool = DRAW_TOOLS.includes(opts.tool) ? opts.tool : "draw";
545
+ this.gridSize = Number.isFinite(opts.gridSize) ? opts.gridSize : DEFAULT_GRID_SIZE;
546
+ this.showGrid = opts.showGrid == null ? true : Boolean(opts.showGrid);
547
+ this.snap = opts.snap == null ? true : Boolean(opts.snap);
548
+ this.autoFit = Boolean(opts.autoFit);
549
+ this.historyEnabled = opts.history == null ? true : Boolean(opts.history);
550
+ this.historyLimit = Number.isFinite(opts.historyLimit) ? opts.historyLimit : 100;
551
+ this.style = {
552
+ color: typeof opts.color === "string" ? opts.color : "#1f2720",
553
+ opacity: Number.isFinite(opts.opacity) ? clamp(opts.opacity, 0.05, 1) : 1,
554
+ size: Number.isFinite(opts.brushSize) ? opts.brushSize : BRUSH_PRESETS[DEFAULT_BRUSH].size,
555
+ brush: BRUSH_PRESETS[opts.brush] ? opts.brush : DEFAULT_BRUSH
556
+ };
557
+ this.recentColors = [];
558
+ this.documentData = normalizeDocument(opts.data);
559
+ this.selectedIds = /* @__PURE__ */ new Set();
560
+ this.interaction = null;
561
+ this.clipboard = [];
562
+ this.textEditor = null;
563
+ this.destroyed = false;
564
+ this.history = [];
565
+ this.historyIndex = -1;
566
+ this.isApplyingHistory = false;
567
+ this.lastReason = null;
568
+ this.lastTargetKey = null;
569
+ this.listeners = /* @__PURE__ */ new Map();
570
+ this._buildShell();
571
+ if (typeof opts.color !== "string") this.style.color = this._resolveInk();
572
+ this._bindEvents();
573
+ this._resetHistory();
574
+ this.render();
575
+ this._syncStylePanel();
576
+ this._scheduleReady();
577
+ }
578
+ _resolveElement(target) {
579
+ if (!target) return null;
580
+ if (typeof target === "string") return hasWindow() ? document.querySelector(target) : null;
581
+ return target;
582
+ }
583
+ _resolveInk() {
584
+ if (!hasWindow()) return "#1f2720";
585
+ try {
586
+ const v = getComputedStyle(this.svg).getPropertyValue("--vd-draw-ink").trim();
587
+ return v || "#1f2720";
588
+ } catch {
589
+ return "#1f2720";
590
+ }
591
+ }
592
+ // ── Shell ──────────────────────────────────────────────────────────────
593
+ _buildShell() {
594
+ const host = this.element;
595
+ host.classList.add("vd-draw-host");
596
+ clearChildren(host);
597
+ const shell = document.createElement("div");
598
+ shell.className = "vd-draw-shell";
599
+ this.toolbarEl = document.createElement("div");
600
+ this.toolbarEl.className = "vd-draw-toolbar";
601
+ this.toolbarEl.setAttribute("role", "toolbar");
602
+ this.toolbarEl.setAttribute("aria-label", "Drawing tools");
603
+ this.panelEl = document.createElement("div");
604
+ this.panelEl.className = "vd-draw-panel";
605
+ if (!this.readonly) {
606
+ this._buildToolbar();
607
+ this._buildStylePanel();
608
+ }
609
+ this.canvasEl = document.createElement("div");
610
+ this.canvasEl.className = "vd-draw-canvas";
611
+ this.canvasEl.tabIndex = 0;
612
+ this.svg = createSvgEl("svg", { class: "vd-draw-svg" });
613
+ this.svg.setAttribute("width", "100%");
614
+ this.svg.setAttribute("height", "100%");
615
+ const defs = createSvgEl("defs");
616
+ const gs = this.gridSize;
617
+ this.gridPattern = createSvgEl("pattern", {
618
+ id: this._svgId("grid"),
619
+ width: gs,
620
+ height: gs,
621
+ patternUnits: "userSpaceOnUse"
622
+ });
623
+ this.gridPatternPath = createSvgEl("path", {
624
+ d: `M ${gs} 0 L 0 0 0 ${gs}`,
625
+ fill: "none",
626
+ stroke: "var(--vd-draw-grid)",
627
+ "stroke-width": 1
628
+ });
629
+ this.gridPattern.appendChild(this.gridPatternPath);
630
+ defs.appendChild(this.gridPattern);
631
+ const marker = createSvgEl("marker", {
632
+ id: this._svgId("arrow"),
633
+ viewBox: "0 0 10 10",
634
+ refX: 8,
635
+ refY: 5,
636
+ markerWidth: 7,
637
+ markerHeight: 7,
638
+ orient: "auto-start-reverse"
639
+ });
640
+ marker.appendChild(
641
+ createSvgEl("path", { d: "M 0 0 L 10 5 L 0 10 z", fill: "var(--vd-draw-shape-stroke)" })
642
+ );
643
+ defs.appendChild(marker);
644
+ this.svg.appendChild(defs);
645
+ this.world = createSvgEl("g", { class: "vd-draw-world" });
646
+ this.gridLayer = createSvgEl("rect", {
647
+ class: "vd-draw-grid-rect",
648
+ x: -WORLD_EXTENT,
649
+ y: -WORLD_EXTENT,
650
+ width: WORLD_EXTENT * 2,
651
+ height: WORLD_EXTENT * 2,
652
+ fill: `url(#${this._svgId("grid")})`
653
+ });
654
+ if (!this.showGrid) this.gridLayer.style.display = "none";
655
+ this.shapesLayer = createSvgEl("g", { class: "vd-draw-shapes" });
656
+ this.guidesLayer = createSvgEl("g", { class: "vd-draw-guides" });
657
+ this.overlayLayer = createSvgEl("g", { class: "vd-draw-overlay" });
658
+ this.marqueeLayer = createSvgEl("g", { class: "vd-draw-marquee" });
659
+ this.world.append(
660
+ this.gridLayer,
661
+ this.shapesLayer,
662
+ this.guidesLayer,
663
+ this.overlayLayer,
664
+ this.marqueeLayer
665
+ );
666
+ this.svg.appendChild(this.world);
667
+ this.canvasEl.appendChild(this.svg);
668
+ this.textLayer = document.createElement("div");
669
+ this.textLayer.className = "vd-draw-text-layer";
670
+ this.canvasEl.appendChild(this.textLayer);
671
+ shell.append(this.toolbarEl, this.panelEl, this.canvasEl);
672
+ host.appendChild(shell);
673
+ }
674
+ _buildToolbar() {
675
+ clearChildren(this.toolbarEl);
676
+ const makeBtn = (name, label, attr, value, active) => {
677
+ const btn = document.createElement("button");
678
+ btn.type = "button";
679
+ btn.className = "vd-draw-tool";
680
+ btn.setAttribute(attr, value);
681
+ btn.setAttribute("aria-label", label);
682
+ btn.setAttribute("title", label);
683
+ if (active) btn.setAttribute("aria-pressed", "true");
684
+ btn.appendChild(createIcon(name));
685
+ return btn;
686
+ };
687
+ const tools = document.createElement("div");
688
+ tools.className = "vd-draw-toolbar-group";
689
+ for (const t of TOOL_BUTTONS)
690
+ tools.appendChild(makeBtn(t.tool, t.label, "data-tool", t.tool, t.tool === this.tool));
691
+ const actions = document.createElement("div");
692
+ actions.className = "vd-draw-toolbar-group";
693
+ for (const a of ACTION_BUTTONS)
694
+ actions.appendChild(makeBtn(a.action, a.label, "data-action", a.action, false));
695
+ this.toolbarEl.append(tools, actions);
696
+ }
697
+ _panelGroup(labelText, className = "") {
698
+ const group = document.createElement("div");
699
+ group.className = `vd-draw-panel-group${className ? ` ${className}` : ""}`;
700
+ const label = document.createElement("span");
701
+ label.className = "vd-draw-panel-label";
702
+ label.textContent = labelText;
703
+ group.appendChild(label);
704
+ return group;
705
+ }
706
+ _rangeInput(min, max, step, styleKey) {
707
+ const input = document.createElement("input");
708
+ input.type = "range";
709
+ input.min = String(min);
710
+ input.max = String(max);
711
+ input.step = String(step);
712
+ input.setAttribute("data-style", styleKey);
713
+ input.setAttribute("aria-label", styleKey);
714
+ return input;
715
+ }
716
+ _buildStylePanel() {
717
+ clearChildren(this.panelEl);
718
+ const brushGroup = this._panelGroup("Brush");
719
+ for (const key of BRUSH_ORDER) {
720
+ const btn = document.createElement("button");
721
+ btn.type = "button";
722
+ btn.className = "vd-draw-brush";
723
+ btn.setAttribute("data-brush", key);
724
+ btn.setAttribute("title", BRUSH_LABELS[key]);
725
+ btn.textContent = BRUSH_LABELS[key];
726
+ brushGroup.appendChild(btn);
727
+ }
728
+ const colorGroup = this._panelGroup("Color", "vd-draw-swatches-group");
729
+ for (const color of SWATCHES) {
730
+ const sw = document.createElement("button");
731
+ sw.type = "button";
732
+ sw.className = "vd-draw-swatch";
733
+ sw.setAttribute("data-swatch", color);
734
+ sw.setAttribute("aria-label", `Color ${color}`);
735
+ sw.setAttribute("title", color);
736
+ sw.style.setProperty("--sw", color);
737
+ colorGroup.appendChild(sw);
738
+ }
739
+ this.colorInput = document.createElement("input");
740
+ this.colorInput.type = "color";
741
+ this.colorInput.className = "vd-draw-color-input";
742
+ this.colorInput.setAttribute("data-style", "color");
743
+ this.colorInput.setAttribute("aria-label", "Custom color");
744
+ this.colorInput.setAttribute("title", "Custom color");
745
+ colorGroup.appendChild(this.colorInput);
746
+ const sizeGroup = this._panelGroup("Size");
747
+ this.sizeInput = this._rangeInput(1, 40, 1, "size");
748
+ this.sizeValue = document.createElement("span");
749
+ this.sizeValue.className = "vd-draw-value";
750
+ sizeGroup.append(this.sizeInput, this.sizeValue);
751
+ const opacityGroup = this._panelGroup("Opacity");
752
+ this.opacityInput = this._rangeInput(0.1, 1, 0.05, "opacity");
753
+ this.opacityValue = document.createElement("span");
754
+ this.opacityValue.className = "vd-draw-value";
755
+ opacityGroup.append(this.opacityInput, this.opacityValue);
756
+ const gridGroup = this._panelGroup("Grid");
757
+ this.gridToggle = document.createElement("button");
758
+ this.gridToggle.type = "button";
759
+ this.gridToggle.className = "vd-draw-tool";
760
+ this.gridToggle.setAttribute("data-grid", "toggle");
761
+ this.gridToggle.setAttribute("aria-label", "Toggle grid");
762
+ this.gridToggle.setAttribute("title", "Toggle grid");
763
+ this.gridToggle.appendChild(createIcon("grid"));
764
+ this.gridSizeInput = this._rangeInput(4, 128, 4, "grid");
765
+ this.gridSizeInput.title = "Grid cell size";
766
+ gridGroup.append(this.gridToggle, this.gridSizeInput);
767
+ this.recentGroup = this._panelGroup("Recent", "vd-draw-swatches-group");
768
+ this.recentEl = document.createElement("span");
769
+ this.recentEl.className = "vd-draw-recent";
770
+ this.recentGroup.appendChild(this.recentEl);
771
+ this.panelEl.append(
772
+ brushGroup,
773
+ colorGroup,
774
+ sizeGroup,
775
+ opacityGroup,
776
+ gridGroup,
777
+ this.recentGroup
778
+ );
779
+ }
780
+ _svgId(suffix) {
781
+ if (!this._idBase) this._idBase = createId("draw");
782
+ return `${this._idBase}-${suffix}`;
783
+ }
784
+ _syncToolbar() {
785
+ if (this.readonly || !this.toolbarEl) return;
786
+ for (const btn of this.toolbarEl.querySelectorAll("[data-tool]")) {
787
+ if (btn.getAttribute("data-tool") === this.tool) btn.setAttribute("aria-pressed", "true");
788
+ else btn.removeAttribute("aria-pressed");
789
+ }
790
+ }
791
+ _syncStylePanel() {
792
+ if (this.readonly || !this.panelEl) return;
793
+ for (const btn of this.panelEl.querySelectorAll("[data-brush]")) {
794
+ if (btn.getAttribute("data-brush") === this.style.brush)
795
+ btn.setAttribute("aria-pressed", "true");
796
+ else btn.removeAttribute("aria-pressed");
797
+ }
798
+ if (this.colorInput) {
799
+ const hex = toHex6(this.style.color);
800
+ if (hex) this.colorInput.value = hex;
801
+ }
802
+ if (this.sizeInput) this.sizeInput.value = String(this.style.size);
803
+ if (this.sizeValue) this.sizeValue.textContent = `${Math.round(this.style.size)}px`;
804
+ if (this.opacityInput) this.opacityInput.value = String(this.style.opacity);
805
+ if (this.opacityValue)
806
+ this.opacityValue.textContent = `${Math.round(this.style.opacity * 100)}%`;
807
+ if (this.gridToggle) {
808
+ if (this.showGrid) this.gridToggle.setAttribute("aria-pressed", "true");
809
+ else this.gridToggle.removeAttribute("aria-pressed");
810
+ }
811
+ if (this.gridSizeInput) this.gridSizeInput.value = String(this.gridSize);
812
+ for (const sw of this.panelEl.querySelectorAll(".vd-draw-swatch[data-swatch]")) {
813
+ if (sw.getAttribute("data-swatch") === this.style.color)
814
+ sw.setAttribute("aria-pressed", "true");
815
+ else sw.removeAttribute("aria-pressed");
816
+ }
817
+ if (this.recentEl && this.recentGroup) {
818
+ clearChildren(this.recentEl);
819
+ for (const color of this.recentColors) {
820
+ const sw = document.createElement("button");
821
+ sw.type = "button";
822
+ sw.className = "vd-draw-swatch";
823
+ sw.setAttribute("data-swatch", color);
824
+ sw.setAttribute("aria-label", `Recent color ${color}`);
825
+ sw.setAttribute("title", color);
826
+ sw.style.setProperty("--sw", color);
827
+ this.recentEl.appendChild(sw);
828
+ }
829
+ this.recentGroup.style.display = this.recentColors.length ? "" : "none";
830
+ }
831
+ }
832
+ // ── Events ─────────────────────────────────────────────────────────────
833
+ _bindEvents() {
834
+ this._onPointerDown = this._handlePointerDown.bind(this);
835
+ this._onPointerMove = this._handlePointerMove.bind(this);
836
+ this._onPointerUp = this._handlePointerUp.bind(this);
837
+ this._onWheel = this._handleWheel.bind(this);
838
+ this._onKeyDown = this._handleKeyDown.bind(this);
839
+ this._onToolbarClick = this._handleToolbarClick.bind(this);
840
+ this._onPanelClick = this._handlePanelClick.bind(this);
841
+ this._onPanelInput = this._handlePanelInput.bind(this);
842
+ this._onResize = this._handleResize.bind(this);
843
+ this.canvasEl.addEventListener("pointerdown", this._onPointerDown);
844
+ this.canvasEl.addEventListener("pointermove", this._onPointerMove);
845
+ this.canvasEl.addEventListener("pointerup", this._onPointerUp);
846
+ this.canvasEl.addEventListener("pointercancel", this._onPointerUp);
847
+ this.canvasEl.addEventListener("wheel", this._onWheel, { passive: false });
848
+ this.canvasEl.addEventListener("keydown", this._onKeyDown);
849
+ this.toolbarEl.addEventListener("click", this._onToolbarClick);
850
+ this.panelEl.addEventListener("click", this._onPanelClick);
851
+ this.panelEl.addEventListener("input", this._onPanelInput);
852
+ window.addEventListener("pointerup", this._onPointerUp);
853
+ window.addEventListener("resize", this._onResize);
854
+ }
855
+ _unbindEvents() {
856
+ this.canvasEl.removeEventListener("pointerdown", this._onPointerDown);
857
+ this.canvasEl.removeEventListener("pointermove", this._onPointerMove);
858
+ this.canvasEl.removeEventListener("pointerup", this._onPointerUp);
859
+ this.canvasEl.removeEventListener("pointercancel", this._onPointerUp);
860
+ this.canvasEl.removeEventListener("wheel", this._onWheel);
861
+ this.canvasEl.removeEventListener("keydown", this._onKeyDown);
862
+ this.toolbarEl.removeEventListener("click", this._onToolbarClick);
863
+ this.panelEl.removeEventListener("click", this._onPanelClick);
864
+ this.panelEl.removeEventListener("input", this._onPanelInput);
865
+ window.removeEventListener("pointerup", this._onPointerUp);
866
+ window.removeEventListener("resize", this._onResize);
867
+ }
868
+ on(event, callback) {
869
+ if (!this.listeners.has(event)) this.listeners.set(event, /* @__PURE__ */ new Set());
870
+ this.listeners.get(event).add(callback);
871
+ return this;
872
+ }
873
+ off(event, callback) {
874
+ this.listeners.get(event)?.delete(callback);
875
+ return this;
876
+ }
877
+ emit(event, payload) {
878
+ const set = this.listeners.get(event);
879
+ if (set) for (const cb of set) cb(payload);
880
+ }
881
+ // ── Coordinate transforms ───────────────────────────────────────────────
882
+ _clientToLocal(clientX, clientY) {
883
+ const rect = this.canvasEl.getBoundingClientRect();
884
+ return { x: clientX - rect.left, y: clientY - rect.top };
885
+ }
886
+ _localToWorld(lx, ly) {
887
+ const vp = this.documentData.viewport;
888
+ return { x: (lx - vp.x) / vp.scale, y: (ly - vp.y) / vp.scale };
889
+ }
890
+ _clientToWorld(clientX, clientY) {
891
+ const local = this._clientToLocal(clientX, clientY);
892
+ return this._localToWorld(local.x, local.y);
893
+ }
894
+ scaleAround(factor, lx, ly) {
895
+ const vp = this.documentData.viewport;
896
+ const next = clamp(vp.scale * factor, MIN_SCALE, MAX_SCALE);
897
+ const applied = next / vp.scale;
898
+ vp.x = lx - (lx - vp.x) * applied;
899
+ vp.y = ly - (ly - vp.y) * applied;
900
+ vp.scale = next;
901
+ return this;
902
+ }
903
+ // ── Viewport ─────────────────────────────────────────────────────────────
904
+ setViewport(patch) {
905
+ const vp = this.documentData.viewport;
906
+ if (patch && typeof patch === "object") {
907
+ if (Number.isFinite(patch.x)) vp.x = patch.x;
908
+ if (Number.isFinite(patch.y)) vp.y = patch.y;
909
+ if (Number.isFinite(patch.scale)) vp.scale = clamp(patch.scale, MIN_SCALE, MAX_SCALE);
910
+ }
911
+ this.render({ scene: false });
912
+ this._emitViewportChange("viewport:set");
913
+ return this;
914
+ }
915
+ zoomIn() {
916
+ return this._zoomAtCentre(1.2);
917
+ }
918
+ zoomOut() {
919
+ return this._zoomAtCentre(1 / 1.2);
920
+ }
921
+ _zoomAtCentre(factor) {
922
+ const rect = this.canvasEl.getBoundingClientRect();
923
+ this.scaleAround(factor, rect.width / 2, rect.height / 2);
924
+ this.render({ scene: false });
925
+ this._emitViewportChange("viewport:zoom");
926
+ return this;
927
+ }
928
+ resetView() {
929
+ this.documentData.viewport = { x: 0, y: 0, scale: 1 };
930
+ this.render({ scene: false });
931
+ this._emitViewportChange("viewport:reset");
932
+ return this;
933
+ }
934
+ fitView() {
935
+ const bounds = boundsOfShapes(this.documentData.shapes);
936
+ const rect = this.canvasEl.getBoundingClientRect();
937
+ if (!bounds || bounds.w === 0 || bounds.h === 0 || !rect.width || !rect.height) return this;
938
+ const pad = 40;
939
+ const scale = clamp(
940
+ Math.min((rect.width - pad * 2) / bounds.w, (rect.height - pad * 2) / bounds.h),
941
+ MIN_SCALE,
942
+ MAX_SCALE
943
+ );
944
+ const vp = this.documentData.viewport;
945
+ vp.scale = scale;
946
+ vp.x = (rect.width - bounds.w * scale) / 2 - bounds.x * scale;
947
+ vp.y = (rect.height - bounds.h * scale) / 2 - bounds.y * scale;
948
+ this.render({ scene: false });
949
+ this._emitViewportChange("viewport:fit");
950
+ return this;
951
+ }
952
+ _emitViewportChange(reason) {
953
+ this.emit("viewport", {
954
+ reason,
955
+ viewport: { ...this.documentData.viewport },
956
+ document: this.toJSON()
957
+ });
958
+ }
959
+ // ── Grid ─────────────────────────────────────────────────────────────────
960
+ setGridSize(size) {
961
+ if (!Number.isFinite(size)) return this;
962
+ this.gridSize = clamp(Math.round(size), 4, 128);
963
+ if (this.gridPattern) {
964
+ this.gridPattern.setAttribute("width", String(this.gridSize));
965
+ this.gridPattern.setAttribute("height", String(this.gridSize));
966
+ this.gridPatternPath.setAttribute("d", `M ${this.gridSize} 0 L 0 0 0 ${this.gridSize}`);
967
+ }
968
+ this._syncStylePanel();
969
+ return this;
970
+ }
971
+ setGridVisible(visible) {
972
+ this.showGrid = Boolean(visible);
973
+ if (this.gridLayer) this.gridLayer.style.display = this.showGrid ? "" : "none";
974
+ this._syncStylePanel();
975
+ return this;
976
+ }
977
+ toggleGrid() {
978
+ return this.setGridVisible(!this.showGrid);
979
+ }
980
+ // ── Tool + current style ─────────────────────────────────────────────────
981
+ setTool(tool) {
982
+ if (!DRAW_TOOLS.includes(tool)) return this;
983
+ this.tool = tool;
984
+ this._syncToolbar();
985
+ return this;
986
+ }
987
+ setColor(color) {
988
+ if (typeof color !== "string") return this;
989
+ this.style.color = color;
990
+ this._pushRecent(color);
991
+ this._syncStylePanel();
992
+ return this;
993
+ }
994
+ setOpacity(opacity) {
995
+ if (Number.isFinite(opacity)) this.style.opacity = clamp(opacity, 0.05, 1);
996
+ this._syncStylePanel();
997
+ return this;
998
+ }
999
+ setBrushSize(size) {
1000
+ if (Number.isFinite(size)) this.style.size = clamp(size, 1, 200);
1001
+ this._syncStylePanel();
1002
+ return this;
1003
+ }
1004
+ setBrush(brush) {
1005
+ if (BRUSH_PRESETS[brush]) this.style.brush = brush;
1006
+ this._syncStylePanel();
1007
+ return this;
1008
+ }
1009
+ _pushRecent(color) {
1010
+ this.recentColors = [color, ...this.recentColors.filter((c) => c !== color)].slice(0, 8);
1011
+ }
1012
+ _shapeStrokeWidth() {
1013
+ return clamp(round(this.style.size / 3), 1, 14);
1014
+ }
1015
+ // ── Shape CRUD ─────────────────────────────────────────────────────────
1016
+ getShape(id) {
1017
+ return this.documentData.shapes.find((s) => s.id === id) || null;
1018
+ }
1019
+ addShape(partial = {}) {
1020
+ const type = DRAW_SHAPE_TYPES.includes(partial.type) ? partial.type : "rectangle";
1021
+ const s = this.style;
1022
+ let shape;
1023
+ if (type === "freehand") {
1024
+ const points = Array.isArray(partial.points) ? partial.points.map((p) => this._roundPoint(p)) : [];
1025
+ shape = {
1026
+ id: partial.id || createId("fh"),
1027
+ type: "freehand",
1028
+ brush: BRUSH_PRESETS[partial.brush] ? partial.brush : s.brush,
1029
+ color: partial.color || s.color,
1030
+ size: partial.size == null ? s.size : partial.size,
1031
+ opacity: partial.opacity == null ? s.opacity : partial.opacity,
1032
+ points
1033
+ };
1034
+ } else if (type === "line") {
1035
+ const points = Array.isArray(partial.points) ? partial.points.map(([x, y]) => [round(x), round(y)]) : [];
1036
+ shape = {
1037
+ id: partial.id || createId("ln"),
1038
+ type: "line",
1039
+ points,
1040
+ color: partial.color || s.color,
1041
+ strokeWidth: partial.strokeWidth == null ? this._shapeStrokeWidth() : partial.strokeWidth,
1042
+ opacity: partial.opacity == null ? s.opacity : partial.opacity,
1043
+ arrowStart: Boolean(partial.arrowStart),
1044
+ arrowEnd: partial.arrowEnd == null ? true : Boolean(partial.arrowEnd)
1045
+ };
1046
+ } else {
1047
+ shape = {
1048
+ id: partial.id || createId(type.slice(0, 2)),
1049
+ type,
1050
+ x: round(partial.x ?? 0),
1051
+ y: round(partial.y ?? 0),
1052
+ w: round(Math.max(1, partial.w ?? 100)),
1053
+ h: round(Math.max(1, partial.h ?? 80)),
1054
+ rotation: partial.rotation ?? 0,
1055
+ color: partial.color || s.color,
1056
+ strokeWidth: partial.strokeWidth == null ? this._shapeStrokeWidth() : partial.strokeWidth,
1057
+ opacity: partial.opacity == null ? s.opacity : partial.opacity
1058
+ };
1059
+ if (partial.fill) shape.fill = partial.fill;
1060
+ if (type === "text" || type === "sticky")
1061
+ shape.text = typeof partial.text === "string" ? partial.text : "";
1062
+ }
1063
+ this.documentData.shapes.push(shape);
1064
+ this.render();
1065
+ this._emitChange("shape:add", { shape: deepClone(shape), shapeId: shape.id });
1066
+ return shape;
1067
+ }
1068
+ _roundPoint(p) {
1069
+ const out = [round(p[0]), round(p[1])];
1070
+ if (p.length >= 3 && p[2] != null) out.push(clamp(Number(p[2]) || 0, 0, 1));
1071
+ return out;
1072
+ }
1073
+ updateShape(id, patch, options = {}) {
1074
+ const shape = this.getShape(id);
1075
+ if (!shape || !patch) return null;
1076
+ Object.assign(shape, patch);
1077
+ this.render();
1078
+ this._emitChange(options.reason || "shape:update", { shapeId: id }, options.reason ? id : null);
1079
+ return shape;
1080
+ }
1081
+ removeShape(id) {
1082
+ const idx = this.documentData.shapes.findIndex((s) => s.id === id);
1083
+ if (idx === -1) return false;
1084
+ this.documentData.shapes.splice(idx, 1);
1085
+ this.selectedIds.delete(id);
1086
+ this.render();
1087
+ this._emitChange("shape:delete", { shapeId: id });
1088
+ return true;
1089
+ }
1090
+ // ── Selection ────────────────────────────────────────────────────────────
1091
+ getSelectedShapes() {
1092
+ return this.documentData.shapes.filter((s) => this.selectedIds.has(s.id));
1093
+ }
1094
+ select(ids, { additive = false } = {}) {
1095
+ const list = Array.isArray(ids) ? ids : ids == null ? [] : [ids];
1096
+ if (!additive) this.selectedIds.clear();
1097
+ for (const id of list) {
1098
+ const shape = this.getShape(id);
1099
+ if (shape?.groupId) {
1100
+ for (const s of this.documentData.shapes)
1101
+ if (s.groupId === shape.groupId) this.selectedIds.add(s.id);
1102
+ } else if (shape) {
1103
+ this.selectedIds.add(id);
1104
+ }
1105
+ }
1106
+ this.render({ scene: false });
1107
+ this._emitSelect();
1108
+ return this;
1109
+ }
1110
+ selectAll() {
1111
+ this.selectedIds = new Set(this.documentData.shapes.map((s) => s.id));
1112
+ this.render({ scene: false });
1113
+ this._emitSelect();
1114
+ return this;
1115
+ }
1116
+ deselect() {
1117
+ if (this.selectedIds.size === 0) return this;
1118
+ this.selectedIds.clear();
1119
+ this.render({ scene: false });
1120
+ this._emitSelect();
1121
+ return this;
1122
+ }
1123
+ selectInBounds(box, { additive = false } = {}) {
1124
+ if (!additive) this.selectedIds.clear();
1125
+ for (const shape of this.documentData.shapes) {
1126
+ if (boundsIntersect(shapeBounds(shape), box)) this.selectedIds.add(shape.id);
1127
+ }
1128
+ for (const shape of [...this.getSelectedShapes()]) {
1129
+ if (shape.groupId) {
1130
+ for (const s of this.documentData.shapes)
1131
+ if (s.groupId === shape.groupId) this.selectedIds.add(s.id);
1132
+ }
1133
+ }
1134
+ this.render({ scene: false });
1135
+ this._emitSelect();
1136
+ return this;
1137
+ }
1138
+ _emitSelect() {
1139
+ this.emit("select", {
1140
+ ids: [...this.selectedIds],
1141
+ shapes: this.getSelectedShapes().map((s) => deepClone(s))
1142
+ });
1143
+ }
1144
+ _syncSelectionValidity() {
1145
+ const present = new Set(this.documentData.shapes.map((s) => s.id));
1146
+ for (const id of [...this.selectedIds]) if (!present.has(id)) this.selectedIds.delete(id);
1147
+ }
1148
+ // ── Manipulation ─────────────────────────────────────────────────────────
1149
+ _replaceShape(next) {
1150
+ const idx = this.documentData.shapes.findIndex((s) => s.id === next.id);
1151
+ if (idx !== -1) this.documentData.shapes[idx] = next;
1152
+ }
1153
+ nudge(dx, dy) {
1154
+ const selected = this.getSelectedShapes();
1155
+ if (!selected.length) return this;
1156
+ for (const shape of selected) this._replaceShape(translateShape(shape, dx, dy));
1157
+ this.render();
1158
+ this._emitChange(
1159
+ "shape:nudge",
1160
+ { shapeIds: [...this.selectedIds] },
1161
+ `nudge:${[...this.selectedIds].sort().join(",")}`
1162
+ );
1163
+ return this;
1164
+ }
1165
+ setSelectionBounds(target) {
1166
+ const selected = this.getSelectedShapes();
1167
+ const cur = boundsOfShapes(selected);
1168
+ if (!cur || cur.w === 0 || cur.h === 0 || !target) return this;
1169
+ const sx = target.w / cur.w;
1170
+ const sy = target.h / cur.h;
1171
+ for (const shape of selected) {
1172
+ const scaled = scaleShape(shape, cur.x, cur.y, sx, sy);
1173
+ this._replaceShape(translateShape(scaled, target.x - cur.x, target.y - cur.y));
1174
+ }
1175
+ this.render();
1176
+ this._emitChange("shape:resize", { shapeIds: [...this.selectedIds] });
1177
+ return this;
1178
+ }
1179
+ deleteSelection() {
1180
+ if (this.selectedIds.size === 0) return false;
1181
+ const ids = [...this.selectedIds];
1182
+ this.documentData.shapes = this.documentData.shapes.filter((s) => !this.selectedIds.has(s.id));
1183
+ this.selectedIds.clear();
1184
+ this.render();
1185
+ this._emitChange("shape:delete", { shapeIds: ids });
1186
+ return true;
1187
+ }
1188
+ setStyle(patch) {
1189
+ const selected = this.getSelectedShapes();
1190
+ if (!selected.length || !patch) return this;
1191
+ const allowed = ["color", "fill", "strokeWidth", "opacity", "size", "brush"];
1192
+ for (const shape of selected) {
1193
+ for (const key of allowed) if (key in patch) shape[key] = patch[key];
1194
+ }
1195
+ this.render();
1196
+ this._emitChange(
1197
+ "shape:style",
1198
+ { shapeIds: [...this.selectedIds] },
1199
+ `style:${[...this.selectedIds].sort().join(",")}`
1200
+ );
1201
+ return this;
1202
+ }
1203
+ // ── Z-order ────────────────────────────────────────────────────────────
1204
+ _reorder(mutator) {
1205
+ if (this.selectedIds.size === 0) return this;
1206
+ mutator();
1207
+ this.render();
1208
+ this._emitChange("shape:reorder", { shapeIds: [...this.selectedIds] });
1209
+ return this;
1210
+ }
1211
+ bringToFront() {
1212
+ return this._reorder(() => {
1213
+ const sel = this.documentData.shapes.filter((s) => this.selectedIds.has(s.id));
1214
+ const rest = this.documentData.shapes.filter((s) => !this.selectedIds.has(s.id));
1215
+ this.documentData.shapes = [...rest, ...sel];
1216
+ });
1217
+ }
1218
+ sendToBack() {
1219
+ return this._reorder(() => {
1220
+ const sel = this.documentData.shapes.filter((s) => this.selectedIds.has(s.id));
1221
+ const rest = this.documentData.shapes.filter((s) => !this.selectedIds.has(s.id));
1222
+ this.documentData.shapes = [...sel, ...rest];
1223
+ });
1224
+ }
1225
+ bringForward() {
1226
+ return this._reorder(() => {
1227
+ const arr = this.documentData.shapes;
1228
+ for (let i = arr.length - 2; i >= 0; i -= 1) {
1229
+ if (this.selectedIds.has(arr[i].id) && !this.selectedIds.has(arr[i + 1].id))
1230
+ [arr[i], arr[i + 1]] = [arr[i + 1], arr[i]];
1231
+ }
1232
+ });
1233
+ }
1234
+ sendBackward() {
1235
+ return this._reorder(() => {
1236
+ const arr = this.documentData.shapes;
1237
+ for (let i = 1; i < arr.length; i += 1) {
1238
+ if (this.selectedIds.has(arr[i].id) && !this.selectedIds.has(arr[i - 1].id))
1239
+ [arr[i], arr[i - 1]] = [arr[i - 1], arr[i]];
1240
+ }
1241
+ });
1242
+ }
1243
+ // ── Grouping ─────────────────────────────────────────────────────────────
1244
+ group() {
1245
+ const selected = this.getSelectedShapes();
1246
+ if (selected.length < 2) return this;
1247
+ const groupId = createId("grp");
1248
+ for (const shape of selected) shape.groupId = groupId;
1249
+ this.render();
1250
+ this._emitChange("shape:group", { groupId, shapeIds: [...this.selectedIds] });
1251
+ return this;
1252
+ }
1253
+ ungroup() {
1254
+ const selected = this.getSelectedShapes();
1255
+ let changed = false;
1256
+ for (const shape of selected) {
1257
+ if (shape.groupId) {
1258
+ delete shape.groupId;
1259
+ changed = true;
1260
+ }
1261
+ }
1262
+ if (!changed) return this;
1263
+ this.render();
1264
+ this._emitChange("shape:ungroup", { shapeIds: [...this.selectedIds] });
1265
+ return this;
1266
+ }
1267
+ // ── Clipboard ────────────────────────────────────────────────────────────
1268
+ copy() {
1269
+ this.clipboard = this.getSelectedShapes().map((s) => deepClone(s));
1270
+ return this;
1271
+ }
1272
+ cut() {
1273
+ this.copy();
1274
+ this.deleteSelection();
1275
+ return this;
1276
+ }
1277
+ paste({ offset = 16 } = {}) {
1278
+ if (!this.clipboard.length) return this;
1279
+ const groupRemap = /* @__PURE__ */ new Map();
1280
+ const newIds = [];
1281
+ for (const src of this.clipboard) {
1282
+ const copy = translateShape(deepClone(src), offset, offset);
1283
+ copy.id = createId(copy.type.slice(0, 2));
1284
+ if (copy.groupId) {
1285
+ if (!groupRemap.has(copy.groupId)) groupRemap.set(copy.groupId, createId("grp"));
1286
+ copy.groupId = groupRemap.get(copy.groupId);
1287
+ }
1288
+ this.documentData.shapes.push(copy);
1289
+ newIds.push(copy.id);
1290
+ }
1291
+ this.selectedIds = new Set(newIds);
1292
+ this.render();
1293
+ this._emitChange("shape:paste", { shapeIds: newIds });
1294
+ this._emitSelect();
1295
+ return this;
1296
+ }
1297
+ duplicate() {
1298
+ this.copy();
1299
+ this.paste();
1300
+ return this;
1301
+ }
1302
+ // ── Snapping ─────────────────────────────────────────────────────────────
1303
+ _computeSnap(movingBounds, movingIds) {
1304
+ if (!this.snap) return { dx: 0, dy: 0, guides: [] };
1305
+ const threshold = SNAP_THRESHOLD / this.documentData.viewport.scale;
1306
+ const targetsX = [
1307
+ movingBounds.x,
1308
+ movingBounds.x + movingBounds.w / 2,
1309
+ movingBounds.x + movingBounds.w
1310
+ ];
1311
+ const targetsY = [
1312
+ movingBounds.y,
1313
+ movingBounds.y + movingBounds.h / 2,
1314
+ movingBounds.y + movingBounds.h
1315
+ ];
1316
+ let bestX = null;
1317
+ let bestY = null;
1318
+ const guides = [];
1319
+ for (const other of this.documentData.shapes) {
1320
+ if (movingIds.has(other.id)) continue;
1321
+ const b = shapeBounds(other);
1322
+ for (const t of targetsX)
1323
+ for (const l of [b.x, b.x + b.w / 2, b.x + b.w]) {
1324
+ const d = l - t;
1325
+ if (Math.abs(d) <= threshold && (!bestX || Math.abs(d) < Math.abs(bestX.d)))
1326
+ bestX = { d, at: l };
1327
+ }
1328
+ for (const t of targetsY)
1329
+ for (const l of [b.y, b.y + b.h / 2, b.y + b.h]) {
1330
+ const d = l - t;
1331
+ if (Math.abs(d) <= threshold && (!bestY || Math.abs(d) < Math.abs(bestY.d)))
1332
+ bestY = { d, at: l };
1333
+ }
1334
+ }
1335
+ if (bestX) guides.push({ x1: bestX.at, y1: -WORLD_EXTENT, x2: bestX.at, y2: WORLD_EXTENT });
1336
+ if (bestY) guides.push({ x1: -WORLD_EXTENT, y1: bestY.at, x2: WORLD_EXTENT, y2: bestY.at });
1337
+ return { dx: bestX ? bestX.d : 0, dy: bestY ? bestY.d : 0, guides };
1338
+ }
1339
+ // ── Export ───────────────────────────────────────────────────────────────
1340
+ _resolvedColors() {
1341
+ const cs = hasWindow() ? getComputedStyle(this.svg) : null;
1342
+ const read = (name, fallback) => {
1343
+ const v = cs?.getPropertyValue(name)?.trim();
1344
+ return v || fallback;
1345
+ };
1346
+ return {
1347
+ ink: read("--vd-draw-ink", "#1f2720"),
1348
+ shapeStroke: read("--vd-draw-shape-stroke", "#245f52"),
1349
+ text: read("--vd-draw-text", "#1f2720"),
1350
+ sticky: read("--vd-draw-sticky-fill", "#fdf3c4")
1351
+ };
1352
+ }
1353
+ toSVG() {
1354
+ const shapes = this.documentData.shapes;
1355
+ const bounds = boundsOfShapes(shapes) || { x: 0, y: 0, w: 100, h: 100 };
1356
+ const pad = 20;
1357
+ const vb = {
1358
+ x: bounds.x - pad,
1359
+ y: bounds.y - pad,
1360
+ w: Math.max(1, bounds.w) + pad * 2,
1361
+ h: Math.max(1, bounds.h) + pad * 2
1362
+ };
1363
+ const colors = this._resolvedColors();
1364
+ const svg = createSvgEl("svg", {
1365
+ xmlns: SVG_NS,
1366
+ width: round(vb.w),
1367
+ height: round(vb.h),
1368
+ viewBox: `${round(vb.x)} ${round(vb.y)} ${round(vb.w)} ${round(vb.h)}`
1369
+ });
1370
+ for (const shape of shapes) {
1371
+ const el = this._renderShapeEl(shape, { standalone: true, colors });
1372
+ if (el) svg.appendChild(el);
1373
+ }
1374
+ return new XMLSerializer().serializeToString(svg);
1375
+ }
1376
+ toPNG({ scale = 2 } = {}) {
1377
+ const markup = this.toSVG();
1378
+ const bounds = boundsOfShapes(this.documentData.shapes) || { w: 100, h: 100 };
1379
+ const pad = 20;
1380
+ const w = Math.max(1, (bounds.w || 100) + pad * 2);
1381
+ const h2 = Math.max(1, (bounds.h || 100) + pad * 2);
1382
+ return new Promise((resolve, reject) => {
1383
+ try {
1384
+ const img = new Image();
1385
+ const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(markup)}`;
1386
+ img.onload = () => {
1387
+ const canvas = document.createElement("canvas");
1388
+ canvas.width = Math.ceil(w * scale);
1389
+ canvas.height = Math.ceil(h2 * scale);
1390
+ const ctx = canvas.getContext("2d");
1391
+ if (!ctx) {
1392
+ reject(new Error("VdDraw: 2D canvas context unavailable"));
1393
+ return;
1394
+ }
1395
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
1396
+ resolve(canvas.toDataURL("image/png"));
1397
+ };
1398
+ img.onerror = () => reject(new Error("VdDraw: failed to rasterize SVG"));
1399
+ img.src = url;
1400
+ } catch (err) {
1401
+ reject(err);
1402
+ }
1403
+ });
1404
+ }
1405
+ // ── History ──────────────────────────────────────────────────────────────
1406
+ _resetHistory() {
1407
+ this.history = [this.toJSON()];
1408
+ this.historyIndex = 0;
1409
+ this.lastReason = null;
1410
+ this.lastTargetKey = null;
1411
+ }
1412
+ _recordHistory(reason, targetKey) {
1413
+ if (!this.historyEnabled || this.isApplyingHistory) return;
1414
+ const snapshot = this.toJSON();
1415
+ const coalesce = COALESCE_REASONS.has(reason) && reason === this.lastReason && targetKey != null && targetKey === this.lastTargetKey && this.historyIndex >= 0;
1416
+ if (coalesce) {
1417
+ this.history[this.historyIndex] = snapshot;
1418
+ } else {
1419
+ this.history = this.history.slice(0, this.historyIndex + 1);
1420
+ this.history.push(snapshot);
1421
+ this.historyIndex = this.history.length - 1;
1422
+ if (this.history.length > this.historyLimit + 1) {
1423
+ this.history.shift();
1424
+ this.historyIndex -= 1;
1425
+ }
1426
+ }
1427
+ this.lastReason = reason;
1428
+ this.lastTargetKey = targetKey ?? null;
1429
+ }
1430
+ _emitChange(reason, extra = {}, targetKey = null) {
1431
+ this._recordHistory(reason, targetKey);
1432
+ this._syncSelectionValidity();
1433
+ this.emit("change", { reason, document: this.toJSON(), ...extra });
1434
+ }
1435
+ canUndo() {
1436
+ return this.historyIndex > 0;
1437
+ }
1438
+ canRedo() {
1439
+ return this.historyIndex < this.history.length - 1;
1440
+ }
1441
+ undo() {
1442
+ if (!this.canUndo()) return this;
1443
+ this.historyIndex -= 1;
1444
+ this._applySnapshot(this.history[this.historyIndex], "undo");
1445
+ return this;
1446
+ }
1447
+ redo() {
1448
+ if (!this.canRedo()) return this;
1449
+ this.historyIndex += 1;
1450
+ this._applySnapshot(this.history[this.historyIndex], "redo");
1451
+ return this;
1452
+ }
1453
+ clearHistory() {
1454
+ this._resetHistory();
1455
+ this.emit("history", { reason: "clear", canUndo: false, canRedo: false });
1456
+ return this;
1457
+ }
1458
+ _applySnapshot(snapshot, reason) {
1459
+ this.isApplyingHistory = true;
1460
+ const viewport = { ...this.documentData.viewport };
1461
+ this.documentData = deepClone(snapshot);
1462
+ this.documentData.viewport = viewport;
1463
+ this._syncSelectionValidity();
1464
+ this.render();
1465
+ this.emit("change", { reason, document: this.toJSON() });
1466
+ this.emit("history", { reason, canUndo: this.canUndo(), canRedo: this.canRedo() });
1467
+ this.isApplyingHistory = false;
1468
+ }
1469
+ // ── Serialization ──────────────────────────────────────────────────────
1470
+ toJSON() {
1471
+ return deepClone({
1472
+ version: VD_DRAW_VERSION,
1473
+ viewport: this.documentData.viewport,
1474
+ shapes: this.documentData.shapes
1475
+ });
1476
+ }
1477
+ load(data) {
1478
+ this.documentData = normalizeDocument(data);
1479
+ this.selectedIds.clear();
1480
+ this._resetHistory();
1481
+ this.render();
1482
+ this._emitChange("load");
1483
+ this._emitSelect();
1484
+ return this;
1485
+ }
1486
+ clear() {
1487
+ if (!this.documentData.shapes.length) return this;
1488
+ this.documentData.shapes = [];
1489
+ this.selectedIds.clear();
1490
+ this.render();
1491
+ this._emitChange("clear");
1492
+ return this;
1493
+ }
1494
+ // ── Text editing ─────────────────────────────────────────────────────────
1495
+ startTextEdit(id) {
1496
+ const shape = this.getShape(id);
1497
+ if (!shape || shape.type !== "text" && shape.type !== "sticky" || this.readonly) return false;
1498
+ this.stopTextEdit({ commit: false });
1499
+ const editor = document.createElement("textarea");
1500
+ editor.className = "vd-draw-text-editor";
1501
+ editor.value = shape.text || "";
1502
+ this.textEditor = { id, el: editor };
1503
+ this._positionTextEditor(shape, editor);
1504
+ this.textLayer.appendChild(editor);
1505
+ editor.focus();
1506
+ editor.addEventListener("blur", () => this.stopTextEdit({ commit: true }));
1507
+ editor.addEventListener("keydown", (e) => {
1508
+ if (e.key === "Escape") this.stopTextEdit({ commit: false });
1509
+ });
1510
+ return true;
1511
+ }
1512
+ _positionTextEditor(shape, editor) {
1513
+ const vp = this.documentData.viewport;
1514
+ editor.style.left = `${vp.x + shape.x * vp.scale}px`;
1515
+ editor.style.top = `${vp.y + shape.y * vp.scale}px`;
1516
+ editor.style.width = `${shape.w * vp.scale}px`;
1517
+ editor.style.height = `${shape.h * vp.scale}px`;
1518
+ }
1519
+ stopTextEdit({ commit = true } = {}) {
1520
+ if (!this.textEditor) return;
1521
+ const { id, el } = this.textEditor;
1522
+ const value = el.value;
1523
+ this.textEditor = null;
1524
+ el.remove();
1525
+ if (commit) {
1526
+ const shape = this.getShape(id);
1527
+ if (shape && shape.text !== value) {
1528
+ shape.text = value;
1529
+ this.render();
1530
+ this._emitChange("shape:text", { shapeId: id }, `text:${id}`);
1531
+ }
1532
+ }
1533
+ }
1534
+ // ── Pointer interaction ──────────────────────────────────────────────────
1535
+ _capture(pointerId) {
1536
+ if (typeof this.canvasEl.setPointerCapture === "function") {
1537
+ try {
1538
+ this.canvasEl.setPointerCapture(pointerId);
1539
+ } catch {
1540
+ }
1541
+ }
1542
+ }
1543
+ _handleToolbarClick(event) {
1544
+ const toolBtn = event.target.closest("[data-tool]");
1545
+ if (toolBtn) {
1546
+ this.setTool(toolBtn.getAttribute("data-tool"));
1547
+ return;
1548
+ }
1549
+ const actionBtn = event.target.closest("[data-action]");
1550
+ if (!actionBtn) return;
1551
+ const action = actionBtn.getAttribute("data-action");
1552
+ if (action === "undo") this.undo();
1553
+ else if (action === "redo") this.redo();
1554
+ else if (action === "duplicate") this.duplicate();
1555
+ else if (action === "delete") {
1556
+ if (this.selectedIds.size) this.deleteSelection();
1557
+ else this.clear();
1558
+ }
1559
+ }
1560
+ _handlePanelClick(event) {
1561
+ const brushBtn = event.target.closest("[data-brush]");
1562
+ if (brushBtn) {
1563
+ this.setBrush(brushBtn.getAttribute("data-brush"));
1564
+ if (this.tool !== "draw") this.setTool("draw");
1565
+ return;
1566
+ }
1567
+ const gridBtn = event.target.closest("[data-grid]");
1568
+ if (gridBtn) {
1569
+ this.toggleGrid();
1570
+ return;
1571
+ }
1572
+ const swatch = event.target.closest("[data-swatch]");
1573
+ if (swatch) this.setColor(swatch.getAttribute("data-swatch"));
1574
+ }
1575
+ _handlePanelInput(event) {
1576
+ const kind = event.target.getAttribute?.("data-style");
1577
+ if (kind === "color") this.setColor(event.target.value);
1578
+ else if (kind === "size") this.setBrushSize(Number(event.target.value));
1579
+ else if (kind === "opacity") this.setOpacity(Number(event.target.value));
1580
+ else if (kind === "grid") this.setGridSize(Number(event.target.value));
1581
+ }
1582
+ _handlePointerDown(event) {
1583
+ if (this.destroyed || event.button != null && event.button !== 0) return;
1584
+ this.canvasEl.focus();
1585
+ this.stopTextEdit({ commit: true });
1586
+ const world = this._clientToWorld(event.clientX, event.clientY);
1587
+ const shapeTarget = event.target.closest("[data-shape-id]");
1588
+ const handleTarget = event.target.closest("[data-handle]");
1589
+ if (this.tool === "hand") {
1590
+ this.interaction = this._beginPan(event);
1591
+ this._capture(event.pointerId);
1592
+ return;
1593
+ }
1594
+ if (this.tool === "eraser" && !this.readonly) {
1595
+ this.interaction = { kind: "erase", pointerId: event.pointerId, erased: /* @__PURE__ */ new Set() };
1596
+ this._capture(event.pointerId);
1597
+ this._applyErase(world);
1598
+ return;
1599
+ }
1600
+ if (this.tool === "select") {
1601
+ if (handleTarget && this.selectedIds.size) {
1602
+ this.interaction = {
1603
+ kind: "resize",
1604
+ pointerId: event.pointerId,
1605
+ handle: handleTarget.getAttribute("data-handle"),
1606
+ startBounds: boundsOfShapes(this.getSelectedShapes()),
1607
+ originals: this.getSelectedShapes().map((s) => deepClone(s)),
1608
+ start: world,
1609
+ moved: false
1610
+ };
1611
+ this._capture(event.pointerId);
1612
+ return;
1613
+ }
1614
+ if (shapeTarget) {
1615
+ const id = shapeTarget.getAttribute("data-shape-id");
1616
+ if (!this.selectedIds.has(id)) this.select(id, { additive: event.shiftKey });
1617
+ this.interaction = {
1618
+ kind: "move",
1619
+ pointerId: event.pointerId,
1620
+ start: world,
1621
+ originals: this.getSelectedShapes().map((s) => deepClone(s)),
1622
+ moved: false
1623
+ };
1624
+ this._capture(event.pointerId);
1625
+ return;
1626
+ }
1627
+ if (!event.shiftKey) this.deselect();
1628
+ this.interaction = {
1629
+ kind: "marquee",
1630
+ pointerId: event.pointerId,
1631
+ start: world,
1632
+ additive: event.shiftKey
1633
+ };
1634
+ this._capture(event.pointerId);
1635
+ return;
1636
+ }
1637
+ if (this.readonly) return;
1638
+ this.interaction = this._beginCreate(this.tool, world, event);
1639
+ this._capture(event.pointerId);
1640
+ }
1641
+ _beginPan(event) {
1642
+ return {
1643
+ kind: "pan",
1644
+ pointerId: event.pointerId,
1645
+ startClientX: event.clientX,
1646
+ startClientY: event.clientY,
1647
+ startX: this.documentData.viewport.x,
1648
+ startY: this.documentData.viewport.y
1649
+ };
1650
+ }
1651
+ _beginCreate(tool, world, event) {
1652
+ const pressure = event.pressure || 0.5;
1653
+ if (tool === "draw") {
1654
+ const shape2 = {
1655
+ id: createId("fh"),
1656
+ type: "freehand",
1657
+ brush: this.style.brush,
1658
+ color: this.style.color,
1659
+ size: this.style.size,
1660
+ opacity: this.style.opacity,
1661
+ points: [[round(world.x), round(world.y), pressure]]
1662
+ };
1663
+ this.documentData.shapes.push(shape2);
1664
+ return { kind: "freehand", pointerId: event.pointerId, shapeId: shape2.id };
1665
+ }
1666
+ if (tool === "line") {
1667
+ const shape2 = {
1668
+ id: createId("ln"),
1669
+ type: "line",
1670
+ points: [
1671
+ [round(world.x), round(world.y)],
1672
+ [round(world.x), round(world.y)]
1673
+ ],
1674
+ arrowEnd: true,
1675
+ arrowStart: false,
1676
+ color: this.style.color,
1677
+ strokeWidth: this._shapeStrokeWidth(),
1678
+ opacity: this.style.opacity
1679
+ };
1680
+ this.documentData.shapes.push(shape2);
1681
+ return { kind: "create-line", pointerId: event.pointerId, shapeId: shape2.id };
1682
+ }
1683
+ const type = tool;
1684
+ const shape = {
1685
+ id: createId(type.slice(0, 2)),
1686
+ type,
1687
+ x: round(world.x),
1688
+ y: round(world.y),
1689
+ w: 1,
1690
+ h: 1,
1691
+ rotation: 0,
1692
+ color: this.style.color,
1693
+ strokeWidth: this._shapeStrokeWidth(),
1694
+ opacity: this.style.opacity
1695
+ };
1696
+ if (type === "text" || type === "sticky") shape.text = "";
1697
+ this.documentData.shapes.push(shape);
1698
+ return { kind: "create-box", pointerId: event.pointerId, shapeId: shape.id, start: world };
1699
+ }
1700
+ _applyErase(world) {
1701
+ const it = this.interaction;
1702
+ if (!it || it.kind !== "erase") return;
1703
+ const threshold = ERASER_RADIUS / this.documentData.viewport.scale;
1704
+ let changed = false;
1705
+ for (const shape of this.documentData.shapes) {
1706
+ if (it.erased.has(shape.id)) continue;
1707
+ if (distanceToShape(shape, world.x, world.y) <= threshold) {
1708
+ it.erased.add(shape.id);
1709
+ changed = true;
1710
+ }
1711
+ }
1712
+ if (changed) this.render({ scene: true, overlay: false });
1713
+ }
1714
+ _handlePointerMove(event) {
1715
+ const it = this.interaction;
1716
+ if (!it) return;
1717
+ if (it.kind === "pan") {
1718
+ const vp = this.documentData.viewport;
1719
+ vp.x = it.startX + (event.clientX - it.startClientX);
1720
+ vp.y = it.startY + (event.clientY - it.startClientY);
1721
+ this.render({ scene: false });
1722
+ return;
1723
+ }
1724
+ const world = this._clientToWorld(event.clientX, event.clientY);
1725
+ if (it.kind === "erase") {
1726
+ this._applyErase(world);
1727
+ return;
1728
+ }
1729
+ if (it.kind === "move") {
1730
+ const dx = world.x - it.start.x;
1731
+ const dy = world.y - it.start.y;
1732
+ if (Math.hypot(dx, dy) * this.documentData.viewport.scale > DRAG_THRESHOLD) it.moved = true;
1733
+ for (const orig of it.originals) this._replaceShape(translateShape(orig, dx, dy));
1734
+ const movingIds = new Set(it.originals.map((s) => s.id));
1735
+ const snap = this._computeSnap(boundsOfShapes(this.getSelectedShapes()), movingIds);
1736
+ if (snap.dx || snap.dy)
1737
+ for (const orig of it.originals)
1738
+ this._replaceShape(translateShape(orig, dx + snap.dx, dy + snap.dy));
1739
+ this._renderGuides(snap.guides);
1740
+ this.render({ scene: true, guides: false });
1741
+ return;
1742
+ }
1743
+ if (it.kind === "resize") {
1744
+ const t = this._resizeBounds(
1745
+ it.startBounds,
1746
+ it.handle,
1747
+ world.x - it.start.x,
1748
+ world.y - it.start.y
1749
+ );
1750
+ const sx = it.startBounds.w === 0 ? 1 : t.w / it.startBounds.w;
1751
+ const sy = it.startBounds.h === 0 ? 1 : t.h / it.startBounds.h;
1752
+ for (const orig of it.originals) {
1753
+ const scaled = scaleShape(orig, it.startBounds.x, it.startBounds.y, sx, sy);
1754
+ this._replaceShape(translateShape(scaled, t.x - it.startBounds.x, t.y - it.startBounds.y));
1755
+ }
1756
+ it.moved = true;
1757
+ this.render();
1758
+ return;
1759
+ }
1760
+ if (it.kind === "marquee") {
1761
+ it.current = world;
1762
+ this._renderMarquee(it.start, world);
1763
+ return;
1764
+ }
1765
+ if (it.kind === "freehand") {
1766
+ const shape = this.getShape(it.shapeId);
1767
+ if (shape) {
1768
+ shape.points.push([round(world.x), round(world.y), event.pressure || 0.5]);
1769
+ this.render({ scene: true });
1770
+ }
1771
+ return;
1772
+ }
1773
+ if (it.kind === "create-line") {
1774
+ const shape = this.getShape(it.shapeId);
1775
+ if (shape) {
1776
+ shape.points[1] = [round(world.x), round(world.y)];
1777
+ this.render({ scene: true });
1778
+ }
1779
+ return;
1780
+ }
1781
+ if (it.kind === "create-box") {
1782
+ const shape = this.getShape(it.shapeId);
1783
+ if (shape) {
1784
+ shape.x = round(Math.min(it.start.x, world.x));
1785
+ shape.y = round(Math.min(it.start.y, world.y));
1786
+ shape.w = round(Math.max(1, Math.abs(world.x - it.start.x)));
1787
+ shape.h = round(Math.max(1, Math.abs(world.y - it.start.y)));
1788
+ this.render({ scene: true });
1789
+ }
1790
+ }
1791
+ }
1792
+ _resizeBounds(start, handle, dx, dy) {
1793
+ let { x, y, w, h: h2 } = start;
1794
+ if (handle.includes("e")) w = Math.max(1, start.w + dx);
1795
+ if (handle.includes("s")) h2 = Math.max(1, start.h + dy);
1796
+ if (handle.includes("w")) {
1797
+ w = Math.max(1, start.w - dx);
1798
+ x = start.x + (start.w - w);
1799
+ }
1800
+ if (handle.includes("n")) {
1801
+ h2 = Math.max(1, start.h - dy);
1802
+ y = start.y + (start.h - h2);
1803
+ }
1804
+ return { x, y, w, h: h2 };
1805
+ }
1806
+ _handlePointerUp(event) {
1807
+ const it = this.interaction;
1808
+ if (!it) return;
1809
+ this.interaction = null;
1810
+ if (typeof this.canvasEl.releasePointerCapture === "function" && this.canvasEl.hasPointerCapture?.(event.pointerId)) {
1811
+ try {
1812
+ this.canvasEl.releasePointerCapture(event.pointerId);
1813
+ } catch {
1814
+ }
1815
+ }
1816
+ this._renderGuides([]);
1817
+ clearChildren(this.marqueeLayer);
1818
+ if (it.kind === "pan") {
1819
+ this._emitViewportChange("viewport:pan");
1820
+ return;
1821
+ }
1822
+ if (it.kind === "erase") {
1823
+ if (it.erased.size) {
1824
+ const ids = [...it.erased];
1825
+ this.documentData.shapes = this.documentData.shapes.filter((s) => !it.erased.has(s.id));
1826
+ this.render();
1827
+ this._emitChange("shape:erase", { shapeIds: ids });
1828
+ }
1829
+ return;
1830
+ }
1831
+ if (it.kind === "move" && it.moved) {
1832
+ this.render();
1833
+ this._emitChange("shape:move", { shapeIds: [...this.selectedIds] });
1834
+ return;
1835
+ }
1836
+ if (it.kind === "resize" && it.moved) {
1837
+ this.render();
1838
+ this._emitChange("shape:resize", { shapeIds: [...this.selectedIds] });
1839
+ return;
1840
+ }
1841
+ if (it.kind === "marquee") {
1842
+ const box = this._boxFromPoints(it.start, it.current || it.start);
1843
+ this.selectInBounds(box, { additive: it.additive });
1844
+ return;
1845
+ }
1846
+ if (it.kind === "freehand" || it.kind === "create-line" || it.kind === "create-box") {
1847
+ const shape = this.getShape(it.shapeId);
1848
+ if (!shape) return;
1849
+ if (it.kind === "freehand") shape.points = simplifyPoints(shape.points);
1850
+ const b = shapeBounds(shape);
1851
+ if (it.kind !== "freehand" && b.w < 2 && b.h < 2 && shape.type !== "text" && shape.type !== "sticky") {
1852
+ this.documentData.shapes = this.documentData.shapes.filter((s) => s.id !== it.shapeId);
1853
+ this.render();
1854
+ return;
1855
+ }
1856
+ if (shape.type === "text" || shape.type === "sticky") {
1857
+ if (b.w < 2 && b.h < 2) {
1858
+ shape.w = shape.type === "sticky" ? 160 : 120;
1859
+ shape.h = shape.type === "sticky" ? 120 : 40;
1860
+ }
1861
+ }
1862
+ if (it.kind !== "freehand") {
1863
+ this.select(shape.id);
1864
+ this.setTool("select");
1865
+ }
1866
+ this.render();
1867
+ this._emitChange("shape:add", { shape: deepClone(shape), shapeId: shape.id });
1868
+ if (shape.type === "text" || shape.type === "sticky") this.startTextEdit(shape.id);
1869
+ }
1870
+ }
1871
+ _boxFromPoints(a, b) {
1872
+ return {
1873
+ x: Math.min(a.x, b.x),
1874
+ y: Math.min(a.y, b.y),
1875
+ w: Math.abs(a.x - b.x),
1876
+ h: Math.abs(a.y - b.y)
1877
+ };
1878
+ }
1879
+ _handleWheel(event) {
1880
+ event.preventDefault();
1881
+ const local = this._clientToLocal(event.clientX, event.clientY);
1882
+ const factor = event.deltaY < 0 ? 1.1 : 1 / 1.1;
1883
+ this.scaleAround(factor, local.x, local.y);
1884
+ this.render({ scene: false });
1885
+ this._emitViewportChange("viewport:zoom");
1886
+ }
1887
+ _handleKeyDown(event) {
1888
+ if (this.textEditor) return;
1889
+ const meta = event.metaKey || event.ctrlKey;
1890
+ if (meta && event.key.toLowerCase() === "z") {
1891
+ event.preventDefault();
1892
+ if (event.shiftKey) this.redo();
1893
+ else this.undo();
1894
+ return;
1895
+ }
1896
+ if (meta && event.key.toLowerCase() === "y") {
1897
+ event.preventDefault();
1898
+ this.redo();
1899
+ return;
1900
+ }
1901
+ if (meta && event.key.toLowerCase() === "a") {
1902
+ event.preventDefault();
1903
+ this.selectAll();
1904
+ return;
1905
+ }
1906
+ if (meta && event.key.toLowerCase() === "c") {
1907
+ this.copy();
1908
+ return;
1909
+ }
1910
+ if (meta && event.key.toLowerCase() === "v") {
1911
+ this.paste();
1912
+ return;
1913
+ }
1914
+ if (meta && event.key.toLowerCase() === "d") {
1915
+ event.preventDefault();
1916
+ this.duplicate();
1917
+ return;
1918
+ }
1919
+ if (this.readonly) return;
1920
+ if (event.key === "Delete" || event.key === "Backspace") {
1921
+ event.preventDefault();
1922
+ this.deleteSelection();
1923
+ return;
1924
+ }
1925
+ const nudgeMap = {
1926
+ ArrowLeft: [-1, 0],
1927
+ ArrowRight: [1, 0],
1928
+ ArrowUp: [0, -1],
1929
+ ArrowDown: [0, 1]
1930
+ };
1931
+ if (nudgeMap[event.key] && this.selectedIds.size) {
1932
+ event.preventDefault();
1933
+ const step = event.shiftKey ? 10 : 1;
1934
+ const [dx, dy] = nudgeMap[event.key];
1935
+ this.nudge(dx * step, dy * step);
1936
+ }
1937
+ }
1938
+ _handleResize() {
1939
+ if (this.destroyed) return;
1940
+ this.render({ scene: false });
1941
+ }
1942
+ // ── Rendering ────────────────────────────────────────────────────────────
1943
+ render(flags = {}) {
1944
+ if (this.destroyed) return;
1945
+ const { scene = true, overlay = true } = flags;
1946
+ const vp = this.documentData.viewport;
1947
+ this.world.setAttribute("transform", `matrix(${vp.scale} 0 0 ${vp.scale} ${vp.x} ${vp.y})`);
1948
+ if (scene) {
1949
+ clearChildren(this.shapesLayer);
1950
+ const erasing = this.interaction && this.interaction.kind === "erase" ? this.interaction.erased : null;
1951
+ for (const shape of this.documentData.shapes) {
1952
+ if (erasing && erasing.has(shape.id)) continue;
1953
+ const el = this._renderShapeEl(shape, {});
1954
+ if (el) this.shapesLayer.appendChild(el);
1955
+ }
1956
+ }
1957
+ if (overlay) this._renderOverlay();
1958
+ }
1959
+ // Colors are applied via inline `style` (which wins over the CSS class rules
1960
+ // and serializes self-contained), so a picked color always renders.
1961
+ _renderShapeEl(shape, { standalone = false, colors = null }) {
1962
+ let el = null;
1963
+ const setOpacity = (node) => {
1964
+ if (shape.opacity != null && shape.opacity !== 1)
1965
+ node.setAttribute("opacity", String(shape.opacity));
1966
+ };
1967
+ if (shape.type === "freehand") {
1968
+ el = createSvgEl("path", { d: brushStrokePath(shape) });
1969
+ el.classList.add("vd-draw-ink");
1970
+ el.style.stroke = "none";
1971
+ const fill = shape.color || (standalone && colors ? colors.ink : "");
1972
+ if (fill) el.style.fill = fill;
1973
+ if (shape.opacity != null && shape.opacity !== 1)
1974
+ el.style.fillOpacity = String(shape.opacity);
1975
+ const preset = BRUSH_PRESETS[shape.brush];
1976
+ if (preset && preset.blend && preset.blend !== "normal") el.style.mixBlendMode = preset.blend;
1977
+ } else if (shape.type === "rectangle") {
1978
+ el = createSvgEl("rect", { x: shape.x, y: shape.y, width: shape.w, height: shape.h, rx: 4 });
1979
+ el.classList.add("vd-draw-shape");
1980
+ this._applyShapeStroke(el, shape, standalone, colors);
1981
+ setOpacity(el);
1982
+ } else if (shape.type === "ellipse") {
1983
+ el = createSvgEl("ellipse", {
1984
+ cx: shape.x + shape.w / 2,
1985
+ cy: shape.y + shape.h / 2,
1986
+ rx: shape.w / 2,
1987
+ ry: shape.h / 2
1988
+ });
1989
+ el.classList.add("vd-draw-shape");
1990
+ this._applyShapeStroke(el, shape, standalone, colors);
1991
+ setOpacity(el);
1992
+ } else if (shape.type === "line") {
1993
+ el = createSvgEl("path", { d: pointsToPath(shape.points) });
1994
+ el.classList.add("vd-draw-shape");
1995
+ this._applyShapeStroke(el, shape, standalone, colors);
1996
+ setOpacity(el);
1997
+ if (shape.arrowEnd) el.setAttribute("marker-end", `url(#${this._svgId("arrow")})`);
1998
+ if (shape.arrowStart) el.setAttribute("marker-start", `url(#${this._svgId("arrow")})`);
1999
+ } else if (shape.type === "text" || shape.type === "sticky") {
2000
+ el = createSvgEl("g");
2001
+ setOpacity(el);
2002
+ if (shape.type === "sticky") {
2003
+ const bg = createSvgEl("rect", {
2004
+ x: shape.x,
2005
+ y: shape.y,
2006
+ width: shape.w,
2007
+ height: shape.h,
2008
+ rx: 4
2009
+ });
2010
+ bg.classList.add("vd-draw-sticky");
2011
+ if (shape.fill) bg.style.fill = shape.fill;
2012
+ else if (standalone && colors) bg.style.fill = colors.sticky;
2013
+ el.appendChild(bg);
2014
+ }
2015
+ const text = createSvgEl("text", { x: shape.x + 6, y: shape.y + 18 });
2016
+ text.classList.add("vd-draw-text");
2017
+ const fill = shape.color || (standalone && colors ? colors.text : "");
2018
+ if (fill) text.style.fill = fill;
2019
+ text.textContent = shape.text || "";
2020
+ el.appendChild(text);
2021
+ }
2022
+ if (el && !standalone) el.setAttribute("data-shape-id", shape.id);
2023
+ return el;
2024
+ }
2025
+ _applyShapeStroke(el, shape, standalone, colors) {
2026
+ const stroke = shape.color || (standalone && colors ? colors.shapeStroke : "");
2027
+ if (stroke) el.style.stroke = stroke;
2028
+ if (shape.strokeWidth != null) el.setAttribute("stroke-width", String(shape.strokeWidth));
2029
+ el.style.fill = shape.fill ? shape.fill : "none";
2030
+ }
2031
+ _renderOverlay() {
2032
+ clearChildren(this.overlayLayer);
2033
+ const selected = this.getSelectedShapes();
2034
+ if (!selected.length || this.readonly) return;
2035
+ const bounds = boundsOfShapes(selected);
2036
+ if (!bounds) return;
2037
+ const box = createSvgEl("rect", {
2038
+ class: "vd-draw-selection-box",
2039
+ x: bounds.x,
2040
+ y: bounds.y,
2041
+ width: bounds.w,
2042
+ height: bounds.h,
2043
+ fill: "none"
2044
+ });
2045
+ box.setAttribute("vector-effect", "non-scaling-stroke");
2046
+ this.overlayLayer.appendChild(box);
2047
+ const scale = this.documentData.viewport.scale || 1;
2048
+ const r = HANDLE_SIZE / scale / 2;
2049
+ for (const handle of resizeHandlePositions(bounds)) {
2050
+ const dot = createSvgEl("rect", {
2051
+ class: "vd-draw-handle",
2052
+ x: handle.x - r,
2053
+ y: handle.y - r,
2054
+ width: r * 2,
2055
+ height: r * 2,
2056
+ "data-handle": handle.key
2057
+ });
2058
+ dot.setAttribute("vector-effect", "non-scaling-stroke");
2059
+ this.overlayLayer.appendChild(dot);
2060
+ }
2061
+ }
2062
+ _renderMarquee(a, b) {
2063
+ clearChildren(this.marqueeLayer);
2064
+ const box = this._boxFromPoints(a, b);
2065
+ const rect = createSvgEl("rect", {
2066
+ class: "vd-draw-marquee-rect",
2067
+ x: box.x,
2068
+ y: box.y,
2069
+ width: box.w,
2070
+ height: box.h
2071
+ });
2072
+ rect.setAttribute("vector-effect", "non-scaling-stroke");
2073
+ this.marqueeLayer.appendChild(rect);
2074
+ }
2075
+ _renderGuides(guides) {
2076
+ clearChildren(this.guidesLayer);
2077
+ for (const g of guides || []) {
2078
+ const line = createSvgEl("line", {
2079
+ class: "vd-draw-guide",
2080
+ x1: g.x1,
2081
+ y1: g.y1,
2082
+ x2: g.x2,
2083
+ y2: g.y2
2084
+ });
2085
+ line.setAttribute("vector-effect", "non-scaling-stroke");
2086
+ this.guidesLayer.appendChild(line);
2087
+ }
2088
+ }
2089
+ // ── Ready + lifecycle ────────────────────────────────────────────────────
2090
+ _scheduleReady() {
2091
+ const fire = () => {
2092
+ if (this.destroyed || this._ready) return;
2093
+ this._ready = true;
2094
+ if (this.autoFit) this.fitView();
2095
+ this.emit("ready", this);
2096
+ };
2097
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(fire);
2098
+ else setTimeout(fire, 0);
2099
+ }
2100
+ destroy() {
2101
+ if (this.destroyed) return;
2102
+ this.destroyed = true;
2103
+ this.stopTextEdit({ commit: false });
2104
+ this._unbindEvents();
2105
+ this.listeners.clear();
2106
+ if (this.element) this.element.replaceChildren();
2107
+ }
2108
+ };
2109
+
2110
+ // src/draw/vue.js
2111
+ var FORWARDED_EVENTS = ["change", "select", "viewport", "ready"];
2112
+ var VdDraw2 = defineComponent({
2113
+ name: "VdDraw",
2114
+ props: {
2115
+ /** Drawing document — `{ shapes, viewport }`. */
2116
+ data: { type: Object, default: () => ({}) },
2117
+ /** Render as a non-editable viewer (no toolbar, no editing). */
2118
+ readonly: { type: Boolean, default: false },
2119
+ /** Active tool — updated live without recreating the editor. */
2120
+ tool: { type: String, default: "draw" },
2121
+ /** Background grid size in px. */
2122
+ gridSize: { type: Number, default: void 0 },
2123
+ /** Show the background grid — updated live (default true). */
2124
+ showGrid: { type: Boolean, default: true },
2125
+ /** Snap shapes to nearby edges/centres while moving/resizing. */
2126
+ snap: { type: Boolean, default: true },
2127
+ /** Fit the view to content once the editor reports a measurable size. */
2128
+ autoFit: { type: Boolean, default: false },
2129
+ /** Enable the built-in undo/redo history (default true). */
2130
+ history: { type: Boolean, default: true },
2131
+ /** Maximum number of history entries to retain. */
2132
+ historyLimit: { type: Number, default: void 0 }
2133
+ },
2134
+ emits: ["change", "select", "viewport", "ready"],
2135
+ setup(props, { emit, expose }) {
2136
+ const el = ref(null);
2137
+ let instance = null;
2138
+ const create = () => {
2139
+ instance = new VdDraw({
2140
+ element: el.value,
2141
+ data: props.data,
2142
+ readonly: props.readonly,
2143
+ tool: props.tool,
2144
+ gridSize: props.gridSize,
2145
+ showGrid: props.showGrid,
2146
+ snap: props.snap,
2147
+ autoFit: props.autoFit,
2148
+ history: props.history,
2149
+ historyLimit: props.historyLimit
2150
+ });
2151
+ FORWARDED_EVENTS.forEach((name) => {
2152
+ instance.on(name, (payload) => emit(name, payload));
2153
+ });
2154
+ };
2155
+ onMounted(() => {
2156
+ if (typeof window === "undefined" || !el.value) return;
2157
+ create();
2158
+ });
2159
+ watch(
2160
+ () => props.data,
2161
+ (next) => {
2162
+ if (instance && typeof instance.load === "function") instance.load(next);
2163
+ },
2164
+ { deep: true }
2165
+ );
2166
+ watch(
2167
+ () => props.tool,
2168
+ (next) => instance?.setTool(next)
2169
+ );
2170
+ watch(
2171
+ () => props.showGrid,
2172
+ (next) => instance?.setGridVisible(next)
2173
+ );
2174
+ watch(
2175
+ () => [
2176
+ props.readonly,
2177
+ props.gridSize,
2178
+ props.snap,
2179
+ props.autoFit,
2180
+ props.history,
2181
+ props.historyLimit
2182
+ ],
2183
+ () => {
2184
+ if (!instance) return;
2185
+ instance.destroy();
2186
+ create();
2187
+ }
2188
+ );
2189
+ onBeforeUnmount(() => {
2190
+ if (instance) {
2191
+ instance.destroy();
2192
+ instance = null;
2193
+ }
2194
+ });
2195
+ expose({
2196
+ getInstance: () => instance,
2197
+ setTool: (tool) => instance?.setTool(tool),
2198
+ undo: () => instance?.undo(),
2199
+ redo: () => instance?.redo(),
2200
+ canUndo: () => Boolean(instance?.canUndo()),
2201
+ canRedo: () => Boolean(instance?.canRedo()),
2202
+ toSVG: () => instance?.toSVG(),
2203
+ toPNG: (options) => instance?.toPNG(options)
2204
+ });
2205
+ return () => h("div", { ref: el, class: "vd-draw" });
2206
+ }
2207
+ });
2208
+ export {
2209
+ BRUSH_PRESETS,
2210
+ DRAW_SHAPE_TYPES,
2211
+ DRAW_TOOLS,
2212
+ VD_DRAW_VERSION,
2213
+ VdDraw2 as VdDraw,
2214
+ VdDraw as VdDrawCore
2215
+ };
2216
+ //# sourceMappingURL=index.js.map