@fieldnotes/core 0.1.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.
package/dist/index.js ADDED
@@ -0,0 +1,2184 @@
1
+ // src/core/event-bus.ts
2
+ var EventBus = class {
3
+ listeners = /* @__PURE__ */ new Map();
4
+ on(event, listener) {
5
+ const existing = this.listeners.get(event);
6
+ if (existing) {
7
+ existing.add(listener);
8
+ } else {
9
+ const set = /* @__PURE__ */ new Set([listener]);
10
+ this.listeners.set(event, set);
11
+ }
12
+ return () => this.off(event, listener);
13
+ }
14
+ off(event, listener) {
15
+ this.listeners.get(event)?.delete(listener);
16
+ }
17
+ emit(event, data) {
18
+ this.listeners.get(event)?.forEach((listener) => listener(data));
19
+ }
20
+ clear() {
21
+ this.listeners.clear();
22
+ }
23
+ };
24
+
25
+ // src/core/state-serializer.ts
26
+ var CURRENT_VERSION = 1;
27
+ function exportState(elements, camera) {
28
+ return {
29
+ version: CURRENT_VERSION,
30
+ camera: {
31
+ position: { ...camera.position },
32
+ zoom: camera.zoom
33
+ },
34
+ elements: elements.map((el) => structuredClone(el))
35
+ };
36
+ }
37
+ function parseState(json) {
38
+ const data = JSON.parse(json);
39
+ validateState(data);
40
+ return data;
41
+ }
42
+ function validateState(data) {
43
+ if (!data || typeof data !== "object") {
44
+ throw new Error("Invalid state: expected an object");
45
+ }
46
+ const obj = data;
47
+ if (typeof obj["version"] !== "number") {
48
+ throw new Error("Invalid state: missing or invalid version");
49
+ }
50
+ if (!obj["camera"] || typeof obj["camera"] !== "object") {
51
+ throw new Error("Invalid state: missing camera");
52
+ }
53
+ const cam = obj["camera"];
54
+ if (!cam["position"] || typeof cam["position"] !== "object") {
55
+ throw new Error("Invalid state: missing camera.position");
56
+ }
57
+ const pos = cam["position"];
58
+ if (typeof pos["x"] !== "number" || typeof pos["y"] !== "number") {
59
+ throw new Error("Invalid state: camera.position must have x and y numbers");
60
+ }
61
+ if (typeof cam["zoom"] !== "number") {
62
+ throw new Error("Invalid state: missing camera.zoom");
63
+ }
64
+ if (!Array.isArray(obj["elements"])) {
65
+ throw new Error("Invalid state: elements must be an array");
66
+ }
67
+ for (const el of obj["elements"]) {
68
+ validateElement(el);
69
+ migrateElement(el);
70
+ }
71
+ }
72
+ var VALID_TYPES = /* @__PURE__ */ new Set(["stroke", "note", "arrow", "image", "html"]);
73
+ function validateElement(el) {
74
+ if (!el || typeof el !== "object") {
75
+ throw new Error("Invalid element: expected an object");
76
+ }
77
+ const obj = el;
78
+ if (typeof obj["id"] !== "string") {
79
+ throw new Error("Invalid element: missing id");
80
+ }
81
+ if (typeof obj["type"] !== "string" || !VALID_TYPES.has(obj["type"])) {
82
+ throw new Error(`Invalid element: unknown type "${String(obj["type"])}"`);
83
+ }
84
+ if (typeof obj["zIndex"] !== "number") {
85
+ throw new Error("Invalid element: missing zIndex");
86
+ }
87
+ }
88
+ function migrateElement(obj) {
89
+ if (obj["type"] === "arrow" && typeof obj["bend"] !== "number") {
90
+ obj["bend"] = 0;
91
+ }
92
+ }
93
+
94
+ // src/canvas/camera.ts
95
+ var DEFAULT_MIN_ZOOM = 0.1;
96
+ var DEFAULT_MAX_ZOOM = 10;
97
+ var Camera = class {
98
+ x = 0;
99
+ y = 0;
100
+ z = 1;
101
+ minZoom;
102
+ maxZoom;
103
+ changeListeners = /* @__PURE__ */ new Set();
104
+ constructor(options = {}) {
105
+ this.minZoom = options.minZoom ?? DEFAULT_MIN_ZOOM;
106
+ this.maxZoom = options.maxZoom ?? DEFAULT_MAX_ZOOM;
107
+ }
108
+ get position() {
109
+ return { x: this.x, y: this.y };
110
+ }
111
+ get zoom() {
112
+ return this.z;
113
+ }
114
+ pan(dx, dy) {
115
+ this.x += dx;
116
+ this.y += dy;
117
+ this.notifyChange();
118
+ }
119
+ moveTo(x, y) {
120
+ this.x = x;
121
+ this.y = y;
122
+ this.notifyChange();
123
+ }
124
+ setZoom(level) {
125
+ this.z = Math.min(this.maxZoom, Math.max(this.minZoom, level));
126
+ this.notifyChange();
127
+ }
128
+ zoomAt(level, screenPoint) {
129
+ const before = this.screenToWorld(screenPoint);
130
+ this.z = Math.min(this.maxZoom, Math.max(this.minZoom, level));
131
+ const after = this.screenToWorld(screenPoint);
132
+ this.x += (after.x - before.x) * this.z;
133
+ this.y += (after.y - before.y) * this.z;
134
+ this.notifyChange();
135
+ }
136
+ screenToWorld(screen) {
137
+ return {
138
+ x: (screen.x - this.x) / this.z,
139
+ y: (screen.y - this.y) / this.z
140
+ };
141
+ }
142
+ worldToScreen(world) {
143
+ return {
144
+ x: world.x * this.z + this.x,
145
+ y: world.y * this.z + this.y
146
+ };
147
+ }
148
+ toCSSTransform() {
149
+ return `translate3d(${this.x}px, ${this.y}px, 0) scale(${this.z})`;
150
+ }
151
+ onChange(listener) {
152
+ this.changeListeners.add(listener);
153
+ return () => this.changeListeners.delete(listener);
154
+ }
155
+ notifyChange() {
156
+ this.changeListeners.forEach((fn) => fn());
157
+ }
158
+ };
159
+
160
+ // src/canvas/background.ts
161
+ var DEFAULTS = {
162
+ pattern: "dots",
163
+ spacing: 24,
164
+ color: "#d0d0d0",
165
+ dotRadius: 1,
166
+ lineWidth: 0.5
167
+ };
168
+ var Background = class {
169
+ pattern;
170
+ spacing;
171
+ color;
172
+ dotRadius;
173
+ lineWidth;
174
+ constructor(options = {}) {
175
+ this.pattern = options.pattern ?? DEFAULTS.pattern;
176
+ this.spacing = options.spacing ?? DEFAULTS.spacing;
177
+ this.color = options.color ?? DEFAULTS.color;
178
+ this.dotRadius = options.dotRadius ?? DEFAULTS.dotRadius;
179
+ this.lineWidth = options.lineWidth ?? DEFAULTS.lineWidth;
180
+ }
181
+ render(ctx, camera) {
182
+ const { width, height } = ctx.canvas;
183
+ const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
184
+ const cssWidth = width / dpr;
185
+ const cssHeight = height / dpr;
186
+ ctx.save();
187
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
188
+ ctx.clearRect(0, 0, cssWidth, cssHeight);
189
+ if (this.pattern === "dots") {
190
+ this.renderDots(ctx, camera, cssWidth, cssHeight);
191
+ } else if (this.pattern === "grid") {
192
+ this.renderGrid(ctx, camera, cssWidth, cssHeight);
193
+ }
194
+ ctx.restore();
195
+ }
196
+ renderDots(ctx, camera, width, height) {
197
+ const spacing = this.spacing * camera.zoom;
198
+ const offsetX = camera.position.x % spacing;
199
+ const offsetY = camera.position.y % spacing;
200
+ const radius = this.dotRadius * Math.min(camera.zoom, 2);
201
+ ctx.fillStyle = this.color;
202
+ ctx.beginPath();
203
+ for (let x = offsetX; x < width; x += spacing) {
204
+ for (let y = offsetY; y < height; y += spacing) {
205
+ ctx.moveTo(x + radius, y);
206
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
207
+ }
208
+ }
209
+ ctx.fill();
210
+ }
211
+ renderGrid(ctx, camera, width, height) {
212
+ const spacing = this.spacing * camera.zoom;
213
+ const offsetX = camera.position.x % spacing;
214
+ const offsetY = camera.position.y % spacing;
215
+ const lineW = this.lineWidth * Math.min(camera.zoom, 2);
216
+ ctx.fillStyle = this.color;
217
+ for (let x = offsetX; x < width; x += spacing) {
218
+ ctx.fillRect(x, 0, lineW, height);
219
+ }
220
+ for (let y = offsetY; y < height; y += spacing) {
221
+ ctx.fillRect(0, y, width, lineW);
222
+ }
223
+ }
224
+ };
225
+
226
+ // src/canvas/input-handler.ts
227
+ var ZOOM_SENSITIVITY = 1e-3;
228
+ var MIDDLE_BUTTON = 1;
229
+ var InputHandler = class {
230
+ constructor(element, camera, options = {}) {
231
+ this.element = element;
232
+ this.camera = camera;
233
+ this.toolManager = options.toolManager ?? null;
234
+ this.toolContext = options.toolContext ?? null;
235
+ this.historyRecorder = options.historyRecorder ?? null;
236
+ this.historyStack = options.historyStack ?? null;
237
+ this.element.style.touchAction = "none";
238
+ this.bind();
239
+ }
240
+ isPanning = false;
241
+ lastPointer = { x: 0, y: 0 };
242
+ spaceHeld = false;
243
+ activePointers = /* @__PURE__ */ new Map();
244
+ lastPinchDistance = 0;
245
+ lastPinchCenter = { x: 0, y: 0 };
246
+ toolManager;
247
+ toolContext;
248
+ historyRecorder;
249
+ historyStack;
250
+ isToolActive = false;
251
+ abortController = new AbortController();
252
+ setToolManager(toolManager, toolContext) {
253
+ this.toolManager = toolManager;
254
+ this.toolContext = toolContext;
255
+ }
256
+ destroy() {
257
+ this.abortController.abort();
258
+ }
259
+ bind() {
260
+ const opts = { signal: this.abortController.signal };
261
+ this.element.addEventListener("wheel", this.onWheel, { ...opts, passive: false });
262
+ this.element.addEventListener("pointerdown", this.onPointerDown, opts);
263
+ this.element.addEventListener("pointermove", this.onPointerMove, opts);
264
+ this.element.addEventListener("pointerup", this.onPointerUp, opts);
265
+ this.element.addEventListener("pointerleave", this.onPointerUp, opts);
266
+ this.element.addEventListener("pointercancel", this.onPointerUp, opts);
267
+ window.addEventListener("keydown", this.onKeyDown, opts);
268
+ window.addEventListener("keyup", this.onKeyUp, opts);
269
+ }
270
+ onWheel = (e) => {
271
+ e.preventDefault();
272
+ const zoomFactor = 1 - e.deltaY * ZOOM_SENSITIVITY;
273
+ const newZoom = this.camera.zoom * zoomFactor;
274
+ this.camera.zoomAt(newZoom, { x: e.clientX, y: e.clientY });
275
+ };
276
+ isInteractiveHtmlContent(e) {
277
+ const target = e.target;
278
+ if (!target) return false;
279
+ const node = target.closest("[data-element-id]");
280
+ if (!node) return false;
281
+ const elementId = node.dataset["elementId"];
282
+ if (!elementId) return false;
283
+ const store = this.toolContext?.store;
284
+ if (!store) return false;
285
+ const element = store.getById(elementId);
286
+ if (!element || element.type !== "html") return false;
287
+ return true;
288
+ }
289
+ onPointerDown = (e) => {
290
+ this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
291
+ if (this.isInteractiveHtmlContent(e)) {
292
+ return;
293
+ }
294
+ this.element.setPointerCapture?.(e.pointerId);
295
+ if (this.activePointers.size === 2) {
296
+ this.startPinch();
297
+ this.cancelToolIfActive(e);
298
+ return;
299
+ }
300
+ if (e.button === MIDDLE_BUTTON || e.button === 0 && this.spaceHeld) {
301
+ this.isPanning = true;
302
+ this.lastPointer = { x: e.clientX, y: e.clientY };
303
+ return;
304
+ }
305
+ if (this.activePointers.size === 1 && e.button === 0) {
306
+ this.dispatchToolDown(e);
307
+ }
308
+ };
309
+ onPointerMove = (e) => {
310
+ if (this.activePointers.has(e.pointerId)) {
311
+ this.activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
312
+ }
313
+ if (this.activePointers.size === 2) {
314
+ this.handlePinchMove();
315
+ return;
316
+ }
317
+ if (this.isPanning) {
318
+ const dx = e.clientX - this.lastPointer.x;
319
+ const dy = e.clientY - this.lastPointer.y;
320
+ this.lastPointer = { x: e.clientX, y: e.clientY };
321
+ this.camera.pan(dx, dy);
322
+ return;
323
+ }
324
+ if (this.isToolActive) {
325
+ this.dispatchToolMove(e);
326
+ } else if (this.activePointers.size === 0) {
327
+ this.dispatchToolHover(e);
328
+ }
329
+ };
330
+ onPointerUp = (e) => {
331
+ this.activePointers.delete(e.pointerId);
332
+ if (this.activePointers.size < 2) {
333
+ this.lastPinchDistance = 0;
334
+ }
335
+ if (this.isPanning && this.activePointers.size === 0) {
336
+ this.isPanning = false;
337
+ }
338
+ if (this.isToolActive) {
339
+ this.dispatchToolUp(e);
340
+ this.isToolActive = false;
341
+ }
342
+ };
343
+ onKeyDown = (e) => {
344
+ if (e.target?.isContentEditable) return;
345
+ if (e.key === " ") {
346
+ this.spaceHeld = true;
347
+ }
348
+ if (e.key === "Delete" || e.key === "Backspace") {
349
+ this.deleteSelected();
350
+ }
351
+ if ((e.ctrlKey || e.metaKey) && e.key === "z" && !e.shiftKey) {
352
+ e.preventDefault();
353
+ this.handleUndo();
354
+ }
355
+ if ((e.ctrlKey || e.metaKey) && (e.key === "y" || e.key === "z" && e.shiftKey)) {
356
+ e.preventDefault();
357
+ this.handleRedo();
358
+ }
359
+ };
360
+ onKeyUp = (e) => {
361
+ if (e.key === " ") {
362
+ this.spaceHeld = false;
363
+ }
364
+ };
365
+ startPinch() {
366
+ this.isPanning = true;
367
+ const [a, b] = this.getPinchPoints();
368
+ this.lastPinchDistance = this.distance(a, b);
369
+ this.lastPinchCenter = this.midpoint(a, b);
370
+ this.lastPointer = { ...this.lastPinchCenter };
371
+ }
372
+ handlePinchMove() {
373
+ const [a, b] = this.getPinchPoints();
374
+ const dist = this.distance(a, b);
375
+ const center = this.midpoint(a, b);
376
+ if (this.lastPinchDistance > 0) {
377
+ const scale = dist / this.lastPinchDistance;
378
+ const newZoom = this.camera.zoom * scale;
379
+ this.camera.zoomAt(newZoom, center);
380
+ }
381
+ const dx = center.x - this.lastPointer.x;
382
+ const dy = center.y - this.lastPointer.y;
383
+ this.camera.pan(dx, dy);
384
+ this.lastPinchDistance = dist;
385
+ this.lastPinchCenter = center;
386
+ this.lastPointer = { ...center };
387
+ }
388
+ getPinchPoints() {
389
+ const pts = [...this.activePointers.values()];
390
+ return [pts[0] ?? { x: 0, y: 0 }, pts[1] ?? { x: 0, y: 0 }];
391
+ }
392
+ distance(a, b) {
393
+ const dx = a.x - b.x;
394
+ const dy = a.y - b.y;
395
+ return Math.sqrt(dx * dx + dy * dy);
396
+ }
397
+ midpoint(a, b) {
398
+ return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
399
+ }
400
+ toPointerState(e) {
401
+ return { x: e.clientX, y: e.clientY, pressure: e.pressure };
402
+ }
403
+ dispatchToolDown(e) {
404
+ if (!this.toolManager || !this.toolContext) return;
405
+ this.historyRecorder?.begin();
406
+ this.isToolActive = true;
407
+ this.toolManager.handlePointerDown(this.toPointerState(e), this.toolContext);
408
+ }
409
+ dispatchToolMove(e) {
410
+ if (!this.toolManager || !this.toolContext) return;
411
+ this.toolManager.handlePointerMove(this.toPointerState(e), this.toolContext);
412
+ }
413
+ dispatchToolHover(e) {
414
+ if (!this.toolManager?.activeTool || !this.toolContext) return;
415
+ const tool = this.toolManager.activeTool;
416
+ if (tool.onHover) {
417
+ tool.onHover(this.toPointerState(e), this.toolContext);
418
+ }
419
+ }
420
+ dispatchToolUp(e) {
421
+ if (!this.toolManager || !this.toolContext) return;
422
+ this.toolManager.handlePointerUp(this.toPointerState(e), this.toolContext);
423
+ this.historyRecorder?.commit();
424
+ }
425
+ deleteSelected() {
426
+ if (!this.toolManager || !this.toolContext) return;
427
+ const tool = this.toolManager.activeTool;
428
+ if (tool?.name !== "select") return;
429
+ const selectTool = tool;
430
+ const ids = selectTool.selectedIds;
431
+ if (ids.length === 0) return;
432
+ this.historyRecorder?.begin();
433
+ for (const id of ids) {
434
+ this.toolContext.store.remove(id);
435
+ }
436
+ this.historyRecorder?.commit();
437
+ this.toolContext.requestRender();
438
+ }
439
+ handleUndo() {
440
+ if (!this.historyStack || !this.toolContext) return;
441
+ this.historyRecorder?.pause();
442
+ this.historyStack.undo(this.toolContext.store);
443
+ this.historyRecorder?.resume();
444
+ this.toolContext.requestRender();
445
+ }
446
+ handleRedo() {
447
+ if (!this.historyStack || !this.toolContext) return;
448
+ this.historyRecorder?.pause();
449
+ this.historyStack.redo(this.toolContext.store);
450
+ this.historyRecorder?.resume();
451
+ this.toolContext.requestRender();
452
+ }
453
+ cancelToolIfActive(e) {
454
+ if (this.isToolActive) {
455
+ this.dispatchToolUp(e);
456
+ this.isToolActive = false;
457
+ }
458
+ }
459
+ };
460
+
461
+ // src/elements/element-store.ts
462
+ var ElementStore = class {
463
+ elements = /* @__PURE__ */ new Map();
464
+ bus = new EventBus();
465
+ get count() {
466
+ return this.elements.size;
467
+ }
468
+ getAll() {
469
+ return [...this.elements.values()].sort((a, b) => a.zIndex - b.zIndex);
470
+ }
471
+ getById(id) {
472
+ return this.elements.get(id);
473
+ }
474
+ getElementsByType(type) {
475
+ return this.getAll().filter(
476
+ (el) => el.type === type
477
+ );
478
+ }
479
+ add(element) {
480
+ this.elements.set(element.id, element);
481
+ this.bus.emit("add", element);
482
+ }
483
+ update(id, partial) {
484
+ const existing = this.elements.get(id);
485
+ if (!existing) return;
486
+ const updated = { ...existing, ...partial, id: existing.id, type: existing.type };
487
+ this.elements.set(id, updated);
488
+ this.bus.emit("update", { previous: existing, current: updated });
489
+ }
490
+ remove(id) {
491
+ const element = this.elements.get(id);
492
+ if (!element) return;
493
+ this.elements.delete(id);
494
+ this.bus.emit("remove", element);
495
+ }
496
+ clear() {
497
+ this.elements.clear();
498
+ this.bus.emit("clear", null);
499
+ }
500
+ snapshot() {
501
+ return this.getAll().map((el) => ({ ...el }));
502
+ }
503
+ loadSnapshot(elements) {
504
+ this.elements.clear();
505
+ for (const el of elements) {
506
+ this.elements.set(el.id, el);
507
+ }
508
+ }
509
+ on(event, listener) {
510
+ return this.bus.on(event, listener);
511
+ }
512
+ };
513
+
514
+ // src/elements/arrow-geometry.ts
515
+ function getArrowControlPoint(from, to, bend) {
516
+ const midX = (from.x + to.x) / 2;
517
+ const midY = (from.y + to.y) / 2;
518
+ if (bend === 0) return { x: midX, y: midY };
519
+ const dx = to.x - from.x;
520
+ const dy = to.y - from.y;
521
+ const len = Math.sqrt(dx * dx + dy * dy);
522
+ if (len === 0) return { x: midX, y: midY };
523
+ const perpX = -dy / len;
524
+ const perpY = dx / len;
525
+ return {
526
+ x: midX + perpX * bend,
527
+ y: midY + perpY * bend
528
+ };
529
+ }
530
+ function getArrowMidpoint(from, to, bend) {
531
+ const cp = getArrowControlPoint(from, to, bend);
532
+ return {
533
+ x: 0.25 * from.x + 0.5 * cp.x + 0.25 * to.x,
534
+ y: 0.25 * from.y + 0.5 * cp.y + 0.25 * to.y
535
+ };
536
+ }
537
+ function getBendFromPoint(from, to, dragPoint) {
538
+ const midX = (from.x + to.x) / 2;
539
+ const midY = (from.y + to.y) / 2;
540
+ const dx = to.x - from.x;
541
+ const dy = to.y - from.y;
542
+ const len = Math.sqrt(dx * dx + dy * dy);
543
+ if (len === 0) return 0;
544
+ const perpX = -dy / len;
545
+ const perpY = dx / len;
546
+ return (dragPoint.x - midX) * perpX + (dragPoint.y - midY) * perpY;
547
+ }
548
+ function getArrowTangentAngle(from, to, bend, t) {
549
+ const cp = getArrowControlPoint(from, to, bend);
550
+ const tangentX = 2 * (1 - t) * (cp.x - from.x) + 2 * t * (to.x - cp.x);
551
+ const tangentY = 2 * (1 - t) * (cp.y - from.y) + 2 * t * (to.y - cp.y);
552
+ return Math.atan2(tangentY, tangentX);
553
+ }
554
+ function isNearBezier(point, from, to, bend, threshold) {
555
+ if (bend === 0) return isNearLine(point, from, to, threshold);
556
+ const cp = getArrowControlPoint(from, to, bend);
557
+ const segments = 20;
558
+ for (let i = 0; i < segments; i++) {
559
+ const t0 = i / segments;
560
+ const t1 = (i + 1) / segments;
561
+ const a = bezierPoint(from, cp, to, t0);
562
+ const b = bezierPoint(from, cp, to, t1);
563
+ if (isNearLine(point, a, b, threshold)) return true;
564
+ }
565
+ return false;
566
+ }
567
+ function getArrowBounds(from, to, bend) {
568
+ if (bend === 0) {
569
+ const minX2 = Math.min(from.x, to.x);
570
+ const minY2 = Math.min(from.y, to.y);
571
+ return {
572
+ x: minX2,
573
+ y: minY2,
574
+ w: Math.abs(to.x - from.x),
575
+ h: Math.abs(to.y - from.y)
576
+ };
577
+ }
578
+ const cp = getArrowControlPoint(from, to, bend);
579
+ const steps = 20;
580
+ let minX = Math.min(from.x, to.x);
581
+ let minY = Math.min(from.y, to.y);
582
+ let maxX = Math.max(from.x, to.x);
583
+ let maxY = Math.max(from.y, to.y);
584
+ for (let i = 1; i < steps; i++) {
585
+ const t = i / steps;
586
+ const p = bezierPoint(from, cp, to, t);
587
+ if (p.x < minX) minX = p.x;
588
+ if (p.y < minY) minY = p.y;
589
+ if (p.x > maxX) maxX = p.x;
590
+ if (p.y > maxY) maxY = p.y;
591
+ }
592
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
593
+ }
594
+ function bezierPoint(from, cp, to, t) {
595
+ const mt = 1 - t;
596
+ return {
597
+ x: mt * mt * from.x + 2 * mt * t * cp.x + t * t * to.x,
598
+ y: mt * mt * from.y + 2 * mt * t * cp.y + t * t * to.y
599
+ };
600
+ }
601
+ function isNearLine(point, a, b, threshold) {
602
+ const dx = b.x - a.x;
603
+ const dy = b.y - a.y;
604
+ const lenSq = dx * dx + dy * dy;
605
+ if (lenSq === 0) {
606
+ return Math.hypot(point.x - a.x, point.y - a.y) <= threshold;
607
+ }
608
+ const t = Math.max(0, Math.min(1, ((point.x - a.x) * dx + (point.y - a.y) * dy) / lenSq));
609
+ const projX = a.x + t * dx;
610
+ const projY = a.y + t * dy;
611
+ return Math.hypot(point.x - projX, point.y - projY) <= threshold;
612
+ }
613
+
614
+ // src/elements/element-renderer.ts
615
+ var DOM_ELEMENT_TYPES = /* @__PURE__ */ new Set(["note", "image", "html"]);
616
+ var ARROWHEAD_LENGTH = 12;
617
+ var ARROWHEAD_ANGLE = Math.PI / 6;
618
+ var ElementRenderer = class {
619
+ isDomElement(element) {
620
+ return DOM_ELEMENT_TYPES.has(element.type);
621
+ }
622
+ renderCanvasElement(ctx, element) {
623
+ switch (element.type) {
624
+ case "stroke":
625
+ this.renderStroke(ctx, element);
626
+ break;
627
+ case "arrow":
628
+ this.renderArrow(ctx, element);
629
+ break;
630
+ }
631
+ }
632
+ renderStroke(ctx, stroke) {
633
+ if (stroke.points.length < 2) return;
634
+ ctx.save();
635
+ ctx.translate(stroke.position.x, stroke.position.y);
636
+ ctx.strokeStyle = stroke.color;
637
+ ctx.lineWidth = stroke.width;
638
+ ctx.lineCap = "round";
639
+ ctx.lineJoin = "round";
640
+ ctx.globalAlpha = stroke.opacity;
641
+ ctx.beginPath();
642
+ const first = stroke.points[0];
643
+ if (first) {
644
+ ctx.moveTo(first.x, first.y);
645
+ }
646
+ for (let i = 1; i < stroke.points.length; i++) {
647
+ const pt = stroke.points[i];
648
+ if (pt) {
649
+ ctx.lineTo(pt.x, pt.y);
650
+ }
651
+ }
652
+ ctx.stroke();
653
+ ctx.restore();
654
+ }
655
+ renderArrow(ctx, arrow) {
656
+ ctx.save();
657
+ ctx.strokeStyle = arrow.color;
658
+ ctx.lineWidth = arrow.width;
659
+ ctx.lineCap = "round";
660
+ ctx.beginPath();
661
+ ctx.moveTo(arrow.from.x, arrow.from.y);
662
+ if (arrow.bend !== 0) {
663
+ const cp = getArrowControlPoint(arrow.from, arrow.to, arrow.bend);
664
+ ctx.quadraticCurveTo(cp.x, cp.y, arrow.to.x, arrow.to.y);
665
+ } else {
666
+ ctx.lineTo(arrow.to.x, arrow.to.y);
667
+ }
668
+ ctx.stroke();
669
+ this.renderArrowhead(ctx, arrow);
670
+ ctx.restore();
671
+ }
672
+ renderArrowhead(ctx, arrow) {
673
+ const angle = getArrowTangentAngle(arrow.from, arrow.to, arrow.bend, 1);
674
+ ctx.beginPath();
675
+ ctx.moveTo(arrow.to.x, arrow.to.y);
676
+ ctx.lineTo(
677
+ arrow.to.x - ARROWHEAD_LENGTH * Math.cos(angle - ARROWHEAD_ANGLE),
678
+ arrow.to.y - ARROWHEAD_LENGTH * Math.sin(angle - ARROWHEAD_ANGLE)
679
+ );
680
+ ctx.lineTo(
681
+ arrow.to.x - ARROWHEAD_LENGTH * Math.cos(angle + ARROWHEAD_ANGLE),
682
+ arrow.to.y - ARROWHEAD_LENGTH * Math.sin(angle + ARROWHEAD_ANGLE)
683
+ );
684
+ ctx.closePath();
685
+ ctx.fillStyle = arrow.color;
686
+ ctx.fill();
687
+ }
688
+ };
689
+
690
+ // src/elements/note-editor.ts
691
+ var NoteEditor = class {
692
+ editingId = null;
693
+ editingNode = null;
694
+ blurHandler = null;
695
+ keyHandler = null;
696
+ pointerHandler = null;
697
+ pendingEditId = null;
698
+ get isEditing() {
699
+ return this.editingId !== null;
700
+ }
701
+ get editingElementId() {
702
+ return this.editingId;
703
+ }
704
+ startEditing(node, elementId, store) {
705
+ if (this.editingId === elementId) return;
706
+ if (this.editingId) {
707
+ this.stopEditing(store);
708
+ }
709
+ this.pendingEditId = elementId;
710
+ requestAnimationFrame(() => {
711
+ if (this.pendingEditId !== elementId) return;
712
+ this.pendingEditId = null;
713
+ this.activateEditing(node, elementId, store);
714
+ });
715
+ }
716
+ stopEditing(store) {
717
+ this.pendingEditId = null;
718
+ if (!this.editingId || !this.editingNode) return;
719
+ const text = this.editingNode.textContent ?? "";
720
+ store.update(this.editingId, { text });
721
+ this.editingNode.contentEditable = "false";
722
+ Object.assign(this.editingNode.style, {
723
+ userSelect: "none",
724
+ cursor: "default"
725
+ });
726
+ if (this.blurHandler) {
727
+ this.editingNode.removeEventListener("blur", this.blurHandler);
728
+ }
729
+ if (this.keyHandler) {
730
+ this.editingNode.removeEventListener("keydown", this.keyHandler);
731
+ }
732
+ if (this.pointerHandler) {
733
+ this.editingNode.removeEventListener("pointerdown", this.pointerHandler);
734
+ }
735
+ this.editingId = null;
736
+ this.editingNode = null;
737
+ this.blurHandler = null;
738
+ this.keyHandler = null;
739
+ this.pointerHandler = null;
740
+ }
741
+ destroy(store) {
742
+ this.pendingEditId = null;
743
+ if (this.isEditing) {
744
+ this.stopEditing(store);
745
+ }
746
+ }
747
+ activateEditing(node, elementId, store) {
748
+ this.editingId = elementId;
749
+ this.editingNode = node;
750
+ node.contentEditable = "true";
751
+ Object.assign(node.style, {
752
+ userSelect: "text",
753
+ cursor: "text",
754
+ outline: "none"
755
+ });
756
+ node.focus();
757
+ const selection = window.getSelection?.();
758
+ if (selection) {
759
+ const range = document.createRange();
760
+ range.selectNodeContents(node);
761
+ range.collapse(false);
762
+ selection.removeAllRanges();
763
+ selection.addRange(range);
764
+ }
765
+ this.blurHandler = () => this.stopEditing(store);
766
+ this.keyHandler = (e) => {
767
+ if (e.key === "Escape") {
768
+ node.blur();
769
+ }
770
+ e.stopPropagation();
771
+ };
772
+ this.pointerHandler = (e) => {
773
+ e.stopPropagation();
774
+ };
775
+ node.addEventListener("blur", this.blurHandler);
776
+ node.addEventListener("keydown", this.keyHandler);
777
+ node.addEventListener("pointerdown", this.pointerHandler);
778
+ }
779
+ };
780
+
781
+ // src/tools/tool-manager.ts
782
+ var ToolManager = class {
783
+ tools = /* @__PURE__ */ new Map();
784
+ current = null;
785
+ changeListeners = /* @__PURE__ */ new Set();
786
+ get activeTool() {
787
+ return this.current;
788
+ }
789
+ get toolNames() {
790
+ return [...this.tools.keys()];
791
+ }
792
+ register(tool) {
793
+ this.tools.set(tool.name, tool);
794
+ }
795
+ setTool(name, ctx) {
796
+ const tool = this.tools.get(name);
797
+ if (!tool) return;
798
+ this.current?.onDeactivate?.(ctx);
799
+ this.current = tool;
800
+ this.current.onActivate?.(ctx);
801
+ this.changeListeners.forEach((fn) => fn(name));
802
+ }
803
+ handlePointerDown(state, ctx) {
804
+ this.current?.onPointerDown(state, ctx);
805
+ }
806
+ handlePointerMove(state, ctx) {
807
+ this.current?.onPointerMove(state, ctx);
808
+ }
809
+ handlePointerUp(state, ctx) {
810
+ this.current?.onPointerUp(state, ctx);
811
+ }
812
+ onChange(listener) {
813
+ this.changeListeners.add(listener);
814
+ return () => this.changeListeners.delete(listener);
815
+ }
816
+ };
817
+
818
+ // src/history/history-stack.ts
819
+ var DEFAULT_MAX_SIZE = 100;
820
+ var HistoryStack = class {
821
+ undoStack = [];
822
+ redoStack = [];
823
+ maxSize;
824
+ changeListeners = /* @__PURE__ */ new Set();
825
+ constructor(options = {}) {
826
+ this.maxSize = options.maxSize ?? DEFAULT_MAX_SIZE;
827
+ }
828
+ get canUndo() {
829
+ return this.undoStack.length > 0;
830
+ }
831
+ get canRedo() {
832
+ return this.redoStack.length > 0;
833
+ }
834
+ get undoCount() {
835
+ return this.undoStack.length;
836
+ }
837
+ get redoCount() {
838
+ return this.redoStack.length;
839
+ }
840
+ push(command) {
841
+ this.undoStack.push(command);
842
+ this.redoStack = [];
843
+ if (this.undoStack.length > this.maxSize) {
844
+ this.undoStack.shift();
845
+ }
846
+ this.notifyChange();
847
+ }
848
+ undo(store) {
849
+ const command = this.undoStack.pop();
850
+ if (!command) return false;
851
+ command.undo(store);
852
+ this.redoStack.push(command);
853
+ this.notifyChange();
854
+ return true;
855
+ }
856
+ redo(store) {
857
+ const command = this.redoStack.pop();
858
+ if (!command) return false;
859
+ command.execute(store);
860
+ this.undoStack.push(command);
861
+ this.notifyChange();
862
+ return true;
863
+ }
864
+ clear() {
865
+ this.undoStack = [];
866
+ this.redoStack = [];
867
+ this.notifyChange();
868
+ }
869
+ onChange(listener) {
870
+ this.changeListeners.add(listener);
871
+ return () => this.changeListeners.delete(listener);
872
+ }
873
+ notifyChange() {
874
+ this.changeListeners.forEach((fn) => fn());
875
+ }
876
+ };
877
+
878
+ // src/history/commands.ts
879
+ var AddElementCommand = class {
880
+ element;
881
+ constructor(element) {
882
+ this.element = { ...element };
883
+ }
884
+ execute(store) {
885
+ store.add(this.element);
886
+ }
887
+ undo(store) {
888
+ store.remove(this.element.id);
889
+ }
890
+ };
891
+ var RemoveElementCommand = class {
892
+ element;
893
+ constructor(element) {
894
+ this.element = { ...element };
895
+ }
896
+ execute(store) {
897
+ store.remove(this.element.id);
898
+ }
899
+ undo(store) {
900
+ store.add(this.element);
901
+ }
902
+ };
903
+ var UpdateElementCommand = class {
904
+ constructor(id, previous, current) {
905
+ this.id = id;
906
+ this.previous = previous;
907
+ this.current = current;
908
+ }
909
+ execute(store) {
910
+ store.update(this.id, { ...this.current });
911
+ }
912
+ undo(store) {
913
+ store.update(this.id, { ...this.previous });
914
+ }
915
+ };
916
+ var BatchCommand = class {
917
+ commands;
918
+ constructor(commands) {
919
+ this.commands = [...commands];
920
+ }
921
+ execute(store) {
922
+ for (const cmd of this.commands) {
923
+ cmd.execute(store);
924
+ }
925
+ }
926
+ undo(store) {
927
+ for (let i = this.commands.length - 1; i >= 0; i--) {
928
+ this.commands[i]?.undo(store);
929
+ }
930
+ }
931
+ };
932
+
933
+ // src/history/history-recorder.ts
934
+ var HistoryRecorder = class {
935
+ constructor(store, stack) {
936
+ this.store = store;
937
+ this.stack = stack;
938
+ this.unsubscribers = [
939
+ store.on("add", (el) => this.onAdd(el)),
940
+ store.on("remove", (el) => this.onRemove(el)),
941
+ store.on("update", ({ previous, current }) => this.onUpdate(previous, current))
942
+ ];
943
+ }
944
+ recording = true;
945
+ transaction = null;
946
+ updateSnapshots = /* @__PURE__ */ new Map();
947
+ unsubscribers;
948
+ pause() {
949
+ this.recording = false;
950
+ }
951
+ resume() {
952
+ this.recording = true;
953
+ }
954
+ begin() {
955
+ this.transaction = [];
956
+ this.updateSnapshots.clear();
957
+ }
958
+ commit() {
959
+ if (!this.transaction) return;
960
+ const finalCommands = this.flushUpdateSnapshots();
961
+ const all = [...this.transaction, ...finalCommands];
962
+ this.transaction = null;
963
+ this.updateSnapshots.clear();
964
+ if (all.length === 0) return;
965
+ const first = all[0];
966
+ this.stack.push(all.length === 1 && first ? first : new BatchCommand(all));
967
+ }
968
+ rollback() {
969
+ this.transaction = null;
970
+ this.updateSnapshots.clear();
971
+ }
972
+ destroy() {
973
+ this.unsubscribers.forEach((fn) => fn());
974
+ }
975
+ record(command) {
976
+ if (this.transaction) {
977
+ this.transaction.push(command);
978
+ } else {
979
+ this.stack.push(command);
980
+ }
981
+ }
982
+ onAdd(element) {
983
+ if (!this.recording) return;
984
+ this.record(new AddElementCommand(element));
985
+ }
986
+ onRemove(element) {
987
+ if (!this.recording) return;
988
+ if (this.transaction && this.updateSnapshots.has(element.id)) {
989
+ this.updateSnapshots.delete(element.id);
990
+ }
991
+ this.record(new RemoveElementCommand(element));
992
+ }
993
+ onUpdate(previous, current) {
994
+ if (!this.recording) return;
995
+ if (this.transaction) {
996
+ if (!this.updateSnapshots.has(current.id)) {
997
+ this.updateSnapshots.set(current.id, { ...previous });
998
+ }
999
+ } else {
1000
+ this.stack.push(new UpdateElementCommand(current.id, previous, current));
1001
+ }
1002
+ }
1003
+ flushUpdateSnapshots() {
1004
+ const commands = [];
1005
+ for (const [id, previous] of this.updateSnapshots) {
1006
+ const current = this.store.getById(id);
1007
+ if (current) {
1008
+ commands.push(new UpdateElementCommand(id, previous, current));
1009
+ }
1010
+ }
1011
+ return commands;
1012
+ }
1013
+ };
1014
+
1015
+ // src/elements/create-id.ts
1016
+ var counter = 0;
1017
+ function createId(prefix) {
1018
+ return `${prefix}_${Date.now().toString(36)}_${(counter++).toString(36)}`;
1019
+ }
1020
+
1021
+ // src/elements/element-factory.ts
1022
+ function createStroke(input) {
1023
+ return {
1024
+ id: createId("stroke"),
1025
+ type: "stroke",
1026
+ position: input.position ?? { x: 0, y: 0 },
1027
+ zIndex: input.zIndex ?? 0,
1028
+ locked: input.locked ?? false,
1029
+ points: input.points,
1030
+ color: input.color ?? "#000000",
1031
+ width: input.width ?? 2,
1032
+ opacity: input.opacity ?? 1
1033
+ };
1034
+ }
1035
+ function createNote(input) {
1036
+ return {
1037
+ id: createId("note"),
1038
+ type: "note",
1039
+ position: input.position,
1040
+ zIndex: input.zIndex ?? 0,
1041
+ locked: input.locked ?? false,
1042
+ size: input.size ?? { w: 200, h: 100 },
1043
+ text: input.text ?? "",
1044
+ backgroundColor: input.backgroundColor ?? "#ffeb3b"
1045
+ };
1046
+ }
1047
+ function createArrow(input) {
1048
+ return {
1049
+ id: createId("arrow"),
1050
+ type: "arrow",
1051
+ position: input.position ?? { x: 0, y: 0 },
1052
+ zIndex: input.zIndex ?? 0,
1053
+ locked: input.locked ?? false,
1054
+ from: input.from,
1055
+ to: input.to,
1056
+ bend: input.bend ?? 0,
1057
+ color: input.color ?? "#000000",
1058
+ width: input.width ?? 2
1059
+ };
1060
+ }
1061
+ function createImage(input) {
1062
+ return {
1063
+ id: createId("image"),
1064
+ type: "image",
1065
+ position: input.position,
1066
+ zIndex: input.zIndex ?? 0,
1067
+ locked: input.locked ?? false,
1068
+ size: input.size,
1069
+ src: input.src
1070
+ };
1071
+ }
1072
+ function createHtmlElement(input) {
1073
+ return {
1074
+ id: createId("html"),
1075
+ type: "html",
1076
+ position: input.position,
1077
+ zIndex: input.zIndex ?? 0,
1078
+ locked: input.locked ?? false,
1079
+ size: input.size
1080
+ };
1081
+ }
1082
+
1083
+ // src/canvas/viewport.ts
1084
+ var Viewport = class {
1085
+ constructor(container, options = {}) {
1086
+ this.container = container;
1087
+ this.camera = new Camera(options.camera);
1088
+ this.background = new Background(options.background);
1089
+ this.store = new ElementStore();
1090
+ this.toolManager = new ToolManager();
1091
+ this.renderer = new ElementRenderer();
1092
+ this.noteEditor = new NoteEditor();
1093
+ this.history = new HistoryStack();
1094
+ this.historyRecorder = new HistoryRecorder(this.store, this.history);
1095
+ this.wrapper = this.createWrapper();
1096
+ this.canvasEl = this.createCanvas();
1097
+ this.domLayer = this.createDomLayer();
1098
+ this.wrapper.appendChild(this.canvasEl);
1099
+ this.wrapper.appendChild(this.domLayer);
1100
+ this.container.appendChild(this.wrapper);
1101
+ this.toolContext = {
1102
+ camera: this.camera,
1103
+ store: this.store,
1104
+ requestRender: () => this.requestRender(),
1105
+ switchTool: (name) => this.toolManager.setTool(name, this.toolContext),
1106
+ editElement: (id) => this.startEditingNote(id),
1107
+ setCursor: (cursor) => {
1108
+ this.wrapper.style.cursor = cursor;
1109
+ }
1110
+ };
1111
+ this.inputHandler = new InputHandler(this.wrapper, this.camera, {
1112
+ toolManager: this.toolManager,
1113
+ toolContext: this.toolContext,
1114
+ historyRecorder: this.historyRecorder,
1115
+ historyStack: this.history
1116
+ });
1117
+ this.unsubCamera = this.camera.onChange(() => {
1118
+ this.applyCameraTransform();
1119
+ this.requestRender();
1120
+ });
1121
+ this.unsubStore = [
1122
+ this.store.on("add", () => this.requestRender()),
1123
+ this.store.on("remove", (el) => this.removeDomNode(el.id)),
1124
+ this.store.on("update", () => this.requestRender()),
1125
+ this.store.on("clear", () => this.clearDomNodes())
1126
+ ];
1127
+ this.wrapper.addEventListener("dblclick", this.onDblClick);
1128
+ this.wrapper.addEventListener("dragover", this.onDragOver);
1129
+ this.wrapper.addEventListener("drop", this.onDrop);
1130
+ this.observeResize();
1131
+ this.syncCanvasSize();
1132
+ this.startRenderLoop();
1133
+ }
1134
+ camera;
1135
+ store;
1136
+ toolManager;
1137
+ history;
1138
+ domLayer;
1139
+ canvasEl;
1140
+ wrapper;
1141
+ unsubCamera;
1142
+ unsubStore;
1143
+ inputHandler;
1144
+ background;
1145
+ renderer;
1146
+ noteEditor;
1147
+ historyRecorder;
1148
+ toolContext;
1149
+ resizeObserver = null;
1150
+ animFrameId = 0;
1151
+ needsRender = true;
1152
+ domNodes = /* @__PURE__ */ new Map();
1153
+ htmlContent = /* @__PURE__ */ new Map();
1154
+ get ctx() {
1155
+ return this.canvasEl.getContext("2d");
1156
+ }
1157
+ requestRender() {
1158
+ this.needsRender = true;
1159
+ }
1160
+ exportState() {
1161
+ return exportState(this.store.snapshot(), this.camera);
1162
+ }
1163
+ exportJSON() {
1164
+ return JSON.stringify(this.exportState());
1165
+ }
1166
+ loadState(state) {
1167
+ this.historyRecorder.pause();
1168
+ this.noteEditor.destroy(this.store);
1169
+ this.clearDomNodes();
1170
+ this.store.loadSnapshot(state.elements);
1171
+ this.history.clear();
1172
+ this.historyRecorder.resume();
1173
+ this.camera.moveTo(state.camera.position.x, state.camera.position.y);
1174
+ this.camera.setZoom(state.camera.zoom);
1175
+ }
1176
+ loadJSON(json) {
1177
+ this.loadState(parseState(json));
1178
+ }
1179
+ undo() {
1180
+ this.historyRecorder.pause();
1181
+ const result = this.history.undo(this.store);
1182
+ this.historyRecorder.resume();
1183
+ if (result) this.requestRender();
1184
+ return result;
1185
+ }
1186
+ redo() {
1187
+ this.historyRecorder.pause();
1188
+ const result = this.history.redo(this.store);
1189
+ this.historyRecorder.resume();
1190
+ if (result) this.requestRender();
1191
+ return result;
1192
+ }
1193
+ addImage(src, position, size = { w: 300, h: 200 }) {
1194
+ const image = createImage({ position, size, src });
1195
+ this.historyRecorder.begin();
1196
+ this.store.add(image);
1197
+ this.historyRecorder.commit();
1198
+ this.requestRender();
1199
+ }
1200
+ addHtmlElement(dom, position, size = { w: 200, h: 150 }) {
1201
+ const el = createHtmlElement({ position, size });
1202
+ this.htmlContent.set(el.id, dom);
1203
+ this.historyRecorder.begin();
1204
+ this.store.add(el);
1205
+ this.historyRecorder.commit();
1206
+ this.requestRender();
1207
+ return el.id;
1208
+ }
1209
+ destroy() {
1210
+ cancelAnimationFrame(this.animFrameId);
1211
+ this.noteEditor.destroy(this.store);
1212
+ this.historyRecorder.destroy();
1213
+ this.wrapper.removeEventListener("dblclick", this.onDblClick);
1214
+ this.wrapper.removeEventListener("dragover", this.onDragOver);
1215
+ this.wrapper.removeEventListener("drop", this.onDrop);
1216
+ this.inputHandler.destroy();
1217
+ this.unsubCamera();
1218
+ this.unsubStore.forEach((fn) => fn());
1219
+ this.resizeObserver?.disconnect();
1220
+ this.resizeObserver = null;
1221
+ this.wrapper.remove();
1222
+ }
1223
+ startRenderLoop() {
1224
+ const loop = () => {
1225
+ if (this.needsRender) {
1226
+ this.render();
1227
+ this.needsRender = false;
1228
+ }
1229
+ this.animFrameId = requestAnimationFrame(loop);
1230
+ };
1231
+ this.animFrameId = requestAnimationFrame(loop);
1232
+ }
1233
+ render() {
1234
+ const ctx = this.ctx;
1235
+ if (!ctx) return;
1236
+ const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
1237
+ ctx.save();
1238
+ ctx.scale(dpr, dpr);
1239
+ this.background.render(ctx, this.camera);
1240
+ ctx.save();
1241
+ ctx.translate(this.camera.position.x, this.camera.position.y);
1242
+ ctx.scale(this.camera.zoom, this.camera.zoom);
1243
+ for (const element of this.store.getAll()) {
1244
+ if (this.renderer.isDomElement(element)) {
1245
+ this.syncDomNode(element);
1246
+ } else {
1247
+ this.renderer.renderCanvasElement(ctx, element);
1248
+ }
1249
+ }
1250
+ const activeTool = this.toolManager.activeTool;
1251
+ if (activeTool?.renderOverlay) {
1252
+ activeTool.renderOverlay(ctx);
1253
+ }
1254
+ ctx.restore();
1255
+ ctx.restore();
1256
+ }
1257
+ startEditingNote(id) {
1258
+ const element = this.store.getById(id);
1259
+ if (!element || element.type !== "note") return;
1260
+ this.render();
1261
+ const node = this.domNodes.get(id);
1262
+ if (node) {
1263
+ this.noteEditor.startEditing(node, id, this.store);
1264
+ }
1265
+ }
1266
+ onDblClick = (e) => {
1267
+ const el = document.elementFromPoint(e.clientX, e.clientY);
1268
+ if (!el) return;
1269
+ const nodeEl = el.closest("[data-element-id]");
1270
+ if (!nodeEl) return;
1271
+ const elementId = nodeEl.dataset["elementId"];
1272
+ if (elementId) this.startEditingNote(elementId);
1273
+ };
1274
+ onDragOver = (e) => {
1275
+ e.preventDefault();
1276
+ };
1277
+ onDrop = (e) => {
1278
+ e.preventDefault();
1279
+ const files = e.dataTransfer?.files;
1280
+ if (!files) return;
1281
+ const rect = this.wrapper.getBoundingClientRect();
1282
+ for (const file of files) {
1283
+ if (!file.type.startsWith("image/")) continue;
1284
+ const reader = new FileReader();
1285
+ reader.onload = () => {
1286
+ const src = reader.result;
1287
+ if (typeof src !== "string") return;
1288
+ const screenPos = { x: e.clientX - rect.left, y: e.clientY - rect.top };
1289
+ const worldPos = this.camera.screenToWorld(screenPos);
1290
+ this.addImage(src, worldPos);
1291
+ };
1292
+ reader.readAsDataURL(file);
1293
+ }
1294
+ };
1295
+ syncDomNode(element) {
1296
+ let node = this.domNodes.get(element.id);
1297
+ if (!node) {
1298
+ node = document.createElement("div");
1299
+ node.dataset["elementId"] = element.id;
1300
+ Object.assign(node.style, {
1301
+ position: "absolute",
1302
+ pointerEvents: "auto"
1303
+ });
1304
+ this.domLayer.appendChild(node);
1305
+ this.domNodes.set(element.id, node);
1306
+ }
1307
+ const size = "size" in element ? element.size : null;
1308
+ Object.assign(node.style, {
1309
+ left: `${element.position.x}px`,
1310
+ top: `${element.position.y}px`,
1311
+ width: size ? `${size.w}px` : "auto",
1312
+ height: size ? `${size.h}px` : "auto"
1313
+ });
1314
+ this.renderDomContent(node, element);
1315
+ }
1316
+ renderDomContent(node, element) {
1317
+ if (element.type === "note") {
1318
+ if (!node.dataset["initialized"]) {
1319
+ node.dataset["initialized"] = "true";
1320
+ Object.assign(node.style, {
1321
+ backgroundColor: element.backgroundColor,
1322
+ padding: "8px",
1323
+ borderRadius: "4px",
1324
+ boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
1325
+ fontSize: "14px",
1326
+ overflow: "hidden",
1327
+ cursor: "default",
1328
+ userSelect: "none",
1329
+ wordWrap: "break-word"
1330
+ });
1331
+ node.textContent = element.text || "";
1332
+ node.addEventListener("dblclick", (e) => {
1333
+ e.stopPropagation();
1334
+ const id = node.dataset["elementId"];
1335
+ if (id) this.startEditingNote(id);
1336
+ });
1337
+ }
1338
+ if (!this.noteEditor.isEditing || this.noteEditor.editingElementId !== element.id) {
1339
+ if (node.textContent !== element.text) {
1340
+ node.textContent = element.text || "";
1341
+ }
1342
+ node.style.backgroundColor = element.backgroundColor;
1343
+ }
1344
+ }
1345
+ if (element.type === "image") {
1346
+ if (!node.dataset["initialized"]) {
1347
+ node.dataset["initialized"] = "true";
1348
+ const img = document.createElement("img");
1349
+ img.src = element.src;
1350
+ Object.assign(img.style, {
1351
+ width: "100%",
1352
+ height: "100%",
1353
+ objectFit: "contain",
1354
+ pointerEvents: "none"
1355
+ });
1356
+ img.draggable = false;
1357
+ node.appendChild(img);
1358
+ } else {
1359
+ const img = node.querySelector("img");
1360
+ if (img && img.src !== element.src) {
1361
+ img.src = element.src;
1362
+ }
1363
+ }
1364
+ }
1365
+ if (element.type === "html" && !node.dataset["initialized"]) {
1366
+ const content = this.htmlContent.get(element.id);
1367
+ if (content) {
1368
+ node.dataset["initialized"] = "true";
1369
+ Object.assign(node.style, {
1370
+ overflow: "hidden"
1371
+ });
1372
+ node.appendChild(content);
1373
+ }
1374
+ }
1375
+ }
1376
+ removeDomNode(id) {
1377
+ this.htmlContent.delete(id);
1378
+ const node = this.domNodes.get(id);
1379
+ if (node) {
1380
+ node.remove();
1381
+ this.domNodes.delete(id);
1382
+ }
1383
+ this.requestRender();
1384
+ }
1385
+ clearDomNodes() {
1386
+ this.domNodes.forEach((node) => node.remove());
1387
+ this.domNodes.clear();
1388
+ this.htmlContent.clear();
1389
+ this.requestRender();
1390
+ }
1391
+ createWrapper() {
1392
+ const el = document.createElement("div");
1393
+ Object.assign(el.style, {
1394
+ position: "relative",
1395
+ width: "100%",
1396
+ height: "100%",
1397
+ overflow: "hidden"
1398
+ });
1399
+ return el;
1400
+ }
1401
+ createCanvas() {
1402
+ const el = document.createElement("canvas");
1403
+ Object.assign(el.style, {
1404
+ position: "absolute",
1405
+ top: "0",
1406
+ left: "0",
1407
+ width: "100%",
1408
+ height: "100%"
1409
+ });
1410
+ return el;
1411
+ }
1412
+ createDomLayer() {
1413
+ const el = document.createElement("div");
1414
+ Object.assign(el.style, {
1415
+ position: "absolute",
1416
+ top: "0",
1417
+ left: "0",
1418
+ width: "100%",
1419
+ height: "100%",
1420
+ pointerEvents: "none",
1421
+ transformOrigin: "0 0"
1422
+ });
1423
+ return el;
1424
+ }
1425
+ applyCameraTransform() {
1426
+ this.domLayer.style.transform = this.camera.toCSSTransform();
1427
+ }
1428
+ syncCanvasSize() {
1429
+ const rect = this.container.getBoundingClientRect();
1430
+ const dpr = typeof devicePixelRatio !== "undefined" ? devicePixelRatio : 1;
1431
+ this.canvasEl.width = rect.width * dpr;
1432
+ this.canvasEl.height = rect.height * dpr;
1433
+ this.requestRender();
1434
+ }
1435
+ observeResize() {
1436
+ if (typeof ResizeObserver === "undefined") return;
1437
+ this.resizeObserver = new ResizeObserver(() => this.syncCanvasSize());
1438
+ this.resizeObserver.observe(this.container);
1439
+ }
1440
+ };
1441
+
1442
+ // src/tools/hand-tool.ts
1443
+ var HandTool = class {
1444
+ name = "hand";
1445
+ panning = false;
1446
+ lastScreen = { x: 0, y: 0 };
1447
+ onActivate(ctx) {
1448
+ ctx.setCursor?.("grab");
1449
+ }
1450
+ onDeactivate(ctx) {
1451
+ ctx.setCursor?.("default");
1452
+ }
1453
+ onPointerDown(state, ctx) {
1454
+ this.panning = true;
1455
+ this.lastScreen = { x: state.x, y: state.y };
1456
+ ctx.setCursor?.("grabbing");
1457
+ }
1458
+ onPointerMove(state, ctx) {
1459
+ if (!this.panning) return;
1460
+ const dx = state.x - this.lastScreen.x;
1461
+ const dy = state.y - this.lastScreen.y;
1462
+ this.lastScreen = { x: state.x, y: state.y };
1463
+ ctx.camera.pan(dx, dy);
1464
+ }
1465
+ onPointerUp(_state, ctx) {
1466
+ this.panning = false;
1467
+ ctx.setCursor?.("grab");
1468
+ }
1469
+ };
1470
+
1471
+ // src/tools/pencil-tool.ts
1472
+ var MIN_POINTS_FOR_STROKE = 2;
1473
+ var PencilTool = class {
1474
+ name = "pencil";
1475
+ drawing = false;
1476
+ points = [];
1477
+ color;
1478
+ width;
1479
+ constructor(options = {}) {
1480
+ this.color = options.color ?? "#000000";
1481
+ this.width = options.width ?? 2;
1482
+ }
1483
+ onActivate(ctx) {
1484
+ ctx.setCursor?.("crosshair");
1485
+ }
1486
+ onDeactivate(ctx) {
1487
+ ctx.setCursor?.("default");
1488
+ }
1489
+ setOptions(options) {
1490
+ if (options.color !== void 0) this.color = options.color;
1491
+ if (options.width !== void 0) this.width = options.width;
1492
+ }
1493
+ onPointerDown(state, ctx) {
1494
+ this.drawing = true;
1495
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
1496
+ this.points = [world];
1497
+ }
1498
+ onPointerMove(state, ctx) {
1499
+ if (!this.drawing) return;
1500
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
1501
+ this.points.push(world);
1502
+ ctx.requestRender();
1503
+ }
1504
+ onPointerUp(_state, ctx) {
1505
+ if (!this.drawing) return;
1506
+ this.drawing = false;
1507
+ if (this.points.length < MIN_POINTS_FOR_STROKE) {
1508
+ this.points = [];
1509
+ return;
1510
+ }
1511
+ const stroke = createStroke({
1512
+ points: this.points,
1513
+ color: this.color,
1514
+ width: this.width
1515
+ });
1516
+ ctx.store.add(stroke);
1517
+ this.points = [];
1518
+ ctx.requestRender();
1519
+ }
1520
+ renderOverlay(ctx) {
1521
+ if (!this.drawing || this.points.length < 2) return;
1522
+ ctx.save();
1523
+ ctx.strokeStyle = this.color;
1524
+ ctx.lineWidth = this.width;
1525
+ ctx.lineCap = "round";
1526
+ ctx.lineJoin = "round";
1527
+ ctx.globalAlpha = 0.8;
1528
+ ctx.beginPath();
1529
+ const first = this.points[0];
1530
+ if (!first) return;
1531
+ ctx.moveTo(first.x, first.y);
1532
+ for (let i = 1; i < this.points.length; i++) {
1533
+ const p = this.points[i];
1534
+ if (p) ctx.lineTo(p.x, p.y);
1535
+ }
1536
+ ctx.stroke();
1537
+ ctx.restore();
1538
+ }
1539
+ };
1540
+
1541
+ // src/tools/eraser-tool.ts
1542
+ var DEFAULT_RADIUS = 20;
1543
+ function makeEraserCursor(radius) {
1544
+ const size = radius * 2;
1545
+ const svg = `<svg xmlns='http://www.w3.org/2000/svg' width='${size}' height='${size}'><circle cx='${radius}' cy='${radius}' r='${radius - 1}' fill='none' stroke='%23666' stroke-width='1.5'/></svg>`;
1546
+ return `url("data:image/svg+xml,${svg}") ${radius} ${radius}, crosshair`;
1547
+ }
1548
+ var EraserTool = class {
1549
+ name = "eraser";
1550
+ erasing = false;
1551
+ radius;
1552
+ cursor;
1553
+ constructor(options = {}) {
1554
+ this.radius = options.radius ?? DEFAULT_RADIUS;
1555
+ this.cursor = makeEraserCursor(this.radius);
1556
+ }
1557
+ onActivate(ctx) {
1558
+ ctx.setCursor?.(this.cursor);
1559
+ }
1560
+ onDeactivate(ctx) {
1561
+ ctx.setCursor?.("default");
1562
+ }
1563
+ onPointerDown(state, ctx) {
1564
+ this.erasing = true;
1565
+ this.eraseAt(state, ctx);
1566
+ }
1567
+ onPointerMove(state, ctx) {
1568
+ if (!this.erasing) return;
1569
+ this.eraseAt(state, ctx);
1570
+ }
1571
+ onPointerUp(_state, _ctx) {
1572
+ this.erasing = false;
1573
+ }
1574
+ eraseAt(state, ctx) {
1575
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
1576
+ const strokes = ctx.store.getElementsByType("stroke");
1577
+ let erased = false;
1578
+ for (const stroke of strokes) {
1579
+ if (this.strokeIntersects(stroke, world)) {
1580
+ ctx.store.remove(stroke.id);
1581
+ erased = true;
1582
+ }
1583
+ }
1584
+ if (erased) ctx.requestRender();
1585
+ }
1586
+ strokeIntersects(stroke, point) {
1587
+ const radiusSq = this.radius * this.radius;
1588
+ return stroke.points.some((p) => {
1589
+ const dx = p.x + stroke.position.x - point.x;
1590
+ const dy = p.y + stroke.position.y - point.y;
1591
+ return dx * dx + dy * dy <= radiusSq;
1592
+ });
1593
+ }
1594
+ };
1595
+
1596
+ // src/tools/arrow-handles.ts
1597
+ var HANDLE_RADIUS = 5;
1598
+ var HANDLE_HIT_PADDING = 4;
1599
+ var ARROW_HANDLE_CURSORS = {
1600
+ start: "crosshair",
1601
+ end: "crosshair",
1602
+ mid: "grab"
1603
+ };
1604
+ function getArrowHandleCursor(handle, active) {
1605
+ if (handle === "mid" && active) return "grabbing";
1606
+ return ARROW_HANDLE_CURSORS[handle];
1607
+ }
1608
+ function getArrowHandlePositions(arrow) {
1609
+ const mid = getArrowMidpoint(arrow.from, arrow.to, arrow.bend);
1610
+ return [
1611
+ ["start", arrow.from],
1612
+ ["mid", mid],
1613
+ ["end", arrow.to]
1614
+ ];
1615
+ }
1616
+ function hitTestArrowHandles(world, selectedIds, ctx) {
1617
+ if (selectedIds.length === 0) return null;
1618
+ const zoom = ctx.camera.zoom;
1619
+ const hitRadius = (HANDLE_RADIUS + HANDLE_HIT_PADDING) / zoom;
1620
+ for (const id of selectedIds) {
1621
+ const el = ctx.store.getById(id);
1622
+ if (!el || el.type !== "arrow") continue;
1623
+ const handles = getArrowHandlePositions(el);
1624
+ for (const [handle, pos] of handles) {
1625
+ const dx = world.x - pos.x;
1626
+ const dy = world.y - pos.y;
1627
+ if (dx * dx + dy * dy <= hitRadius * hitRadius) {
1628
+ return { elementId: id, handle };
1629
+ }
1630
+ }
1631
+ }
1632
+ return null;
1633
+ }
1634
+ function applyArrowHandleDrag(handle, elementId, world, ctx) {
1635
+ const el = ctx.store.getById(elementId);
1636
+ if (!el || el.type !== "arrow") return;
1637
+ switch (handle) {
1638
+ case "start":
1639
+ ctx.store.update(elementId, {
1640
+ from: { x: world.x, y: world.y },
1641
+ position: { x: world.x, y: world.y }
1642
+ });
1643
+ break;
1644
+ case "end":
1645
+ ctx.store.update(elementId, {
1646
+ to: { x: world.x, y: world.y }
1647
+ });
1648
+ break;
1649
+ case "mid": {
1650
+ const bend = getBendFromPoint(el.from, el.to, world);
1651
+ ctx.store.update(elementId, { bend });
1652
+ break;
1653
+ }
1654
+ }
1655
+ ctx.requestRender();
1656
+ }
1657
+ function renderArrowHandles(canvasCtx, arrow, zoom) {
1658
+ const radius = HANDLE_RADIUS / zoom;
1659
+ const handles = getArrowHandlePositions(arrow);
1660
+ canvasCtx.setLineDash([]);
1661
+ canvasCtx.lineWidth = 1.5 / zoom;
1662
+ for (const [handle, pos] of handles) {
1663
+ canvasCtx.fillStyle = handle === "mid" ? "#2196F3" : "#ffffff";
1664
+ canvasCtx.strokeStyle = "#2196F3";
1665
+ canvasCtx.beginPath();
1666
+ canvasCtx.arc(pos.x, pos.y, radius, 0, Math.PI * 2);
1667
+ canvasCtx.fill();
1668
+ canvasCtx.stroke();
1669
+ }
1670
+ }
1671
+
1672
+ // src/tools/select-tool.ts
1673
+ var HANDLE_SIZE = 8;
1674
+ var HANDLE_HIT_PADDING2 = 4;
1675
+ var SELECTION_PAD = 4;
1676
+ var MIN_ELEMENT_SIZE = 20;
1677
+ var HANDLE_CURSORS = {
1678
+ nw: "nwse-resize",
1679
+ se: "nwse-resize",
1680
+ ne: "nesw-resize",
1681
+ sw: "nesw-resize"
1682
+ };
1683
+ var SelectTool = class {
1684
+ name = "select";
1685
+ _selectedIds = [];
1686
+ mode = { type: "idle" };
1687
+ lastWorld = { x: 0, y: 0 };
1688
+ currentWorld = { x: 0, y: 0 };
1689
+ ctx = null;
1690
+ get selectedIds() {
1691
+ return [...this._selectedIds];
1692
+ }
1693
+ get isMarqueeActive() {
1694
+ return this.mode.type === "marquee";
1695
+ }
1696
+ onActivate(ctx) {
1697
+ this.ctx = ctx;
1698
+ }
1699
+ onDeactivate(ctx) {
1700
+ this._selectedIds = [];
1701
+ this.mode = { type: "idle" };
1702
+ ctx.setCursor?.("default");
1703
+ }
1704
+ onPointerDown(state, ctx) {
1705
+ this.ctx = ctx;
1706
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
1707
+ this.lastWorld = world;
1708
+ this.currentWorld = world;
1709
+ const arrowHit = hitTestArrowHandles(world, this._selectedIds, ctx);
1710
+ if (arrowHit) {
1711
+ this.mode = {
1712
+ type: "arrow-handle",
1713
+ elementId: arrowHit.elementId,
1714
+ handle: arrowHit.handle
1715
+ };
1716
+ ctx.requestRender();
1717
+ return;
1718
+ }
1719
+ const resizeHit = this.hitTestResizeHandle(world, ctx);
1720
+ if (resizeHit) {
1721
+ const el = ctx.store.getById(resizeHit.elementId);
1722
+ if (el) {
1723
+ this.mode = {
1724
+ type: "resizing",
1725
+ elementId: resizeHit.elementId,
1726
+ handle: resizeHit.handle
1727
+ };
1728
+ ctx.requestRender();
1729
+ return;
1730
+ }
1731
+ }
1732
+ const hit = this.hitTest(world, ctx);
1733
+ if (hit) {
1734
+ const alreadySelected = this._selectedIds.includes(hit.id);
1735
+ if (!alreadySelected) {
1736
+ this._selectedIds = [hit.id];
1737
+ }
1738
+ this.mode = hit.locked ? { type: "idle" } : { type: "dragging" };
1739
+ } else {
1740
+ this._selectedIds = [];
1741
+ this.mode = { type: "marquee", start: world };
1742
+ }
1743
+ ctx.requestRender();
1744
+ }
1745
+ onPointerMove(state, ctx) {
1746
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
1747
+ this.currentWorld = world;
1748
+ if (this.mode.type === "arrow-handle") {
1749
+ ctx.setCursor?.(getArrowHandleCursor(this.mode.handle, true));
1750
+ applyArrowHandleDrag(this.mode.handle, this.mode.elementId, world, ctx);
1751
+ return;
1752
+ }
1753
+ if (this.mode.type === "resizing") {
1754
+ ctx.setCursor?.(HANDLE_CURSORS[this.mode.handle]);
1755
+ this.handleResize(world, ctx);
1756
+ return;
1757
+ }
1758
+ if (this.mode.type === "dragging" && this._selectedIds.length > 0) {
1759
+ ctx.setCursor?.("move");
1760
+ const dx = world.x - this.lastWorld.x;
1761
+ const dy = world.y - this.lastWorld.y;
1762
+ this.lastWorld = world;
1763
+ for (const id of this._selectedIds) {
1764
+ const el = ctx.store.getById(id);
1765
+ if (!el || el.locked) continue;
1766
+ if (el.type === "arrow") {
1767
+ ctx.store.update(id, {
1768
+ position: { x: el.position.x + dx, y: el.position.y + dy },
1769
+ from: { x: el.from.x + dx, y: el.from.y + dy },
1770
+ to: { x: el.to.x + dx, y: el.to.y + dy }
1771
+ });
1772
+ } else {
1773
+ ctx.store.update(id, {
1774
+ position: { x: el.position.x + dx, y: el.position.y + dy }
1775
+ });
1776
+ }
1777
+ }
1778
+ ctx.requestRender();
1779
+ return;
1780
+ }
1781
+ if (this.mode.type === "marquee") {
1782
+ ctx.setCursor?.("crosshair");
1783
+ ctx.requestRender();
1784
+ return;
1785
+ }
1786
+ this.updateHoverCursor(world, ctx);
1787
+ }
1788
+ onPointerUp(_state, ctx) {
1789
+ if (this.mode.type === "marquee") {
1790
+ const rect = this.getMarqueeRect();
1791
+ if (rect) {
1792
+ this._selectedIds = this.findElementsInRect(rect, ctx);
1793
+ }
1794
+ ctx.requestRender();
1795
+ }
1796
+ this.mode = { type: "idle" };
1797
+ ctx.setCursor?.("default");
1798
+ }
1799
+ onHover(state, ctx) {
1800
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
1801
+ this.updateHoverCursor(world, ctx);
1802
+ }
1803
+ renderOverlay(canvasCtx) {
1804
+ this.renderMarquee(canvasCtx);
1805
+ this.renderSelectionBoxes(canvasCtx);
1806
+ }
1807
+ updateHoverCursor(world, ctx) {
1808
+ const arrowHit = hitTestArrowHandles(world, this._selectedIds, ctx);
1809
+ if (arrowHit) {
1810
+ ctx.setCursor?.(getArrowHandleCursor(arrowHit.handle, false));
1811
+ return;
1812
+ }
1813
+ const resizeHit = this.hitTestResizeHandle(world, ctx);
1814
+ if (resizeHit) {
1815
+ ctx.setCursor?.(HANDLE_CURSORS[resizeHit.handle]);
1816
+ return;
1817
+ }
1818
+ const hit = this.hitTest(world, ctx);
1819
+ ctx.setCursor?.(hit ? "move" : "default");
1820
+ }
1821
+ handleResize(world, ctx) {
1822
+ if (this.mode.type !== "resizing") return;
1823
+ const el = ctx.store.getById(this.mode.elementId);
1824
+ if (!el || !("size" in el)) return;
1825
+ const { handle } = this.mode;
1826
+ const dx = world.x - this.lastWorld.x;
1827
+ const dy = world.y - this.lastWorld.y;
1828
+ this.lastWorld = world;
1829
+ let { x, y, w, h } = { x: el.position.x, y: el.position.y, w: el.size.w, h: el.size.h };
1830
+ switch (handle) {
1831
+ case "se":
1832
+ w += dx;
1833
+ h += dy;
1834
+ break;
1835
+ case "sw":
1836
+ x += dx;
1837
+ w -= dx;
1838
+ h += dy;
1839
+ break;
1840
+ case "ne":
1841
+ y += dy;
1842
+ w += dx;
1843
+ h -= dy;
1844
+ break;
1845
+ case "nw":
1846
+ x += dx;
1847
+ y += dy;
1848
+ w -= dx;
1849
+ h -= dy;
1850
+ break;
1851
+ }
1852
+ if (w < MIN_ELEMENT_SIZE) {
1853
+ if (handle === "nw" || handle === "sw") x = el.position.x + el.size.w - MIN_ELEMENT_SIZE;
1854
+ w = MIN_ELEMENT_SIZE;
1855
+ }
1856
+ if (h < MIN_ELEMENT_SIZE) {
1857
+ if (handle === "nw" || handle === "ne") y = el.position.y + el.size.h - MIN_ELEMENT_SIZE;
1858
+ h = MIN_ELEMENT_SIZE;
1859
+ }
1860
+ ctx.store.update(this.mode.elementId, {
1861
+ position: { x, y },
1862
+ size: { w, h }
1863
+ });
1864
+ ctx.requestRender();
1865
+ }
1866
+ hitTestResizeHandle(world, ctx) {
1867
+ if (this._selectedIds.length === 0) return null;
1868
+ const zoom = ctx.camera.zoom;
1869
+ const handleHalf = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / zoom;
1870
+ for (const id of this._selectedIds) {
1871
+ const el = ctx.store.getById(id);
1872
+ if (!el || !("size" in el)) continue;
1873
+ const bounds = this.getElementBounds(el);
1874
+ if (!bounds) continue;
1875
+ const corners = this.getHandlePositions(bounds);
1876
+ for (const [handle, pos] of corners) {
1877
+ if (Math.abs(world.x - pos.x) <= handleHalf && Math.abs(world.y - pos.y) <= handleHalf) {
1878
+ return { elementId: id, handle };
1879
+ }
1880
+ }
1881
+ }
1882
+ return null;
1883
+ }
1884
+ getHandlePositions(bounds) {
1885
+ return [
1886
+ ["nw", { x: bounds.x, y: bounds.y }],
1887
+ ["ne", { x: bounds.x + bounds.w, y: bounds.y }],
1888
+ ["sw", { x: bounds.x, y: bounds.y + bounds.h }],
1889
+ ["se", { x: bounds.x + bounds.w, y: bounds.y + bounds.h }]
1890
+ ];
1891
+ }
1892
+ renderMarquee(canvasCtx) {
1893
+ if (this.mode.type !== "marquee") return;
1894
+ const rect = this.getMarqueeRect();
1895
+ if (!rect) return;
1896
+ canvasCtx.save();
1897
+ canvasCtx.strokeStyle = "#2196F3";
1898
+ canvasCtx.fillStyle = "rgba(33, 150, 243, 0.08)";
1899
+ canvasCtx.lineWidth = 1;
1900
+ canvasCtx.setLineDash([4, 4]);
1901
+ canvasCtx.strokeRect(rect.x, rect.y, rect.w, rect.h);
1902
+ canvasCtx.fillRect(rect.x, rect.y, rect.w, rect.h);
1903
+ canvasCtx.restore();
1904
+ }
1905
+ renderSelectionBoxes(canvasCtx) {
1906
+ if (this._selectedIds.length === 0 || !this.ctx) return;
1907
+ const zoom = this.ctx.camera.zoom;
1908
+ const handleWorldSize = HANDLE_SIZE / zoom;
1909
+ canvasCtx.save();
1910
+ canvasCtx.strokeStyle = "#2196F3";
1911
+ canvasCtx.lineWidth = 1.5 / zoom;
1912
+ canvasCtx.setLineDash([4 / zoom, 4 / zoom]);
1913
+ for (const id of this._selectedIds) {
1914
+ const el = this.ctx.store.getById(id);
1915
+ if (!el) continue;
1916
+ if (el.type === "arrow") {
1917
+ renderArrowHandles(canvasCtx, el, zoom);
1918
+ continue;
1919
+ }
1920
+ const bounds = this.getElementBounds(el);
1921
+ if (!bounds) continue;
1922
+ const pad = SELECTION_PAD / zoom;
1923
+ canvasCtx.strokeRect(bounds.x - pad, bounds.y - pad, bounds.w + pad * 2, bounds.h + pad * 2);
1924
+ if ("size" in el) {
1925
+ canvasCtx.setLineDash([]);
1926
+ canvasCtx.fillStyle = "#ffffff";
1927
+ const corners = this.getHandlePositions(bounds);
1928
+ for (const [, pos] of corners) {
1929
+ canvasCtx.fillRect(
1930
+ pos.x - handleWorldSize / 2,
1931
+ pos.y - handleWorldSize / 2,
1932
+ handleWorldSize,
1933
+ handleWorldSize
1934
+ );
1935
+ canvasCtx.strokeRect(
1936
+ pos.x - handleWorldSize / 2,
1937
+ pos.y - handleWorldSize / 2,
1938
+ handleWorldSize,
1939
+ handleWorldSize
1940
+ );
1941
+ }
1942
+ canvasCtx.setLineDash([4 / zoom, 4 / zoom]);
1943
+ }
1944
+ }
1945
+ canvasCtx.restore();
1946
+ }
1947
+ getMarqueeRect() {
1948
+ if (this.mode.type !== "marquee") return null;
1949
+ const { start } = this.mode;
1950
+ const end = this.currentWorld;
1951
+ const x = Math.min(start.x, end.x);
1952
+ const y = Math.min(start.y, end.y);
1953
+ const w = Math.abs(end.x - start.x);
1954
+ const h = Math.abs(end.y - start.y);
1955
+ if (w === 0 && h === 0) return null;
1956
+ return { x, y, w, h };
1957
+ }
1958
+ findElementsInRect(marquee, ctx) {
1959
+ const ids = [];
1960
+ for (const el of ctx.store.getAll()) {
1961
+ const bounds = this.getElementBounds(el);
1962
+ if (bounds && this.rectsOverlap(marquee, bounds)) {
1963
+ ids.push(el.id);
1964
+ }
1965
+ }
1966
+ return ids;
1967
+ }
1968
+ rectsOverlap(a, b) {
1969
+ return a.x <= b.x + b.w && a.x + a.w >= b.x && a.y <= b.y + b.h && a.y + a.h >= b.y;
1970
+ }
1971
+ getElementBounds(el) {
1972
+ if ("size" in el) {
1973
+ return { x: el.position.x, y: el.position.y, w: el.size.w, h: el.size.h };
1974
+ }
1975
+ if (el.type === "stroke" && el.points.length > 0) {
1976
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
1977
+ for (const p of el.points) {
1978
+ const px = p.x + el.position.x;
1979
+ const py = p.y + el.position.y;
1980
+ if (px < minX) minX = px;
1981
+ if (py < minY) minY = py;
1982
+ if (px > maxX) maxX = px;
1983
+ if (py > maxY) maxY = py;
1984
+ }
1985
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
1986
+ }
1987
+ if (el.type === "arrow") {
1988
+ return getArrowBounds(el.from, el.to, el.bend);
1989
+ }
1990
+ return null;
1991
+ }
1992
+ hitTest(world, ctx) {
1993
+ const elements = ctx.store.getAll().reverse();
1994
+ for (const el of elements) {
1995
+ if (this.isInsideBounds(world, el)) return el;
1996
+ }
1997
+ return null;
1998
+ }
1999
+ isInsideBounds(point, el) {
2000
+ if ("size" in el) {
2001
+ const s = el.size;
2002
+ return point.x >= el.position.x && point.x <= el.position.x + s.w && point.y >= el.position.y && point.y <= el.position.y + s.h;
2003
+ }
2004
+ if (el.type === "stroke") {
2005
+ const HIT_RADIUS = 10;
2006
+ return el.points.some((p) => {
2007
+ const dx = p.x + el.position.x - point.x;
2008
+ const dy = p.y + el.position.y - point.y;
2009
+ return dx * dx + dy * dy <= HIT_RADIUS * HIT_RADIUS;
2010
+ });
2011
+ }
2012
+ if (el.type === "arrow") {
2013
+ return isNearBezier(point, el.from, el.to, el.bend, 10);
2014
+ }
2015
+ return false;
2016
+ }
2017
+ };
2018
+
2019
+ // src/tools/arrow-tool.ts
2020
+ var ArrowTool = class {
2021
+ name = "arrow";
2022
+ drawing = false;
2023
+ start = { x: 0, y: 0 };
2024
+ end = { x: 0, y: 0 };
2025
+ color;
2026
+ width;
2027
+ constructor(options = {}) {
2028
+ this.color = options.color ?? "#000000";
2029
+ this.width = options.width ?? 2;
2030
+ }
2031
+ onPointerDown(state, ctx) {
2032
+ this.drawing = true;
2033
+ this.start = ctx.camera.screenToWorld({ x: state.x, y: state.y });
2034
+ this.end = { ...this.start };
2035
+ }
2036
+ onPointerMove(state, ctx) {
2037
+ if (!this.drawing) return;
2038
+ this.end = ctx.camera.screenToWorld({ x: state.x, y: state.y });
2039
+ ctx.requestRender();
2040
+ }
2041
+ onPointerUp(_state, ctx) {
2042
+ if (!this.drawing) return;
2043
+ this.drawing = false;
2044
+ if (this.start.x === this.end.x && this.start.y === this.end.y) return;
2045
+ const arrow = createArrow({
2046
+ from: this.start,
2047
+ to: this.end,
2048
+ color: this.color,
2049
+ width: this.width
2050
+ });
2051
+ ctx.store.add(arrow);
2052
+ ctx.requestRender();
2053
+ }
2054
+ renderOverlay(ctx) {
2055
+ if (!this.drawing) return;
2056
+ if (this.start.x === this.end.x && this.start.y === this.end.y) return;
2057
+ ctx.save();
2058
+ ctx.strokeStyle = this.color;
2059
+ ctx.lineWidth = this.width;
2060
+ ctx.lineCap = "round";
2061
+ ctx.globalAlpha = 0.6;
2062
+ ctx.beginPath();
2063
+ ctx.moveTo(this.start.x, this.start.y);
2064
+ ctx.lineTo(this.end.x, this.end.y);
2065
+ ctx.stroke();
2066
+ const angle = Math.atan2(this.end.y - this.start.y, this.end.x - this.start.x);
2067
+ const headLen = 12;
2068
+ const headAngle = Math.PI / 6;
2069
+ ctx.fillStyle = this.color;
2070
+ ctx.beginPath();
2071
+ ctx.moveTo(this.end.x, this.end.y);
2072
+ ctx.lineTo(
2073
+ this.end.x - headLen * Math.cos(angle - headAngle),
2074
+ this.end.y - headLen * Math.sin(angle - headAngle)
2075
+ );
2076
+ ctx.lineTo(
2077
+ this.end.x - headLen * Math.cos(angle + headAngle),
2078
+ this.end.y - headLen * Math.sin(angle + headAngle)
2079
+ );
2080
+ ctx.closePath();
2081
+ ctx.fill();
2082
+ ctx.restore();
2083
+ }
2084
+ };
2085
+
2086
+ // src/tools/note-tool.ts
2087
+ var NoteTool = class {
2088
+ name = "note";
2089
+ backgroundColor;
2090
+ size;
2091
+ constructor(options = {}) {
2092
+ this.backgroundColor = options.backgroundColor ?? "#ffeb3b";
2093
+ this.size = options.size ?? { w: 200, h: 100 };
2094
+ }
2095
+ onPointerDown(_state, _ctx) {
2096
+ }
2097
+ onPointerMove(_state, _ctx) {
2098
+ }
2099
+ onPointerUp(state, ctx) {
2100
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
2101
+ const note = createNote({
2102
+ position: world,
2103
+ size: { ...this.size },
2104
+ backgroundColor: this.backgroundColor
2105
+ });
2106
+ ctx.store.add(note);
2107
+ ctx.requestRender();
2108
+ ctx.switchTool?.("select");
2109
+ ctx.editElement?.(note.id);
2110
+ }
2111
+ };
2112
+
2113
+ // src/tools/image-tool.ts
2114
+ var ImageTool = class {
2115
+ name = "image";
2116
+ size;
2117
+ src = null;
2118
+ constructor(options = {}) {
2119
+ this.size = options.size ?? { w: 300, h: 200 };
2120
+ }
2121
+ setSrc(src) {
2122
+ this.src = src;
2123
+ }
2124
+ onPointerDown(_state, _ctx) {
2125
+ }
2126
+ onPointerMove(_state, _ctx) {
2127
+ }
2128
+ onPointerUp(state, ctx) {
2129
+ if (!this.src) return;
2130
+ const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
2131
+ const image = createImage({
2132
+ position: world,
2133
+ size: { ...this.size },
2134
+ src: this.src
2135
+ });
2136
+ ctx.store.add(image);
2137
+ ctx.requestRender();
2138
+ this.src = null;
2139
+ ctx.switchTool?.("select");
2140
+ }
2141
+ };
2142
+
2143
+ // src/index.ts
2144
+ var VERSION = "0.1.0";
2145
+ export {
2146
+ AddElementCommand,
2147
+ ArrowTool,
2148
+ Background,
2149
+ BatchCommand,
2150
+ Camera,
2151
+ ElementRenderer,
2152
+ ElementStore,
2153
+ EraserTool,
2154
+ EventBus,
2155
+ HandTool,
2156
+ HistoryRecorder,
2157
+ HistoryStack,
2158
+ ImageTool,
2159
+ InputHandler,
2160
+ NoteEditor,
2161
+ NoteTool,
2162
+ PencilTool,
2163
+ RemoveElementCommand,
2164
+ SelectTool,
2165
+ ToolManager,
2166
+ UpdateElementCommand,
2167
+ VERSION,
2168
+ Viewport,
2169
+ createArrow,
2170
+ createHtmlElement,
2171
+ createId,
2172
+ createImage,
2173
+ createNote,
2174
+ createStroke,
2175
+ exportState,
2176
+ getArrowBounds,
2177
+ getArrowControlPoint,
2178
+ getArrowMidpoint,
2179
+ getArrowTangentAngle,
2180
+ getBendFromPoint,
2181
+ isNearBezier,
2182
+ parseState
2183
+ };
2184
+ //# sourceMappingURL=index.js.map