@tsdraw/core 0.4.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,1653 @@
1
+ import { getStroke } from 'perfect-freehand';
2
+
3
+ // src/types.ts
4
+ var STROKE_WIDTHS = {
5
+ s: 2,
6
+ m: 3.5,
7
+ l: 5,
8
+ xl: 10
9
+ };
10
+ var DRAG_DISTANCE_SQUARED = 36;
11
+ var DEFAULT_COLORS = {
12
+ black: "#1d1d1d",
13
+ grey: "#9fa8b2",
14
+ "light-violet": "#e085f4",
15
+ violet: "#ae3ec9",
16
+ blue: "#4465e9",
17
+ "light-blue": "#4ba1f1",
18
+ yellow: "#f1ac4b",
19
+ orange: "#e16919",
20
+ green: "#099268",
21
+ "light-green": "#4cb05e",
22
+ "light-red": "#f87777",
23
+ red: "#e03131",
24
+ white: "#ffffff"
25
+ };
26
+ var MAX_POINTS_PER_SHAPE = 200;
27
+
28
+ // src/utils/pathCodec.ts
29
+ function encodePoints(points) {
30
+ const arr = [];
31
+ for (const p of points) {
32
+ arr.push(p.x, p.y, p.z ?? 0.5);
33
+ }
34
+ return btoa(JSON.stringify(arr));
35
+ }
36
+ function decodePoints(path) {
37
+ try {
38
+ const arr = JSON.parse(atob(path));
39
+ const out = [];
40
+ for (let i = 0; i < arr.length; i += 3) {
41
+ out.push({
42
+ x: arr[i] ?? 0,
43
+ y: arr[i + 1] ?? 0,
44
+ z: arr[i + 2]
45
+ });
46
+ }
47
+ return out;
48
+ } catch {
49
+ return [];
50
+ }
51
+ }
52
+ function decodeFirstPoint(path) {
53
+ const pts = decodePoints(path);
54
+ return pts.length > 0 ? pts[0] : null;
55
+ }
56
+ function decodeLastPoint(path) {
57
+ const pts = decodePoints(path);
58
+ return pts.length > 0 ? pts[pts.length - 1] : null;
59
+ }
60
+ function decodePathToPoints(segments, ox, oy) {
61
+ const out = [];
62
+ for (const seg of segments) {
63
+ const pts = decodePoints(seg.path);
64
+ for (const p of pts) {
65
+ out.push({ x: ox + p.x, y: oy + p.y });
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+
71
+ // src/store/documentStore.ts
72
+ var DocumentStore = class {
73
+ state = {
74
+ id: "page-1",
75
+ shapes: {},
76
+ erasingShapeIds: []
77
+ };
78
+ order = [];
79
+ getPage() {
80
+ return this.state;
81
+ }
82
+ getShape(id) {
83
+ return this.state.shapes[id];
84
+ }
85
+ // Shapes organised in sorted order (first at bottom)
86
+ getCurrentPageShapesSorted() {
87
+ const list = this.order.length > 0 ? this.order : Object.keys(this.state.shapes);
88
+ return list.map((id) => this.state.shapes[id]).filter((s) => s != null);
89
+ }
90
+ // Shapes in reverse order (topmost first) for hit-testing
91
+ getCurrentPageRenderingShapesSorted() {
92
+ return [...this.getCurrentPageShapesSorted()].reverse();
93
+ }
94
+ getErasingShapeIds() {
95
+ return [...this.state.erasingShapeIds];
96
+ }
97
+ setErasingShapes(ids) {
98
+ this.state.erasingShapeIds = ids;
99
+ }
100
+ createShape(shape) {
101
+ this.state.shapes[shape.id] = shape;
102
+ this.order.push(shape.id);
103
+ }
104
+ updateShape(id, partial) {
105
+ const existing = this.state.shapes[id];
106
+ if (!existing) return;
107
+ this.state.shapes[id] = { ...existing, ...partial, id };
108
+ }
109
+ deleteShapes(ids) {
110
+ for (const id of ids) {
111
+ delete this.state.shapes[id];
112
+ this.order = this.order.filter((i) => i !== id);
113
+ }
114
+ this.state.erasingShapeIds = this.state.erasingShapeIds.filter((i) => !ids.includes(i));
115
+ }
116
+ getCurrentPageShapes() {
117
+ return Object.values(this.state.shapes);
118
+ }
119
+ // Shape IDs whose bounds intersect the given box for eraser line-segment hit
120
+ getShapeIdsInBounds(box) {
121
+ const ids = /* @__PURE__ */ new Set();
122
+ for (const shape of this.getCurrentPageShapesSorted()) {
123
+ const b = getShapeBounds(shape);
124
+ if (b.maxX >= box.minX && b.minX <= box.maxX && b.maxY >= box.minY && b.minY <= box.maxY) {
125
+ ids.add(shape.id);
126
+ }
127
+ }
128
+ return ids;
129
+ }
130
+ };
131
+ function getShapeBounds(shape) {
132
+ if (shape.type !== "draw") {
133
+ return { minX: shape.x, minY: shape.y, maxX: shape.x, maxY: shape.y };
134
+ }
135
+ const pts = decodePathToPoints(shape.props.segments, shape.x, shape.y);
136
+ if (pts.length === 0) return { minX: shape.x, minY: shape.y, maxX: shape.x, maxY: shape.y };
137
+ let minX = pts[0].x;
138
+ let minY = pts[0].y;
139
+ let maxX = minX;
140
+ let maxY = minY;
141
+ for (const p of pts) {
142
+ if (p.x < minX) minX = p.x;
143
+ if (p.y < minY) minY = p.y;
144
+ if (p.x > maxX) maxX = p.x;
145
+ if (p.y > maxY) maxY = p.y;
146
+ }
147
+ const stroke = STROKE_WIDTHS[shape.props.size] * shape.props.scale;
148
+ return { minX: minX - stroke, minY: minY - stroke, maxX: maxX + stroke, maxY: maxY + stroke };
149
+ }
150
+
151
+ // src/store/stateNode.ts
152
+ var StateNode = class {
153
+ constructor(ctx, editor) {
154
+ this.ctx = ctx;
155
+ this.editor = editor;
156
+ }
157
+ static id = "base";
158
+ onEnter(_info) {
159
+ }
160
+ onExit(_info, _to) {
161
+ }
162
+ onPointerDown(_info) {
163
+ }
164
+ onPointerMove(_info) {
165
+ }
166
+ onPointerUp() {
167
+ }
168
+ onKeyDown(_info) {
169
+ }
170
+ onKeyUp(_info) {
171
+ }
172
+ onCancel() {
173
+ }
174
+ onInterrupt() {
175
+ }
176
+ };
177
+
178
+ // src/canvas/viewport.ts
179
+ function createViewport() {
180
+ return { x: 0, y: 0, zoom: 1 };
181
+ }
182
+ function screenToPage(viewport, screenX, screenY) {
183
+ return {
184
+ x: (screenX - viewport.x) / viewport.zoom,
185
+ y: (screenY - viewport.y) / viewport.zoom
186
+ };
187
+ }
188
+ function pageToScreen(viewport, pageX, pageY) {
189
+ return {
190
+ x: pageX * viewport.zoom + viewport.x,
191
+ y: pageY * viewport.zoom + viewport.y
192
+ };
193
+ }
194
+ function setViewport(viewport, updater) {
195
+ return {
196
+ x: updater.x ?? viewport.x,
197
+ y: updater.y ?? viewport.y,
198
+ zoom: updater.zoom ?? viewport.zoom
199
+ };
200
+ }
201
+ function panViewport(viewport, dx, dy) {
202
+ return { ...viewport, x: viewport.x + dx, y: viewport.y + dy };
203
+ }
204
+ function zoomViewport(viewport, factor, centerX, centerY) {
205
+ const zoom = Math.max(0.1, Math.min(4, viewport.zoom * factor));
206
+ if (centerX == null || centerY == null) {
207
+ return { ...viewport, zoom };
208
+ }
209
+ const pageBefore = screenToPage(viewport, centerX, centerY);
210
+ const x = centerX - pageBefore.x * zoom;
211
+ const y = centerY - pageBefore.y * zoom;
212
+ return { x, y, zoom };
213
+ }
214
+
215
+ // src/utils/colors.ts
216
+ function resolveThemeColor(colorStyle, theme) {
217
+ const paletteColor = DEFAULT_COLORS[colorStyle];
218
+ if (!paletteColor) return colorStyle;
219
+ if (theme === "light") return paletteColor;
220
+ return invertAndHueRotate180(paletteColor);
221
+ }
222
+ function invertAndHueRotate180(color) {
223
+ const rgb = parseHexColor(color);
224
+ if (!rgb) return color;
225
+ const inverted = {
226
+ r: 255 - rgb.r,
227
+ g: 255 - rgb.g,
228
+ b: 255 - rgb.b
229
+ };
230
+ const hsl = rgbToHsl(inverted.r, inverted.g, inverted.b);
231
+ const rotated = hslToRgb((hsl.h + 180) % 360, hsl.s, hsl.l);
232
+ return rgbToHex(rotated.r, rotated.g, rotated.b);
233
+ }
234
+ function parseHexColor(color) {
235
+ const normalized = color.trim().toLowerCase();
236
+ if (!normalized.startsWith("#")) return null;
237
+ if (normalized.length === 4) {
238
+ return {
239
+ r: parseInt(normalized[1] + normalized[1], 16),
240
+ g: parseInt(normalized[2] + normalized[2], 16),
241
+ b: parseInt(normalized[3] + normalized[3], 16)
242
+ };
243
+ }
244
+ if (normalized.length !== 7) return null;
245
+ return {
246
+ r: parseInt(normalized.slice(1, 3), 16),
247
+ g: parseInt(normalized.slice(3, 5), 16),
248
+ b: parseInt(normalized.slice(5, 7), 16)
249
+ };
250
+ }
251
+ function rgbToHex(r, g, b) {
252
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
253
+ }
254
+ function toHex(value) {
255
+ return Math.round(Math.max(0, Math.min(255, value))).toString(16).padStart(2, "0");
256
+ }
257
+ function rgbToHsl(r, g, b) {
258
+ const red = r / 255;
259
+ const green = g / 255;
260
+ const blue = b / 255;
261
+ const maxChannel = Math.max(red, green, blue);
262
+ const minChannel = Math.min(red, green, blue);
263
+ const delta = maxChannel - minChannel;
264
+ const lightness = (maxChannel + minChannel) / 2;
265
+ if (delta === 0) {
266
+ return { h: 0, s: 0, l: lightness };
267
+ }
268
+ const saturation = lightness > 0.5 ? delta / (2 - maxChannel - minChannel) : delta / (maxChannel + minChannel);
269
+ let hue = 0;
270
+ if (maxChannel === red) {
271
+ hue = ((green - blue) / delta + (green < blue ? 6 : 0)) * 60;
272
+ } else if (maxChannel === green) {
273
+ hue = ((blue - red) / delta + 2) * 60;
274
+ } else {
275
+ hue = ((red - green) / delta + 4) * 60;
276
+ }
277
+ return { h: hue, s: saturation, l: lightness };
278
+ }
279
+ function hslToRgb(h, s, l) {
280
+ if (s === 0) {
281
+ const channel = l * 255;
282
+ return { r: channel, g: channel, b: channel };
283
+ }
284
+ const chroma = (1 - Math.abs(2 * l - 1)) * s;
285
+ const hueSegment = h / 60;
286
+ const x = chroma * (1 - Math.abs(hueSegment % 2 - 1));
287
+ let red = 0;
288
+ let green = 0;
289
+ let blue = 0;
290
+ if (hueSegment >= 0 && hueSegment < 1) {
291
+ red = chroma;
292
+ green = x;
293
+ } else if (hueSegment < 2) {
294
+ red = x;
295
+ green = chroma;
296
+ } else if (hueSegment < 3) {
297
+ green = chroma;
298
+ blue = x;
299
+ } else if (hueSegment < 4) {
300
+ green = x;
301
+ blue = chroma;
302
+ } else if (hueSegment < 5) {
303
+ red = x;
304
+ blue = chroma;
305
+ } else {
306
+ red = chroma;
307
+ blue = x;
308
+ }
309
+ const match = l - chroma / 2;
310
+ return {
311
+ r: (red + match) * 255,
312
+ g: (green + match) * 255,
313
+ b: (blue + match) * 255
314
+ };
315
+ }
316
+ var CanvasRenderer = class {
317
+ theme = "light";
318
+ setTheme(theme) {
319
+ this.theme = theme;
320
+ }
321
+ render(ctx, viewport, shapes) {
322
+ ctx.save();
323
+ ctx.translate(viewport.x, viewport.y);
324
+ ctx.scale(viewport.zoom, viewport.zoom);
325
+ for (const shape of shapes) {
326
+ if (shape.type === "draw") {
327
+ this.paintStroke(ctx, shape);
328
+ }
329
+ }
330
+ ctx.restore();
331
+ }
332
+ paintStroke(ctx, shape) {
333
+ const width = (STROKE_WIDTHS[shape.props.size] ?? 3.5) * shape.props.scale;
334
+ const samples = flattenSegments(shape);
335
+ if (samples.length === 0) return;
336
+ const color = resolveThemeColor(shape.props.color, this.theme);
337
+ if (shape.props.dash !== "draw") {
338
+ this.paintDashedStroke(ctx, samples, width, color, shape.props.dash);
339
+ return;
340
+ }
341
+ const config = strokeConfig(shape, width);
342
+ const outline = getStroke(
343
+ samples.map((p) => [p.x, p.y, p.pressure]),
344
+ config
345
+ );
346
+ if (outline.length === 0) return;
347
+ ctx.fillStyle = color;
348
+ ctx.beginPath();
349
+ const first = outline[0];
350
+ if (!first) return;
351
+ ctx.moveTo(first[0], first[1]);
352
+ for (let i = 1; i < outline.length; i++) {
353
+ const p = outline[i];
354
+ if (p) ctx.lineTo(p[0], p[1]);
355
+ }
356
+ ctx.closePath();
357
+ ctx.fill();
358
+ }
359
+ paintDashedStroke(ctx, samples, width, color, dash) {
360
+ if (samples.length === 1) {
361
+ const p = samples[0];
362
+ ctx.fillStyle = color;
363
+ ctx.beginPath();
364
+ ctx.arc(p.x, p.y, width / 2, 0, Math.PI * 2);
365
+ ctx.fill();
366
+ return;
367
+ }
368
+ ctx.save();
369
+ ctx.strokeStyle = color;
370
+ ctx.lineWidth = width;
371
+ ctx.lineCap = "round";
372
+ ctx.lineJoin = "round";
373
+ ctx.setLineDash(getLineDash(dash, width));
374
+ ctx.beginPath();
375
+ ctx.moveTo(samples[0].x, samples[0].y);
376
+ for (let i = 1; i < samples.length; i++) {
377
+ const p = samples[i];
378
+ ctx.lineTo(p.x, p.y);
379
+ }
380
+ ctx.stroke();
381
+ ctx.restore();
382
+ }
383
+ };
384
+ var PRESSURE_FLOOR = 0.025;
385
+ var STYLUS_CURVE = (t) => t * 0.65 + Math.sin(t * Math.PI / 2) * 0.35;
386
+ var sineOut = (t) => Math.sin(t * Math.PI / 2);
387
+ var cubicInOut = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
388
+ function remap(value, inRange, outRange, clamp = false) {
389
+ const [lo, hi] = inRange;
390
+ const [outLo, outHi] = outRange;
391
+ const t = (value - lo) / (hi - lo);
392
+ const clamped = clamp ? Math.max(0, Math.min(1, t)) : t;
393
+ return outLo + (outHi - outLo) * clamped;
394
+ }
395
+ function strokeConfig(shape, width) {
396
+ const done = shape.props.isComplete;
397
+ if (shape.props.isPen) {
398
+ return {
399
+ size: 1 + width * 1.2,
400
+ thinning: 0.62,
401
+ streamline: 0.62,
402
+ smoothing: 0.62,
403
+ simulatePressure: false,
404
+ easing: STYLUS_CURVE,
405
+ last: done
406
+ };
407
+ }
408
+ return {
409
+ size: width,
410
+ thinning: 0.5,
411
+ streamline: remap(width, [9, 16], [0.64, 0.74], true),
412
+ smoothing: 0.62,
413
+ simulatePressure: true,
414
+ easing: sineOut,
415
+ last: done
416
+ };
417
+ }
418
+ function flattenSegments(shape) {
419
+ const out = [];
420
+ for (const seg of shape.props.segments) {
421
+ const decoded = decodePoints(seg.path).map((p) => ({
422
+ x: p.x + shape.x,
423
+ y: p.y + shape.y,
424
+ pressure: Math.max(PRESSURE_FLOOR, p.z ?? 0.5)
425
+ }));
426
+ if (seg.type === "free" || decoded.length < 2) {
427
+ out.push(...decoded);
428
+ continue;
429
+ }
430
+ const A = decoded[0];
431
+ const D = decoded[1];
432
+ const len = Math.hypot(D.x - A.x, D.y - A.y);
433
+ if (len === 0) {
434
+ out.push(A);
435
+ continue;
436
+ }
437
+ const ux = (D.x - A.x) / len;
438
+ const uy = (D.y - A.y) / len;
439
+ const nudge = Math.min(1, Math.floor(len / 4));
440
+ const B = { x: A.x + ux * nudge, y: A.y + uy * nudge, pressure: A.pressure };
441
+ const C = { x: D.x - ux * nudge, y: D.y - uy * nudge, pressure: D.pressure };
442
+ const count = Math.max(4, Math.floor(len / 16));
443
+ out.push(A);
444
+ for (let i = 1; i <= count; i++) {
445
+ const t = i / (count + 1);
446
+ const e = cubicInOut(t);
447
+ out.push({
448
+ x: B.x + (C.x - B.x) * e,
449
+ y: B.y + (C.y - B.y) * e,
450
+ pressure: B.pressure + (C.pressure - B.pressure) * e
451
+ });
452
+ }
453
+ out.push(D);
454
+ }
455
+ if (out.length > 0 && !shape.props.isPen) {
456
+ for (const p of out) p.pressure = 0.5;
457
+ }
458
+ return out;
459
+ }
460
+ function getLineDash(dash, width) {
461
+ switch (dash) {
462
+ case "dashed":
463
+ return [width * 2, width * 2];
464
+ case "dotted":
465
+ return [Math.max(1, width * 0.25), width * 2];
466
+ case "solid":
467
+ case "draw":
468
+ default:
469
+ return [];
470
+ }
471
+ }
472
+
473
+ // src/input/inputManager.ts
474
+ var InputManager = class {
475
+ _current = { x: 0, y: 0 };
476
+ _origin = { x: 0, y: 0 };
477
+ // Where pointer_down occured
478
+ _previous = { x: 0, y: 0 };
479
+ // Where pointer was before most recent update
480
+ _isPen = false;
481
+ // Whether input is from a stylus
482
+ _shiftKey = false;
483
+ // Whether shift is pressed
484
+ _ctrlKey = false;
485
+ // Whether ctrl is pressed
486
+ _metaKey = false;
487
+ // Whether meta is pressed
488
+ _isDragging = false;
489
+ // Whether pointer is dragging
490
+ getCurrentPagePoint() {
491
+ return { ...this._current };
492
+ }
493
+ getOriginPagePoint() {
494
+ return { ...this._origin };
495
+ }
496
+ getPreviousPagePoint() {
497
+ return { ...this._previous };
498
+ }
499
+ getIsPen() {
500
+ return this._isPen;
501
+ }
502
+ getShiftKey() {
503
+ return this._shiftKey;
504
+ }
505
+ getCtrlKey() {
506
+ return this._ctrlKey;
507
+ }
508
+ getAccelKey() {
509
+ return this._ctrlKey || this._metaKey;
510
+ }
511
+ getIsDragging() {
512
+ return this._isDragging;
513
+ }
514
+ pointerDown(pageX, pageY, pressure, isPen) {
515
+ this._origin = { x: pageX, y: pageY, z: pressure ?? 0.5 };
516
+ this._current = { ...this._origin };
517
+ this._previous = { ...this._origin };
518
+ this._isDragging = false;
519
+ if (isPen !== void 0) this._isPen = isPen;
520
+ }
521
+ pointerMove(pageX, pageY, pressure, isPen) {
522
+ this._previous = { ...this._current };
523
+ this._current = { x: pageX, y: pageY, z: pressure ?? this._current.z ?? 0.5 };
524
+ this._isPen = isPen ?? this._isPen;
525
+ if (this._origin.x !== this._current.x || this._origin.y !== this._current.y) {
526
+ this._isDragging = true;
527
+ }
528
+ }
529
+ pointerUp() {
530
+ }
531
+ setModifiers(shift, ctrl, meta) {
532
+ this._shiftKey = shift;
533
+ this._ctrlKey = ctrl;
534
+ this._metaKey = meta ?? ctrl;
535
+ }
536
+ getInputs() {
537
+ return {
538
+ currentPagePoint: this.getCurrentPagePoint(),
539
+ originPagePoint: this.getOriginPagePoint(),
540
+ previousPagePoint: this.getPreviousPagePoint(),
541
+ isPen: this._isPen,
542
+ shiftKey: this._shiftKey,
543
+ ctrlKey: this._ctrlKey,
544
+ isDragging: this._isDragging
545
+ };
546
+ }
547
+ };
548
+
549
+ // src/tools/toolManager.ts
550
+ var ToolManager = class {
551
+ currentToolId = "pen";
552
+ currentState = null;
553
+ states = /* @__PURE__ */ new Map();
554
+ toolInitialStateIds = /* @__PURE__ */ new Map();
555
+ registerState(state) {
556
+ const ctor = state.constructor;
557
+ if (this.states.has(ctor.id)) {
558
+ throw new Error(`Tool state '${ctor.id}' is already registered.`);
559
+ }
560
+ this.states.set(ctor.id, state);
561
+ }
562
+ registerTool(id, initialStateId) {
563
+ if (this.toolInitialStateIds.has(id)) {
564
+ throw new Error(`Tool '${id}' is already registered.`);
565
+ }
566
+ this.toolInitialStateIds.set(id, initialStateId);
567
+ }
568
+ hasTool(id) {
569
+ return this.toolInitialStateIds.has(id);
570
+ }
571
+ setCurrentTool(id) {
572
+ const initialStateId = this.toolInitialStateIds.get(id);
573
+ if (!initialStateId) return;
574
+ const nextState = this.states.get(initialStateId);
575
+ if (!nextState) return;
576
+ this.currentState?.onExit?.(void 0, initialStateId);
577
+ this.currentToolId = id;
578
+ this.currentState = nextState;
579
+ this.currentState.onEnter?.();
580
+ }
581
+ getCurrentToolId() {
582
+ return this.currentToolId;
583
+ }
584
+ getCurrentState() {
585
+ return this.currentState;
586
+ }
587
+ transition(stateId, info) {
588
+ const next = this.states.get(stateId);
589
+ if (!next) return;
590
+ this.currentState?.onExit?.(void 0, stateId);
591
+ this.currentState = next;
592
+ this.currentState.onEnter?.(info);
593
+ }
594
+ pointerDown(info) {
595
+ this.currentState?.onPointerDown?.(info);
596
+ }
597
+ pointerMove(info) {
598
+ this.currentState?.onPointerMove?.(info);
599
+ }
600
+ pointerUp() {
601
+ this.currentState?.onPointerUp?.();
602
+ }
603
+ keyDown(info) {
604
+ this.currentState?.onKeyDown?.(info);
605
+ }
606
+ keyUp(info) {
607
+ this.currentState?.onKeyUp?.(info);
608
+ }
609
+ cancel() {
610
+ this.currentState?.onCancel?.();
611
+ }
612
+ interrupt() {
613
+ this.currentState?.onInterrupt?.();
614
+ }
615
+ };
616
+
617
+ // src/tools/pen/states/PenIdleState.ts
618
+ var PenIdleState = class extends StateNode {
619
+ static id = "pen_idle";
620
+ onPointerDown(info) {
621
+ this.ctx.transition("pen_drawing", info);
622
+ }
623
+ };
624
+
625
+ // src/utils/vec.ts
626
+ function dist(a, b) {
627
+ return Math.hypot(b.x - a.x, b.y - a.y);
628
+ }
629
+ function sqDist(a, b) {
630
+ const dx = b.x - a.x;
631
+ const dy = b.y - a.y;
632
+ return dx * dx + dy * dy;
633
+ }
634
+ function withinRadius(a, b, r) {
635
+ return dist(a, b) <= r;
636
+ }
637
+ function toFixed(n, digits = 2) {
638
+ return Number(Number.prototype.toFixed.call(n, digits));
639
+ }
640
+ function roundPt(p) {
641
+ return { x: toFixed(p.x), y: toFixed(p.y), z: p.z != null ? toFixed(p.z) : void 0 };
642
+ }
643
+ function lerpPath(from, to, steps) {
644
+ const result = [];
645
+ for (let i = 0; i <= steps; i++) {
646
+ const t = i / steps;
647
+ result.push({
648
+ x: toFixed(from.x + (to.x - from.x) * t),
649
+ y: toFixed(from.y + (to.y - from.y) * t),
650
+ z: from.z != null && to.z != null ? toFixed(from.z + (to.z - from.z) * t) : to.z ?? from.z
651
+ });
652
+ }
653
+ return result;
654
+ }
655
+ function quantizeAngle(rad, divisions) {
656
+ const step = Math.PI * 2 / divisions;
657
+ return Math.round(rad / step) * step;
658
+ }
659
+ function rotateAround(pt, origin, angle) {
660
+ const c = Math.cos(angle);
661
+ const s = Math.sin(angle);
662
+ const rx = pt.x - origin.x;
663
+ const ry = pt.y - origin.y;
664
+ return {
665
+ x: origin.x + rx * c - ry * s,
666
+ y: origin.y + rx * s + ry * c,
667
+ z: pt.z
668
+ };
669
+ }
670
+ function tail(arr) {
671
+ return arr[arr.length - 1];
672
+ }
673
+
674
+ // src/tools/pen/states/PenDrawingState.ts
675
+ var PenDrawingState = class extends StateNode {
676
+ static id = "pen_drawing";
677
+ _startInfo = { point: { x: 0, y: 0, z: 0.5 } };
678
+ _target;
679
+ _isPenDevice = false;
680
+ _hasPressure = false;
681
+ _phase = "free";
682
+ _extending = false;
683
+ _anchor = { x: 0, y: 0 };
684
+ _pendingAnchor = null;
685
+ _lastSample = { x: 0, y: 0 };
686
+ _shouldMerge = false;
687
+ _pathLen = 0;
688
+ _activePts = [];
689
+ onEnter(info) {
690
+ this._startInfo = info ?? { point: { x: 0, y: 0, z: 0.5 } };
691
+ this._lastSample = { ...this.editor.input.getCurrentPagePoint() };
692
+ this.beginStroke();
693
+ }
694
+ onPointerMove() {
695
+ const inputs = this.editor.input;
696
+ const penActive = inputs.getIsPen();
697
+ if (this._isPenDevice && !penActive) {
698
+ this.beginStroke();
699
+ return;
700
+ }
701
+ if (this._hasPressure) {
702
+ const cur = inputs.getCurrentPagePoint();
703
+ const threshold = 1 / this.editor.getZoomLevel();
704
+ if (dist(cur, this._lastSample) >= threshold) {
705
+ this._lastSample = { ...cur };
706
+ this._shouldMerge = false;
707
+ } else {
708
+ this._shouldMerge = true;
709
+ }
710
+ } else {
711
+ this._shouldMerge = false;
712
+ }
713
+ this.advanceStroke();
714
+ }
715
+ // Shift: start a new straight segment
716
+ // Maybe add a specific key for snapping or turning drawing into a proper shape?
717
+ onKeyDown(info) {
718
+ if (info?.key === "Shift") {
719
+ switch (this._phase) {
720
+ case "free":
721
+ this._phase = "starting_straight";
722
+ this._pendingAnchor = { ...this.editor.input.getCurrentPagePoint() };
723
+ break;
724
+ case "starting_free":
725
+ this._phase = "starting_straight";
726
+ break;
727
+ }
728
+ }
729
+ this.advanceStroke();
730
+ }
731
+ onKeyUp(info) {
732
+ if (info?.key === "Shift") {
733
+ switch (this._phase) {
734
+ case "straight":
735
+ this._phase = "starting_free";
736
+ this._pendingAnchor = { ...this.editor.input.getCurrentPagePoint() };
737
+ break;
738
+ case "starting_straight":
739
+ this._pendingAnchor = null;
740
+ this._phase = "free";
741
+ break;
742
+ }
743
+ }
744
+ this.advanceStroke();
745
+ }
746
+ onPointerUp() {
747
+ this.endStroke();
748
+ }
749
+ onCancel() {
750
+ this.ctx.transition("pen_idle", this._startInfo);
751
+ }
752
+ onInterrupt() {
753
+ if (!this.editor.input.getIsDragging()) {
754
+ this.ctx.transition("pen_idle", this._startInfo);
755
+ }
756
+ }
757
+ canClosePath() {
758
+ return true;
759
+ }
760
+ detectClosure(segments, size, scale) {
761
+ if (!this.canClosePath() || segments.length === 0) return false;
762
+ const w = STROKE_WIDTHS[size];
763
+ const first = decodeFirstPoint(segments[0].path);
764
+ const lastSeg = segments[segments.length - 1];
765
+ const end = decodeLastPoint(lastSeg.path);
766
+ if (!first || !end) return false;
767
+ if (first.x === end.x && first.y === end.y) return false;
768
+ if (this._pathLen <= w * 4 * scale) return false;
769
+ return withinRadius(first, end, w * 2 * scale);
770
+ }
771
+ measurePath(segments) {
772
+ let sum = 0;
773
+ for (const seg of segments) {
774
+ const pts = decodePoints(seg.path);
775
+ for (let i = 0; i < pts.length - 1; i++) {
776
+ sum += sqDist(pts[i], pts[i + 1]);
777
+ }
778
+ }
779
+ return Math.sqrt(sum);
780
+ }
781
+ // Start a new shape, when user starts a stroke
782
+ beginStroke() {
783
+ const inputs = this.editor.input;
784
+ const origin = inputs.getOriginPagePoint();
785
+ const penActive = inputs.getIsPen();
786
+ const z = this._startInfo?.point?.z ?? 0.5;
787
+ this._isPenDevice = penActive;
788
+ this._hasPressure = penActive && z !== 0 || z > 0 && z < 0.5 || z > 0.5 && z < 1;
789
+ const pressure = this._hasPressure ? toFixed(z * 1.25) : 0.5;
790
+ this._phase = inputs.getShiftKey() ? "straight" : "free";
791
+ this._extending = false;
792
+ this._lastSample = { ...origin };
793
+ const sorted = this.editor.store.getCurrentPageShapesSorted();
794
+ const prev = tail(sorted);
795
+ const existing = prev?.type === "draw" ? prev : void 0;
796
+ this._target = existing;
797
+ if (existing && this._phase === "straight") {
798
+ const prevSeg = tail(existing.props.segments);
799
+ if (!prevSeg) {
800
+ this.spawnShape(origin, pressure);
801
+ return;
802
+ }
803
+ const prevEnd = decodeLastPoint(prevSeg.path);
804
+ if (!prevEnd) {
805
+ this.spawnShape(origin, pressure);
806
+ return;
807
+ }
808
+ this._extending = true;
809
+ const local = this.editor.getPointInShapeSpace(existing, origin);
810
+ const localPt = { x: toFixed(local.x), y: toFixed(local.y), z: pressure };
811
+ const newSeg = {
812
+ type: "straight",
813
+ path: encodePoints([
814
+ { x: prevEnd.x, y: prevEnd.y, z: pressure },
815
+ localPt
816
+ ])
817
+ };
818
+ this._anchor = {
819
+ x: existing.x + prevEnd.x,
820
+ y: existing.y + prevEnd.y
821
+ };
822
+ this._pendingAnchor = null;
823
+ const segs = [...existing.props.segments, newSeg];
824
+ this._pathLen = this.measurePath(segs);
825
+ this.editor.updateShapes([
826
+ {
827
+ id: existing.id,
828
+ type: "draw",
829
+ props: {
830
+ segments: segs,
831
+ isClosed: this.detectClosure(segs, existing.props.size, existing.props.scale)
832
+ }
833
+ }
834
+ ]);
835
+ return;
836
+ }
837
+ this.spawnShape(origin, pressure);
838
+ }
839
+ // Create a new shape, when we need a new drawing shape
840
+ spawnShape(originPt, pressure) {
841
+ this._anchor = { ...originPt };
842
+ const drawStyle = this.editor.getCurrentDrawStyle();
843
+ const id = this.editor.createShapeId();
844
+ const firstPt = { x: 0, y: 0, z: pressure };
845
+ this._activePts = [firstPt];
846
+ this.editor.createShape({
847
+ id,
848
+ type: "draw",
849
+ x: originPt.x,
850
+ y: originPt.y,
851
+ props: {
852
+ color: drawStyle.color,
853
+ dash: drawStyle.dash,
854
+ size: drawStyle.size,
855
+ scale: 1,
856
+ isPen: this._hasPressure,
857
+ isComplete: false,
858
+ segments: [
859
+ {
860
+ type: this._phase === "straight" ? "straight" : "free",
861
+ path: encodePoints([firstPt])
862
+ }
863
+ ]
864
+ }
865
+ });
866
+ const shape = this.editor.getShape(id);
867
+ if (!shape) {
868
+ this.ctx.transition("pen_idle", this._startInfo);
869
+ return;
870
+ }
871
+ this._pathLen = 0;
872
+ this._target = shape;
873
+ }
874
+ // Update the drawing shape, while user is drawing
875
+ advanceStroke() {
876
+ const target = this._target;
877
+ const inputs = this.editor.input;
878
+ if (!target) return;
879
+ const shape = this.editor.getShape(target.id);
880
+ if (!shape) return;
881
+ const { id, props: { size, scale } } = target;
882
+ const { segments } = shape.props;
883
+ const curPt = inputs.getCurrentPagePoint();
884
+ const local = this.editor.getPointInShapeSpace(shape, curPt);
885
+ const pressure = this._hasPressure ? toFixed((curPt.z ?? 0.5) * 1.25) : 0.5;
886
+ const pt = { x: toFixed(local.x), y: toFixed(local.y), z: pressure };
887
+ switch (this._phase) {
888
+ case "starting_straight": {
889
+ const pending = this._pendingAnchor;
890
+ if (!pending) break;
891
+ if (sqDist(pending, inputs.getCurrentPagePoint()) <= this.editor.options.dragDistanceSquared) break;
892
+ this._anchor = { ...pending };
893
+ this._pendingAnchor = null;
894
+ this._phase = "straight";
895
+ const prevSeg = tail(segments);
896
+ if (!prevSeg) break;
897
+ const prevEnd = decodeLastPoint(prevSeg.path);
898
+ if (!prevEnd) break;
899
+ const anchorLocal = this.editor.getPointInShapeSpace(shape, this._anchor);
900
+ const anchorPt = roundPt(anchorLocal);
901
+ const seg = {
902
+ type: "straight",
903
+ path: encodePoints([prevEnd, { ...anchorPt, z: pressure }])
904
+ };
905
+ this.editor.updateShapes([
906
+ {
907
+ id,
908
+ type: "draw",
909
+ props: {
910
+ segments: [...segments, seg],
911
+ isClosed: this.detectClosure(segments, size, scale)
912
+ }
913
+ }
914
+ ]);
915
+ break;
916
+ }
917
+ case "starting_free": {
918
+ const pending = this._pendingAnchor;
919
+ if (!pending) break;
920
+ if (sqDist(pending, inputs.getCurrentPagePoint()) <= this.editor.options.dragDistanceSquared) break;
921
+ this._anchor = { ...pending };
922
+ this._pendingAnchor = null;
923
+ this._phase = "free";
924
+ const prevSeg = tail(segments);
925
+ if (!prevSeg) break;
926
+ const prevEnd = decodeLastPoint(prevSeg.path);
927
+ if (!prevEnd) break;
928
+ const interpolated = lerpPath(prevEnd, pt, 6);
929
+ this._activePts = interpolated;
930
+ const freeSeg = {
931
+ type: "free",
932
+ path: encodePoints(interpolated)
933
+ };
934
+ const allSegs = [...segments, freeSeg];
935
+ this._pathLen = this.measurePath(allSegs);
936
+ this.editor.updateShapes([
937
+ {
938
+ id,
939
+ type: "draw",
940
+ props: {
941
+ segments: allSegs,
942
+ isClosed: this.detectClosure(allSegs, size, scale)
943
+ }
944
+ }
945
+ ]);
946
+ break;
947
+ }
948
+ case "straight": {
949
+ const updated = segments.slice();
950
+ const lastSeg = updated[updated.length - 1];
951
+ if (!lastSeg) break;
952
+ const anchorPage = this._anchor;
953
+ const current = inputs.getCurrentPagePoint();
954
+ const shouldSnap = !this._extending || inputs.getIsDragging();
955
+ if (this._extending && inputs.getIsDragging()) {
956
+ this._extending = false;
957
+ }
958
+ let pagePt;
959
+ if (shouldSnap) {
960
+ const angle = Math.atan2(
961
+ current.y - anchorPage.y,
962
+ current.x - anchorPage.x
963
+ );
964
+ const snapped = quantizeAngle(angle, 24);
965
+ const diff = snapped - angle;
966
+ pagePt = rotateAround(current, anchorPage, diff);
967
+ } else {
968
+ pagePt = { ...current };
969
+ }
970
+ const localPt = this.editor.getPointInShapeSpace(shape, pagePt);
971
+ const fixedPt = roundPt(localPt);
972
+ const segStart = decodeFirstPoint(lastSeg.path);
973
+ if (segStart) {
974
+ this._pathLen += dist(segStart, fixedPt);
975
+ }
976
+ updated[updated.length - 1] = {
977
+ ...lastSeg,
978
+ type: "straight",
979
+ path: encodePoints([segStart ?? fixedPt, { ...fixedPt, z: pressure }])
980
+ };
981
+ this.editor.updateShapes([
982
+ {
983
+ id,
984
+ type: "draw",
985
+ props: {
986
+ segments: updated,
987
+ isClosed: this.detectClosure(segments, size, scale)
988
+ }
989
+ }
990
+ ]);
991
+ break;
992
+ }
993
+ case "free": {
994
+ const cached = this._activePts;
995
+ if (cached.length && this._shouldMerge) {
996
+ const last = cached[cached.length - 1];
997
+ last.x = pt.x;
998
+ last.y = pt.y;
999
+ last.z = last.z != null ? Math.max(last.z, pt.z ?? 0) : pt.z;
1000
+ } else {
1001
+ this._pathLen += cached.length ? dist(cached[cached.length - 1], pt) : 0;
1002
+ cached.push({ x: pt.x, y: pt.y, z: pt.z });
1003
+ }
1004
+ const updated = segments.slice();
1005
+ const lastSeg = updated[updated.length - 1];
1006
+ updated[updated.length - 1] = {
1007
+ ...lastSeg,
1008
+ path: encodePoints(cached)
1009
+ };
1010
+ if (this._pathLen < STROKE_WIDTHS[shape.props.size] * 4) {
1011
+ this._pathLen = this.measurePath(updated);
1012
+ }
1013
+ this.editor.updateShapes([
1014
+ {
1015
+ id,
1016
+ type: "draw",
1017
+ props: {
1018
+ segments: updated,
1019
+ isClosed: this.detectClosure(updated, size, scale)
1020
+ }
1021
+ }
1022
+ ]);
1023
+ if (cached.length > MAX_POINTS_PER_SHAPE) {
1024
+ this.editor.updateShapes([{ id, type: "draw", props: { isComplete: true } }]);
1025
+ const newId = this.editor.createShapeId();
1026
+ const curPage = inputs.getCurrentPagePoint();
1027
+ const firstPt = {
1028
+ x: 0,
1029
+ y: 0,
1030
+ z: this._hasPressure ? toFixed((curPage.z ?? 0.5) * 1.25) : 0.5
1031
+ };
1032
+ this._activePts = [firstPt];
1033
+ this.editor.createShape({
1034
+ id: newId,
1035
+ type: "draw",
1036
+ x: curPage.x,
1037
+ y: curPage.y,
1038
+ props: {
1039
+ color: shape.props.color,
1040
+ dash: shape.props.dash,
1041
+ size: shape.props.size,
1042
+ scale: shape.props.scale,
1043
+ isPen: this._hasPressure,
1044
+ isComplete: false,
1045
+ segments: [{ type: "free", path: encodePoints([firstPt]) }]
1046
+ }
1047
+ });
1048
+ const created = this.editor.getShape(newId);
1049
+ if (created) {
1050
+ this._target = created;
1051
+ this._lastSample = { ...curPage };
1052
+ this._pathLen = 0;
1053
+ }
1054
+ }
1055
+ break;
1056
+ }
1057
+ }
1058
+ }
1059
+ endStroke() {
1060
+ if (!this._target) return;
1061
+ this.editor.updateShapes([
1062
+ { id: this._target.id, type: "draw", props: { isComplete: true } }
1063
+ ]);
1064
+ this.ctx.transition("pen_idle");
1065
+ }
1066
+ };
1067
+
1068
+ // src/tools/eraser/states/EraserIdleState.ts
1069
+ var EraserIdleState = class extends StateNode {
1070
+ static id = "eraser_idle";
1071
+ onPointerDown(info) {
1072
+ this.ctx.transition("eraser_pointing", info);
1073
+ }
1074
+ };
1075
+
1076
+ // src/utils/geometry.ts
1077
+ function boundsOf(points) {
1078
+ if (points.length === 0) return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
1079
+ let minX = points[0].x;
1080
+ let minY = points[0].y;
1081
+ let maxX = minX;
1082
+ let maxY = minY;
1083
+ for (const p of points) {
1084
+ if (p.x < minX) minX = p.x;
1085
+ if (p.y < minY) minY = p.y;
1086
+ if (p.x > maxX) maxX = p.x;
1087
+ if (p.y > maxY) maxY = p.y;
1088
+ }
1089
+ return { minX, minY, maxX, maxY };
1090
+ }
1091
+ function padBounds(b, amount) {
1092
+ return {
1093
+ minX: b.minX - amount,
1094
+ minY: b.minY - amount,
1095
+ maxX: b.maxX + amount,
1096
+ maxY: b.maxY + amount
1097
+ };
1098
+ }
1099
+ function sqDistance(ax, ay, bx, by) {
1100
+ const dx = bx - ax;
1101
+ const dy = by - ay;
1102
+ return dx * dx + dy * dy;
1103
+ }
1104
+ function distance(ax, ay, bx, by) {
1105
+ return Math.sqrt(sqDistance(ax, ay, bx, by));
1106
+ }
1107
+ function closestOnSegment(ax, ay, bx, by, px, py) {
1108
+ const dx = bx - ax;
1109
+ const dy = by - ay;
1110
+ const lenSq = dx * dx + dy * dy;
1111
+ if (lenSq === 0) return { x: ax, y: ay };
1112
+ const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lenSq));
1113
+ return { x: ax + t * dx, y: ay + t * dy };
1114
+ }
1115
+ function segmentTouchesPolyline(polyline, ax, ay, bx, by, margin) {
1116
+ for (let i = 0; i < polyline.length - 1; i++) {
1117
+ const p = polyline[i];
1118
+ const q = polyline[i + 1];
1119
+ const n1 = closestOnSegment(p.x, p.y, q.x, q.y, ax, ay);
1120
+ if (distance(n1.x, n1.y, ax, ay) <= margin) return true;
1121
+ const n2 = closestOnSegment(p.x, p.y, q.x, q.y, bx, by);
1122
+ if (distance(n2.x, n2.y, bx, by) <= margin) return true;
1123
+ const n3 = closestOnSegment(ax, ay, bx, by, p.x, p.y);
1124
+ if (distance(n3.x, n3.y, p.x, p.y) <= margin) return true;
1125
+ }
1126
+ if (polyline.length === 1) {
1127
+ const p = polyline[0];
1128
+ return distance(p.x, p.y, ax, ay) <= margin || distance(p.x, p.y, bx, by) <= margin;
1129
+ }
1130
+ return false;
1131
+ }
1132
+ function minDistanceToPolyline(px, py, polyline) {
1133
+ if (polyline.length === 0) return Infinity;
1134
+ if (polyline.length === 1) return distance(px, py, polyline[0].x, polyline[0].y);
1135
+ let best = Infinity;
1136
+ for (let i = 0; i < polyline.length - 1; i++) {
1137
+ const a = polyline[i];
1138
+ const b = polyline[i + 1];
1139
+ const n = closestOnSegment(a.x, a.y, b.x, b.y, px, py);
1140
+ const d = distance(n.x, n.y, px, py);
1141
+ if (d < best) best = d;
1142
+ }
1143
+ return best;
1144
+ }
1145
+
1146
+ // src/tools/eraser/eraserHitTest.ts
1147
+ var ERASER_MARGIN = 4;
1148
+ function shapePagePoints(shape) {
1149
+ return decodePathToPoints(shape.props.segments, shape.x, shape.y);
1150
+ }
1151
+ function pointHitsShape(shape, pageX, pageY, margin) {
1152
+ const pts = shapePagePoints(shape);
1153
+ if (pts.length === 0) return false;
1154
+ const strokeMargin = margin + (STROKE_WIDTHS[shape.props.size] ?? 3.5) * shape.props.scale;
1155
+ return minDistanceToPolyline(pageX, pageY, pts) <= strokeMargin;
1156
+ }
1157
+ function segmentHitsShape(shape, ax, ay, bx, by, margin) {
1158
+ const pts = shapePagePoints(shape);
1159
+ if (pts.length === 0) return false;
1160
+ const strokeMargin = margin + (STROKE_WIDTHS[shape.props.size] ?? 3.5) * shape.props.scale;
1161
+ return segmentTouchesPolyline(pts, ax, ay, bx, by, strokeMargin);
1162
+ }
1163
+
1164
+ // src/tools/eraser/states/EraserPointingState.ts
1165
+ var EraserPointingState = class extends StateNode {
1166
+ static id = "eraser_pointing";
1167
+ onEnter(_info) {
1168
+ const zoom = this.editor.getZoomLevel();
1169
+ const tolerance = ERASER_MARGIN / zoom;
1170
+ const pt = this.editor.input.getCurrentPagePoint();
1171
+ const allShapes = this.editor.store.getCurrentPageRenderingShapesSorted();
1172
+ const hits = [];
1173
+ for (const shape of allShapes) {
1174
+ if (shape.type !== "draw") continue;
1175
+ if (pointHitsShape(shape, pt.x, pt.y, tolerance)) {
1176
+ hits.push(shape.id);
1177
+ }
1178
+ }
1179
+ this.editor.setErasingShapes(hits);
1180
+ }
1181
+ onPointerMove(info) {
1182
+ if (this.editor.input.getIsDragging()) {
1183
+ this.ctx.transition("eraser_erasing", info);
1184
+ }
1185
+ }
1186
+ onPointerUp() {
1187
+ this.finish();
1188
+ }
1189
+ onExit(_info, to) {
1190
+ if (to !== "eraser_erasing") {
1191
+ this.editor.setErasingShapes([]);
1192
+ }
1193
+ }
1194
+ onCancel() {
1195
+ this.editor.setErasingShapes([]);
1196
+ this.ctx.transition("eraser_idle");
1197
+ }
1198
+ finish() {
1199
+ const ids = this.editor.getErasingShapeIds();
1200
+ if (ids.length > 0) {
1201
+ this.editor.store.deleteShapes(ids);
1202
+ this.editor.setErasingShapes([]);
1203
+ }
1204
+ this.ctx.transition("eraser_idle");
1205
+ }
1206
+ };
1207
+
1208
+ // src/tools/eraser/states/EraserErasingState.ts
1209
+ var EraserErasingState = class extends StateNode {
1210
+ static id = "eraser_erasing";
1211
+ _marked = [];
1212
+ onEnter(_info) {
1213
+ this._marked = [...this.editor.getErasingShapeIds()];
1214
+ this.sweep();
1215
+ }
1216
+ onPointerMove() {
1217
+ this.sweep();
1218
+ }
1219
+ onPointerUp() {
1220
+ this.finish();
1221
+ }
1222
+ onExit() {
1223
+ this.editor.setErasingShapes([]);
1224
+ }
1225
+ onCancel() {
1226
+ this.ctx.transition("eraser_idle");
1227
+ }
1228
+ sweep() {
1229
+ const zoom = this.editor.getZoomLevel();
1230
+ const tolerance = ERASER_MARGIN / zoom;
1231
+ const cur = this.editor.input.getCurrentPagePoint();
1232
+ const prev = this.editor.input.getPreviousPagePoint();
1233
+ const hitIds = new Set(this.editor.getErasingShapeIds());
1234
+ const sweepArea = padBounds(boundsOf([prev, cur]), tolerance);
1235
+ const nearby = this.editor.store.getShapeIdsInBounds(sweepArea);
1236
+ const candidates = this.editor.store.getCurrentPageRenderingShapesSorted().filter((s) => nearby.has(s.id));
1237
+ for (const shape of candidates) {
1238
+ if (shape.type !== "draw") continue;
1239
+ if (segmentHitsShape(shape, prev.x, prev.y, cur.x, cur.y, tolerance)) {
1240
+ hitIds.add(shape.id);
1241
+ }
1242
+ }
1243
+ this._marked = [...hitIds];
1244
+ this.editor.setErasingShapes(this._marked);
1245
+ }
1246
+ finish() {
1247
+ const ids = this.editor.getErasingShapeIds();
1248
+ if (ids.length > 0) {
1249
+ this.editor.store.deleteShapes(ids);
1250
+ }
1251
+ this.editor.setErasingShapes([]);
1252
+ this._marked = [];
1253
+ this.ctx.transition("eraser_idle");
1254
+ }
1255
+ };
1256
+
1257
+ // src/tools/select/states/SelectIdleState.ts
1258
+ var SelectIdleState = class extends StateNode {
1259
+ static id = "select_idle";
1260
+ };
1261
+
1262
+ // src/tools/hand/states/HandIdleState.ts
1263
+ var HandIdleState = class extends StateNode {
1264
+ static id = "hand_idle";
1265
+ onPointerDown(info) {
1266
+ this.ctx.transition("hand_dragging", info);
1267
+ }
1268
+ };
1269
+
1270
+ // src/tools/hand/states/HandDraggingState.ts
1271
+ var HandDraggingState = class extends StateNode {
1272
+ static id = "hand_dragging";
1273
+ onPointerMove(info) {
1274
+ const move = info ?? {};
1275
+ const dx = move.screenDeltaX ?? 0;
1276
+ const dy = move.screenDeltaY ?? 0;
1277
+ if (dx === 0 && dy === 0) return;
1278
+ this.editor.panBy(dx, dy);
1279
+ }
1280
+ onPointerUp() {
1281
+ this.ctx.transition("hand_idle");
1282
+ }
1283
+ onCancel() {
1284
+ this.ctx.transition("hand_idle");
1285
+ }
1286
+ onInterrupt() {
1287
+ this.ctx.transition("hand_idle");
1288
+ }
1289
+ };
1290
+
1291
+ // src/editor/Editor.ts
1292
+ var shapeIdCounter = 0;
1293
+ function createShapeId() {
1294
+ return `shape:${String(++shapeIdCounter).padStart(6, "0")}`;
1295
+ }
1296
+ var Editor = class {
1297
+ store = new DocumentStore();
1298
+ input = new InputManager();
1299
+ tools = new ToolManager();
1300
+ renderer = new CanvasRenderer();
1301
+ viewport = createViewport();
1302
+ options;
1303
+ // Default draw style
1304
+ drawStyle = {
1305
+ color: "black",
1306
+ dash: "draw",
1307
+ size: "m"
1308
+ };
1309
+ toolStateContext;
1310
+ // Creates a new editor instance with the given options (with defaults if not provided)
1311
+ constructor(opts = {}) {
1312
+ this.options = { dragDistanceSquared: opts.dragDistanceSquared ?? DRAG_DISTANCE_SQUARED };
1313
+ this.toolStateContext = {
1314
+ transition: (id, info) => this.tools.transition(id, info)
1315
+ };
1316
+ for (const defaultTool of this.getDefaultToolDefinitions()) {
1317
+ this.registerToolDefinition(defaultTool);
1318
+ }
1319
+ for (const customTool of opts.toolDefinitions ?? []) {
1320
+ this.registerToolDefinition(customTool);
1321
+ }
1322
+ this.tools.setCurrentTool(opts.initialToolId ?? "pen");
1323
+ }
1324
+ registerToolDefinition(toolDefinition) {
1325
+ for (const stateConstructor of toolDefinition.stateConstructors) {
1326
+ this.tools.registerState(new stateConstructor(this.toolStateContext, this));
1327
+ }
1328
+ this.tools.registerTool(toolDefinition.id, toolDefinition.initialStateId);
1329
+ }
1330
+ getDefaultToolDefinitions() {
1331
+ return [
1332
+ { id: "pen", initialStateId: PenIdleState.id, stateConstructors: [PenIdleState, PenDrawingState] },
1333
+ { id: "eraser", initialStateId: EraserIdleState.id, stateConstructors: [EraserIdleState, EraserPointingState, EraserErasingState] },
1334
+ { id: "select", initialStateId: SelectIdleState.id, stateConstructors: [SelectIdleState] },
1335
+ { id: "hand", initialStateId: HandIdleState.id, stateConstructors: [HandIdleState, HandDraggingState] }
1336
+ ];
1337
+ }
1338
+ createShapeId() {
1339
+ return createShapeId();
1340
+ }
1341
+ getZoomLevel() {
1342
+ return this.viewport.zoom;
1343
+ }
1344
+ getShape(id) {
1345
+ return this.store.getShape(id);
1346
+ }
1347
+ createShape(shape) {
1348
+ this.store.createShape(shape);
1349
+ }
1350
+ updateShapes(partials) {
1351
+ for (const p of partials) {
1352
+ const existing = this.store.getShape(p.id);
1353
+ if (existing && p.props) {
1354
+ this.store.updateShape(p.id, { props: { ...existing.props, ...p.props } });
1355
+ }
1356
+ }
1357
+ }
1358
+ // Page point to shape local point (for draw shapes: subtract shape pos)
1359
+ getPointInShapeSpace(shape, pagePoint) {
1360
+ return {
1361
+ x: pagePoint.x - shape.x,
1362
+ y: pagePoint.y - shape.y,
1363
+ z: pagePoint.z
1364
+ };
1365
+ }
1366
+ getCurrentPageShapes() {
1367
+ return this.store.getCurrentPageShapes();
1368
+ }
1369
+ getCurrentPageShapesSorted() {
1370
+ return this.store.getCurrentPageShapesSorted();
1371
+ }
1372
+ getCurrentPageRenderingShapesSorted() {
1373
+ return this.store.getCurrentPageRenderingShapesSorted();
1374
+ }
1375
+ getErasingShapeIds() {
1376
+ return this.store.getErasingShapeIds();
1377
+ }
1378
+ setErasingShapes(ids) {
1379
+ this.store.setErasingShapes(ids);
1380
+ }
1381
+ setCurrentTool(id) {
1382
+ this.tools.setCurrentTool(id);
1383
+ }
1384
+ getCurrentToolId() {
1385
+ return this.tools.getCurrentToolId();
1386
+ }
1387
+ getCurrentDrawStyle() {
1388
+ return { ...this.drawStyle };
1389
+ }
1390
+ setCurrentDrawStyle(partial) {
1391
+ this.drawStyle = { ...this.drawStyle, ...partial };
1392
+ }
1393
+ panBy(dx, dy) {
1394
+ this.viewport.x += dx;
1395
+ this.viewport.y += dy;
1396
+ }
1397
+ // Convert screen coords to page coords
1398
+ screenToPage(screenX, screenY) {
1399
+ return screenToPage(this.viewport, screenX, screenY);
1400
+ }
1401
+ // Render current page to 2d canvas context
1402
+ render(ctx) {
1403
+ const shapes = this.getCurrentPageShapesSorted();
1404
+ const erasingIds = new Set(this.getErasingShapeIds());
1405
+ const visible = shapes.filter((s) => !erasingIds.has(s.id));
1406
+ this.renderer.render(ctx, this.viewport, visible);
1407
+ }
1408
+ };
1409
+
1410
+ // src/tools/select/selectHelpers.ts
1411
+ function isSelectTool(tool) {
1412
+ return tool === "select";
1413
+ }
1414
+ function getShapeBounds2(shape) {
1415
+ const points = decodePathToPoints(shape.props.segments, shape.x, shape.y);
1416
+ if (points.length === 0) {
1417
+ return { minX: shape.x, minY: shape.y, maxX: shape.x, maxY: shape.y };
1418
+ }
1419
+ let minX = points[0].x;
1420
+ let minY = points[0].y;
1421
+ let maxX = minX;
1422
+ let maxY = minY;
1423
+ for (const p of points) {
1424
+ if (p.x < minX) minX = p.x;
1425
+ if (p.y < minY) minY = p.y;
1426
+ if (p.x > maxX) maxX = p.x;
1427
+ if (p.y > maxY) maxY = p.y;
1428
+ }
1429
+ const stroke = (STROKE_WIDTHS[shape.props.size] ?? 3.5) * shape.props.scale;
1430
+ return { minX: minX - stroke, minY: minY - stroke, maxX: maxX + stroke, maxY: maxY + stroke };
1431
+ }
1432
+ function normalizeSelectionBounds(a, b) {
1433
+ return {
1434
+ minX: Math.min(a.x, b.x),
1435
+ minY: Math.min(a.y, b.y),
1436
+ maxX: Math.max(a.x, b.x),
1437
+ maxY: Math.max(a.y, b.y)
1438
+ };
1439
+ }
1440
+ function boundsContainPoint(bounds, x, y) {
1441
+ return x >= bounds.minX && x <= bounds.maxX && y >= bounds.minY && y <= bounds.maxY;
1442
+ }
1443
+ function boundsIntersect(a, b) {
1444
+ return a.maxX >= b.minX && a.minX <= b.maxX && a.maxY >= b.minY && a.minY <= b.maxY;
1445
+ }
1446
+ function rotatePoint(point, center, radians) {
1447
+ const dx = point.x - center.x;
1448
+ const dy = point.y - center.y;
1449
+ const c = Math.cos(radians);
1450
+ const s = Math.sin(radians);
1451
+ return {
1452
+ x: center.x + dx * c - dy * s,
1453
+ y: center.y + dx * s + dy * c
1454
+ };
1455
+ }
1456
+ function buildTransformSnapshots(editor, ids) {
1457
+ const snapshots = /* @__PURE__ */ new Map();
1458
+ for (const id of ids) {
1459
+ const shape = editor.getShape(id);
1460
+ if (!shape || shape.type !== "draw") continue;
1461
+ snapshots.set(id, {
1462
+ x: shape.x,
1463
+ y: shape.y,
1464
+ segments: shape.props.segments.map((seg) => ({
1465
+ type: seg.type,
1466
+ points: decodePoints(seg.path)
1467
+ }))
1468
+ });
1469
+ }
1470
+ return snapshots;
1471
+ }
1472
+ function buildStartPositions(editor, ids) {
1473
+ const positions = /* @__PURE__ */ new Map();
1474
+ for (const id of ids) {
1475
+ const shape = editor.getShape(id);
1476
+ if (!shape) continue;
1477
+ positions.set(id, { x: shape.x, y: shape.y });
1478
+ }
1479
+ return positions;
1480
+ }
1481
+ function getTopShapeAtPoint(editor, point) {
1482
+ const margin = 6 / editor.viewport.zoom;
1483
+ const shapes = editor.getCurrentPageRenderingShapesSorted();
1484
+ for (const shape of shapes) {
1485
+ if (shape.type !== "draw") continue;
1486
+ const b = getShapeBounds2(shape);
1487
+ if (boundsContainPoint(
1488
+ {
1489
+ minX: b.minX - margin,
1490
+ minY: b.minY - margin,
1491
+ maxX: b.maxX + margin,
1492
+ maxY: b.maxY + margin
1493
+ },
1494
+ point.x,
1495
+ point.y
1496
+ )) {
1497
+ return shape;
1498
+ }
1499
+ }
1500
+ return null;
1501
+ }
1502
+ function getSelectionBoundsPage(editor, ids) {
1503
+ if (ids.length === 0) return null;
1504
+ let union = null;
1505
+ for (const id of ids) {
1506
+ const shape = editor.getShape(id);
1507
+ if (!shape || shape.type !== "draw") continue;
1508
+ const b = getShapeBounds2(shape);
1509
+ union = union ? {
1510
+ minX: Math.min(union.minX, b.minX),
1511
+ minY: Math.min(union.minY, b.minY),
1512
+ maxX: Math.max(union.maxX, b.maxX),
1513
+ maxY: Math.max(union.maxY, b.maxY)
1514
+ } : b;
1515
+ }
1516
+ return union;
1517
+ }
1518
+ function getShapesInBounds(editor, bounds) {
1519
+ return editor.getCurrentPageShapesSorted().filter((shape) => shape.type === "draw").filter((shape) => boundsIntersect(getShapeBounds2(shape), bounds)).map((shape) => shape.id);
1520
+ }
1521
+ function applyMove(editor, startPositions, deltaX, deltaY) {
1522
+ for (const [id, start] of startPositions) {
1523
+ editor.store.updateShape(id, {
1524
+ x: start.x + deltaX,
1525
+ y: start.y + deltaY
1526
+ });
1527
+ }
1528
+ }
1529
+ function applyRotation(editor, startShapes, center, delta) {
1530
+ for (const [id, snapshot] of startShapes) {
1531
+ const shape = editor.getShape(id);
1532
+ if (!shape || shape.type !== "draw") continue;
1533
+ const rotatedOrigin = rotatePoint({ x: snapshot.x, y: snapshot.y }, center, delta);
1534
+ const segments = snapshot.segments.map((segment) => ({
1535
+ type: segment.type,
1536
+ path: encodePoints(
1537
+ segment.points.map((pt) => {
1538
+ const absolute = { x: snapshot.x + pt.x, y: snapshot.y + pt.y };
1539
+ const rotated = rotatePoint(absolute, center, delta);
1540
+ return {
1541
+ x: rotated.x - rotatedOrigin.x,
1542
+ y: rotated.y - rotatedOrigin.y,
1543
+ z: pt.z
1544
+ };
1545
+ })
1546
+ )
1547
+ }));
1548
+ editor.store.updateShape(id, {
1549
+ x: rotatedOrigin.x,
1550
+ y: rotatedOrigin.y,
1551
+ props: { ...shape.props, segments }
1552
+ });
1553
+ }
1554
+ }
1555
+ function applyResize(editor, handle, startBounds, startShapes, pointer, lockAspectRatio) {
1556
+ const minSize = 8 / editor.viewport.zoom;
1557
+ const startW = Math.max(1e-4, startBounds.maxX - startBounds.minX);
1558
+ const startH = Math.max(1e-4, startBounds.maxY - startBounds.minY);
1559
+ const aspectRatio = startW / startH;
1560
+ let minX = startBounds.minX;
1561
+ let minY = startBounds.minY;
1562
+ let maxX = startBounds.maxX;
1563
+ let maxY = startBounds.maxY;
1564
+ switch (handle) {
1565
+ case "nw":
1566
+ minX = Math.min(pointer.x, startBounds.maxX - minSize);
1567
+ minY = Math.min(pointer.y, startBounds.maxY - minSize);
1568
+ break;
1569
+ case "ne":
1570
+ maxX = Math.max(pointer.x, startBounds.minX + minSize);
1571
+ minY = Math.min(pointer.y, startBounds.maxY - minSize);
1572
+ break;
1573
+ case "sw":
1574
+ minX = Math.min(pointer.x, startBounds.maxX - minSize);
1575
+ maxY = Math.max(pointer.y, startBounds.minY + minSize);
1576
+ break;
1577
+ case "se":
1578
+ maxX = Math.max(pointer.x, startBounds.minX + minSize);
1579
+ maxY = Math.max(pointer.y, startBounds.minY + minSize);
1580
+ break;
1581
+ }
1582
+ if (lockAspectRatio) {
1583
+ let nextW = Math.max(minSize, maxX - minX);
1584
+ let nextH = Math.max(minSize, maxY - minY);
1585
+ if (nextW / nextH > aspectRatio) {
1586
+ nextH = nextW / aspectRatio;
1587
+ } else {
1588
+ nextW = nextH * aspectRatio;
1589
+ }
1590
+ if (nextW < minSize) {
1591
+ nextW = minSize;
1592
+ nextH = nextW / aspectRatio;
1593
+ }
1594
+ if (nextH < minSize) {
1595
+ nextH = minSize;
1596
+ nextW = nextH * aspectRatio;
1597
+ }
1598
+ switch (handle) {
1599
+ case "nw":
1600
+ minX = startBounds.maxX - nextW;
1601
+ minY = startBounds.maxY - nextH;
1602
+ maxX = startBounds.maxX;
1603
+ maxY = startBounds.maxY;
1604
+ break;
1605
+ case "ne":
1606
+ minX = startBounds.minX;
1607
+ minY = startBounds.maxY - nextH;
1608
+ maxX = startBounds.minX + nextW;
1609
+ maxY = startBounds.maxY;
1610
+ break;
1611
+ case "sw":
1612
+ minX = startBounds.maxX - nextW;
1613
+ minY = startBounds.minY;
1614
+ maxX = startBounds.maxX;
1615
+ maxY = startBounds.minY + nextH;
1616
+ break;
1617
+ case "se":
1618
+ minX = startBounds.minX;
1619
+ minY = startBounds.minY;
1620
+ maxX = startBounds.minX + nextW;
1621
+ maxY = startBounds.minY + nextH;
1622
+ break;
1623
+ }
1624
+ }
1625
+ const newBounds = { minX, minY, maxX, maxY };
1626
+ const sx = (newBounds.maxX - newBounds.minX) / startW;
1627
+ const sy = (newBounds.maxY - newBounds.minY) / startH;
1628
+ for (const [id, snapshot] of startShapes) {
1629
+ const shape = editor.getShape(id);
1630
+ if (!shape || shape.type !== "draw") continue;
1631
+ const nextX = newBounds.minX + (snapshot.x - startBounds.minX) * sx;
1632
+ const nextY = newBounds.minY + (snapshot.y - startBounds.minY) * sy;
1633
+ const segments = snapshot.segments.map((segment) => ({
1634
+ type: segment.type,
1635
+ path: encodePoints(
1636
+ segment.points.map((p) => ({
1637
+ x: p.x * sx,
1638
+ y: p.y * sy,
1639
+ z: p.z
1640
+ }))
1641
+ )
1642
+ }));
1643
+ editor.store.updateShape(id, {
1644
+ x: nextX,
1645
+ y: nextY,
1646
+ props: { ...shape.props, segments }
1647
+ });
1648
+ }
1649
+ }
1650
+
1651
+ export { CanvasRenderer, DEFAULT_COLORS, DRAG_DISTANCE_SQUARED, DocumentStore, ERASER_MARGIN, Editor, EraserErasingState, EraserIdleState, EraserPointingState, HandDraggingState, HandIdleState, InputManager, MAX_POINTS_PER_SHAPE, PenDrawingState, PenIdleState, STROKE_WIDTHS, SelectIdleState, StateNode, ToolManager, applyMove, applyResize, applyRotation, boundsContainPoint, boundsIntersect, boundsOf, buildStartPositions, buildTransformSnapshots, closestOnSegment, createViewport, decodeFirstPoint, decodeLastPoint, decodePathToPoints, decodePoints, distance, encodePoints, getSelectionBoundsPage, getShapeBounds2 as getShapeBounds, getShapesInBounds, getTopShapeAtPoint, isSelectTool, minDistanceToPolyline, normalizeSelectionBounds, padBounds, pageToScreen, panViewport, pointHitsShape, resolveThemeColor, rotatePoint, screenToPage, segmentHitsShape, segmentTouchesPolyline, setViewport, shapePagePoints, sqDistance, zoomViewport };
1652
+ //# sourceMappingURL=index.js.map
1653
+ //# sourceMappingURL=index.js.map