@termdraw/opentui 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2058 @@
1
+ import { MouseButton } from "@opentui/core";
2
+ export const BRUSHES = ["#", "*", "+", "-", "=", "x", "o", ".", "|", "/", "\\"];
3
+ export const BOX_STYLES = ["auto", "light", "heavy", "double"];
4
+ export const INK_COLORS = [
5
+ "white",
6
+ "red",
7
+ "orange",
8
+ "yellow",
9
+ "green",
10
+ "cyan",
11
+ "blue",
12
+ "magenta",
13
+ ];
14
+ const MAX_HISTORY = 100;
15
+ const HANDLE_CHARACTER = "●";
16
+ const DIRECTIONS = ["n", "e", "s", "w"];
17
+ const DIRECTION_BITS = {
18
+ n: 1,
19
+ e: 2,
20
+ s: 4,
21
+ w: 8,
22
+ };
23
+ const OPPOSITE_DIRECTION = {
24
+ n: "s",
25
+ e: "w",
26
+ s: "n",
27
+ w: "e",
28
+ };
29
+ const DIRECTION_DELTAS = {
30
+ n: { dx: 0, dy: -1 },
31
+ e: { dx: 1, dy: 0 },
32
+ s: { dx: 0, dy: 1 },
33
+ w: { dx: -1, dy: 0 },
34
+ };
35
+ const LIGHT_GLYPHS = {
36
+ 0: " ",
37
+ 1: "│",
38
+ 2: "─",
39
+ 3: "└",
40
+ 4: "│",
41
+ 5: "│",
42
+ 6: "┌",
43
+ 7: "├",
44
+ 8: "─",
45
+ 9: "┘",
46
+ 10: "─",
47
+ 11: "┴",
48
+ 12: "┐",
49
+ 13: "┤",
50
+ 14: "┬",
51
+ 15: "┼",
52
+ };
53
+ const HEAVY_GLYPHS = {
54
+ 0: " ",
55
+ 1: "┃",
56
+ 2: "━",
57
+ 3: "┗",
58
+ 4: "┃",
59
+ 5: "┃",
60
+ 6: "┏",
61
+ 7: "┣",
62
+ 8: "━",
63
+ 9: "┛",
64
+ 10: "━",
65
+ 11: "┻",
66
+ 12: "┓",
67
+ 13: "┫",
68
+ 14: "┳",
69
+ 15: "╋",
70
+ };
71
+ const DOUBLE_GLYPHS = {
72
+ 0: " ",
73
+ 1: "║",
74
+ 2: "═",
75
+ 3: "╚",
76
+ 4: "║",
77
+ 5: "║",
78
+ 6: "╔",
79
+ 7: "╠",
80
+ 8: "═",
81
+ 9: "╝",
82
+ 10: "═",
83
+ 11: "╩",
84
+ 12: "╗",
85
+ 13: "╣",
86
+ 14: "╦",
87
+ 15: "╬",
88
+ };
89
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
90
+ function splitGraphemes(input) {
91
+ return Array.from(graphemeSegmenter.segment(input), (segment) => segment.segment);
92
+ }
93
+ export function truncateToCells(input, width) {
94
+ if (width <= 0)
95
+ return "";
96
+ return splitGraphemes(input).slice(0, width).join("");
97
+ }
98
+ export function visibleCellCount(input) {
99
+ return splitGraphemes(input).length;
100
+ }
101
+ export function padToWidth(content, width) {
102
+ const clipped = truncateToCells(content, width);
103
+ return clipped + " ".repeat(Math.max(0, width - visibleCellCount(clipped)));
104
+ }
105
+ function normalizeCellCharacter(input) {
106
+ const first = splitGraphemes(input)[0] ?? " ";
107
+ return first.length > 0 ? first : " ";
108
+ }
109
+ function createCanvas(width, height) {
110
+ return Array.from({ length: height }, () => Array.from({ length: width }, () => " "));
111
+ }
112
+ function createColorGrid(width, height) {
113
+ return Array.from({ length: height }, () => Array.from({ length: width }, () => null));
114
+ }
115
+ function createCellConnections() {
116
+ return {
117
+ n: { light: 0, heavy: 0, double: 0 },
118
+ e: { light: 0, heavy: 0, double: 0 },
119
+ s: { light: 0, heavy: 0, double: 0 },
120
+ w: { light: 0, heavy: 0, double: 0 },
121
+ };
122
+ }
123
+ function createConnectionGrid(width, height) {
124
+ return Array.from({ length: height }, () => Array.from({ length: width }, () => createCellConnections()));
125
+ }
126
+ function clamp(value, min, max) {
127
+ return Math.max(min, Math.min(value, max));
128
+ }
129
+ function normalizeRect(start, end) {
130
+ return {
131
+ left: Math.min(start.x, end.x),
132
+ right: Math.max(start.x, end.x),
133
+ top: Math.min(start.y, end.y),
134
+ bottom: Math.max(start.y, end.y),
135
+ };
136
+ }
137
+ function rectContainsPoint(rect, x, y) {
138
+ return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
139
+ }
140
+ function getRectPerimeterPoints(rect) {
141
+ const cells = new Map();
142
+ const add = (x, y) => {
143
+ cells.set(`${x},${y}`, { x, y });
144
+ };
145
+ for (let x = rect.left; x <= rect.right; x += 1) {
146
+ add(x, rect.top);
147
+ add(x, rect.bottom);
148
+ }
149
+ for (let y = rect.top; y <= rect.bottom; y += 1) {
150
+ add(rect.left, y);
151
+ add(rect.right, y);
152
+ }
153
+ return [...cells.values()];
154
+ }
155
+ function cloneObject(object) {
156
+ if (object.type === "paint") {
157
+ return {
158
+ ...object,
159
+ points: object.points.map((point) => ({ ...point })),
160
+ };
161
+ }
162
+ return { ...object };
163
+ }
164
+ function cloneObjects(objects) {
165
+ return objects.map((object) => cloneObject(object));
166
+ }
167
+ function adjustConnection(grid, width, height, x, y, direction, style, delta) {
168
+ if (x < 0 || y < 0 || x >= width || y >= height)
169
+ return;
170
+ const offset = DIRECTION_DELTAS[direction];
171
+ const nx = x + offset.dx;
172
+ const ny = y + offset.dy;
173
+ if (nx < 0 || ny < 0 || nx >= width || ny >= height)
174
+ return;
175
+ const source = grid[y][x][direction];
176
+ source[style] = Math.max(0, source[style] + delta);
177
+ const opposite = OPPOSITE_DIRECTION[direction];
178
+ const target = grid[ny][nx][opposite];
179
+ target[style] = Math.max(0, target[style] + delta);
180
+ }
181
+ function paintConnectionColor(grid, width, height, x, y, direction, color) {
182
+ if (x < 0 || y < 0 || x >= width || y >= height)
183
+ return;
184
+ const offset = DIRECTION_DELTAS[direction];
185
+ const nx = x + offset.dx;
186
+ const ny = y + offset.dy;
187
+ if (nx < 0 || ny < 0 || nx >= width || ny >= height)
188
+ return;
189
+ grid[y][x] = color;
190
+ grid[ny][nx] = color;
191
+ }
192
+ function applyBoxPerimeter(rect, applySegment) {
193
+ if (rect.left === rect.right && rect.top === rect.bottom)
194
+ return;
195
+ for (let x = rect.left; x < rect.right; x += 1) {
196
+ applySegment(x, rect.top, "e");
197
+ }
198
+ if (rect.bottom !== rect.top) {
199
+ for (let x = rect.left; x < rect.right; x += 1) {
200
+ applySegment(x, rect.bottom, "e");
201
+ }
202
+ }
203
+ for (let y = rect.top; y < rect.bottom; y += 1) {
204
+ applySegment(rect.left, y, "s");
205
+ }
206
+ if (rect.right !== rect.left) {
207
+ for (let y = rect.top; y < rect.bottom; y += 1) {
208
+ applySegment(rect.right, y, "s");
209
+ }
210
+ }
211
+ }
212
+ function getBoxBorderGlyphs(style) {
213
+ switch (style) {
214
+ case "heavy":
215
+ return {
216
+ horizontal: "━",
217
+ vertical: "┃",
218
+ topLeft: "┏",
219
+ topRight: "┓",
220
+ bottomLeft: "┗",
221
+ bottomRight: "┛",
222
+ };
223
+ case "double":
224
+ return {
225
+ horizontal: "═",
226
+ vertical: "║",
227
+ topLeft: "╔",
228
+ topRight: "╗",
229
+ bottomLeft: "╚",
230
+ bottomRight: "╝",
231
+ };
232
+ case "light":
233
+ return {
234
+ horizontal: "─",
235
+ vertical: "│",
236
+ topLeft: "┌",
237
+ topRight: "┐",
238
+ bottomLeft: "└",
239
+ bottomRight: "┘",
240
+ };
241
+ }
242
+ }
243
+ function getLinePoints(x0, y0, x1, y1) {
244
+ const points = [];
245
+ let currentX = x0;
246
+ let currentY = y0;
247
+ const deltaX = Math.abs(x1 - x0);
248
+ const deltaY = Math.abs(y1 - y0);
249
+ const stepX = x0 < x1 ? 1 : -1;
250
+ const stepY = y0 < y1 ? 1 : -1;
251
+ let err = deltaX - deltaY;
252
+ while (true) {
253
+ points.push({ x: currentX, y: currentY });
254
+ if (currentX === x1 && currentY === y1)
255
+ break;
256
+ const twiceErr = err * 2;
257
+ if (twiceErr > -deltaY) {
258
+ err -= deltaY;
259
+ currentX += stepX;
260
+ }
261
+ if (twiceErr < deltaX) {
262
+ err += deltaX;
263
+ currentY += stepY;
264
+ }
265
+ }
266
+ return points;
267
+ }
268
+ function mergeUniquePoints(existing, next) {
269
+ const merged = existing.map((point) => ({ ...point }));
270
+ const seen = new Set(existing.map((point) => `${point.x},${point.y}`));
271
+ for (const point of next) {
272
+ const key = `${point.x},${point.y}`;
273
+ if (seen.has(key))
274
+ continue;
275
+ seen.add(key);
276
+ merged.push({ ...point });
277
+ }
278
+ return merged;
279
+ }
280
+ function appendPaintSegment(points, from, to) {
281
+ return mergeUniquePoints(points, getLinePoints(from.x, from.y, to.x, to.y));
282
+ }
283
+ function pointsEqual(a, b) {
284
+ return (a.length === b.length &&
285
+ a.every((point, index) => point.x === b[index]?.x && point.y === b[index]?.y));
286
+ }
287
+ function getObjectBounds(object) {
288
+ switch (object.type) {
289
+ case "box":
290
+ return { left: object.left, top: object.top, right: object.right, bottom: object.bottom };
291
+ case "line":
292
+ return normalizeRect({ x: object.x1, y: object.y1 }, { x: object.x2, y: object.y2 });
293
+ case "paint": {
294
+ const [firstPoint] = object.points;
295
+ let left = firstPoint?.x ?? 0;
296
+ let right = firstPoint?.x ?? 0;
297
+ let top = firstPoint?.y ?? 0;
298
+ let bottom = firstPoint?.y ?? 0;
299
+ for (const point of object.points) {
300
+ left = Math.min(left, point.x);
301
+ right = Math.max(right, point.x);
302
+ top = Math.min(top, point.y);
303
+ bottom = Math.max(bottom, point.y);
304
+ }
305
+ return { left, top, right, bottom };
306
+ }
307
+ case "text": {
308
+ const width = Math.max(1, visibleCellCount(object.content));
309
+ return {
310
+ left: object.x,
311
+ top: object.y,
312
+ right: object.x + width - 1,
313
+ bottom: object.y,
314
+ };
315
+ }
316
+ }
317
+ }
318
+ function getBoxContentBounds(box) {
319
+ return {
320
+ left: box.left + 1,
321
+ top: box.top + 1,
322
+ right: box.right - 1,
323
+ bottom: box.bottom - 1,
324
+ };
325
+ }
326
+ function isValidRect(rect) {
327
+ return rect.left <= rect.right && rect.top <= rect.bottom;
328
+ }
329
+ function rectContainsRect(outer, inner) {
330
+ if (!isValidRect(outer))
331
+ return false;
332
+ return (inner.left >= outer.left &&
333
+ inner.right <= outer.right &&
334
+ inner.top >= outer.top &&
335
+ inner.bottom <= outer.bottom);
336
+ }
337
+ function rectsIntersect(a, b) {
338
+ return a.left <= b.right && a.right >= b.left && a.top <= b.bottom && a.bottom >= b.top;
339
+ }
340
+ function getRectArea(rect) {
341
+ return Math.max(0, rect.right - rect.left + 1) * Math.max(0, rect.bottom - rect.top + 1);
342
+ }
343
+ function getBoundsUnion(objects) {
344
+ if (objects.length === 0)
345
+ return null;
346
+ let left = Number.POSITIVE_INFINITY;
347
+ let top = Number.POSITIVE_INFINITY;
348
+ let right = Number.NEGATIVE_INFINITY;
349
+ let bottom = Number.NEGATIVE_INFINITY;
350
+ for (const object of objects) {
351
+ const bounds = getObjectBounds(object);
352
+ left = Math.min(left, bounds.left);
353
+ top = Math.min(top, bounds.top);
354
+ right = Math.max(right, bounds.right);
355
+ bottom = Math.max(bottom, bounds.bottom);
356
+ }
357
+ return { left, top, right, bottom };
358
+ }
359
+ function getTextSelectionBounds(object) {
360
+ const width = Math.max(1, visibleCellCount(object.content));
361
+ return {
362
+ left: object.x - 1,
363
+ top: object.y - 1,
364
+ right: object.x + width,
365
+ bottom: object.y + 1,
366
+ };
367
+ }
368
+ function getObjectSelectionBounds(object) {
369
+ return object.type === "text" ? getTextSelectionBounds(object) : getObjectBounds(object);
370
+ }
371
+ function getBoxCornerPoints(box) {
372
+ return {
373
+ "top-left": { x: box.left, y: box.top },
374
+ "top-right": { x: box.right, y: box.top },
375
+ "bottom-left": { x: box.left, y: box.bottom },
376
+ "bottom-right": { x: box.right, y: box.bottom },
377
+ };
378
+ }
379
+ function getLineEndpointPoints(line) {
380
+ return {
381
+ start: { x: line.x1, y: line.y1 },
382
+ end: { x: line.x2, y: line.y2 },
383
+ };
384
+ }
385
+ function getObjectRenderCells(object) {
386
+ switch (object.type) {
387
+ case "box": {
388
+ const cells = new Map();
389
+ const add = (x, y) => {
390
+ cells.set(`${x},${y}`, { x, y });
391
+ };
392
+ for (let x = object.left; x <= object.right; x += 1) {
393
+ add(x, object.top);
394
+ add(x, object.bottom);
395
+ }
396
+ for (let y = object.top; y <= object.bottom; y += 1) {
397
+ add(object.left, y);
398
+ add(object.right, y);
399
+ }
400
+ return [...cells.values()];
401
+ }
402
+ case "line":
403
+ return getLinePoints(object.x1, object.y1, object.x2, object.y2);
404
+ case "paint":
405
+ return object.points.map((point) => ({ ...point }));
406
+ case "text":
407
+ return splitGraphemes(object.content).map((_, index) => ({
408
+ x: object.x + index,
409
+ y: object.y,
410
+ }));
411
+ }
412
+ }
413
+ function translateObject(object, dx, dy) {
414
+ switch (object.type) {
415
+ case "box":
416
+ return {
417
+ ...object,
418
+ left: object.left + dx,
419
+ right: object.right + dx,
420
+ top: object.top + dy,
421
+ bottom: object.bottom + dy,
422
+ };
423
+ case "line":
424
+ return {
425
+ ...object,
426
+ x1: object.x1 + dx,
427
+ x2: object.x2 + dx,
428
+ y1: object.y1 + dy,
429
+ y2: object.y2 + dy,
430
+ };
431
+ case "paint":
432
+ return {
433
+ ...object,
434
+ points: object.points.map((point) => ({ x: point.x + dx, y: point.y + dy })),
435
+ };
436
+ case "text":
437
+ return {
438
+ ...object,
439
+ x: object.x + dx,
440
+ y: object.y + dy,
441
+ };
442
+ }
443
+ }
444
+ function objectContainsPoint(object, x, y) {
445
+ switch (object.type) {
446
+ case "box": {
447
+ const withinBounds = x >= object.left && x <= object.right && y >= object.top && y <= object.bottom;
448
+ if (!withinBounds)
449
+ return false;
450
+ return x === object.left || x === object.right || y === object.top || y === object.bottom;
451
+ }
452
+ case "line":
453
+ return getLinePoints(object.x1, object.y1, object.x2, object.y2).some((point) => point.x === x && point.y === y);
454
+ case "paint":
455
+ return object.points.some((point) => point.x === x && point.y === y);
456
+ case "text":
457
+ return y === object.y && x >= object.x && x < object.x + visibleCellCount(object.content);
458
+ }
459
+ }
460
+ const DEFAULT_CANVAS_INSETS = {
461
+ left: 1,
462
+ top: 3,
463
+ right: 1,
464
+ bottom: 2,
465
+ };
466
+ export class DrawState {
467
+ canvasInsets = { ...DEFAULT_CANVAS_INSETS };
468
+ canvasWidth = 0;
469
+ canvasHeight = 0;
470
+ cursorX = 0;
471
+ cursorY = 0;
472
+ mode = "line";
473
+ brush = BRUSHES[0];
474
+ brushIndex = 0;
475
+ boxStyle = BOX_STYLES[0];
476
+ boxStyleIndex = 0;
477
+ inkColor = INK_COLORS[0];
478
+ inkColorIndex = 0;
479
+ objects = [];
480
+ selectedObjectIds = [];
481
+ selectedObjectId = null;
482
+ activeTextObjectId = null;
483
+ pendingSelection = null;
484
+ pendingLine = null;
485
+ pendingBox = null;
486
+ pendingPaint = null;
487
+ dragState = null;
488
+ eraseState = null;
489
+ nextObjectNumber = 1;
490
+ nextZIndex = 1;
491
+ undoStack = [];
492
+ redoStack = [];
493
+ status = "Line mode: drag on empty space to create a line object, or drag an existing object to move it.";
494
+ sceneDirty = true;
495
+ renderCanvas = [];
496
+ renderCanvasColors = [];
497
+ renderConnections = [];
498
+ renderConnectionColors = [];
499
+ constructor(viewWidth, viewHeight, insets = DEFAULT_CANVAS_INSETS) {
500
+ this.ensureCanvasSize(viewWidth, viewHeight, insets);
501
+ }
502
+ get currentMode() {
503
+ return this.mode;
504
+ }
505
+ get currentBrush() {
506
+ return this.brush;
507
+ }
508
+ get currentBoxStyle() {
509
+ return this.boxStyle;
510
+ }
511
+ get currentInkColor() {
512
+ return this.inkColor;
513
+ }
514
+ get currentStatus() {
515
+ return this.status;
516
+ }
517
+ get currentCursorX() {
518
+ return this.cursorX;
519
+ }
520
+ get currentCursorY() {
521
+ return this.cursorY;
522
+ }
523
+ get width() {
524
+ return this.canvasWidth;
525
+ }
526
+ get height() {
527
+ return this.canvasHeight;
528
+ }
529
+ get canvasTopRow() {
530
+ return this.canvasInsets.top;
531
+ }
532
+ get canvasLeftCol() {
533
+ return this.canvasInsets.left;
534
+ }
535
+ get hasSelectedObject() {
536
+ return this.selectedObjectIds.length > 0;
537
+ }
538
+ get isEditingText() {
539
+ return this.getActiveTextObject() !== null;
540
+ }
541
+ get hasActivePointerInteraction() {
542
+ return (this.pendingSelection !== null ||
543
+ this.pendingLine !== null ||
544
+ this.pendingBox !== null ||
545
+ this.pendingPaint !== null ||
546
+ this.dragState !== null ||
547
+ this.eraseState !== null);
548
+ }
549
+ ensureCanvasSize(viewWidth, viewHeight, insets = this.canvasInsets) {
550
+ const nextInsets = { ...insets };
551
+ const nextCanvasWidth = Math.max(1, viewWidth - nextInsets.left - nextInsets.right);
552
+ const nextCanvasHeight = Math.max(1, viewHeight - nextInsets.top - nextInsets.bottom);
553
+ if (nextCanvasWidth === this.canvasWidth &&
554
+ nextCanvasHeight === this.canvasHeight &&
555
+ nextInsets.left === this.canvasInsets.left &&
556
+ nextInsets.top === this.canvasInsets.top &&
557
+ nextInsets.right === this.canvasInsets.right &&
558
+ nextInsets.bottom === this.canvasInsets.bottom) {
559
+ return;
560
+ }
561
+ this.canvasInsets = nextInsets;
562
+ this.canvasWidth = nextCanvasWidth;
563
+ this.canvasHeight = nextCanvasHeight;
564
+ this.cursorX = Math.max(0, Math.min(this.cursorX, this.canvasWidth - 1));
565
+ this.cursorY = Math.max(0, Math.min(this.cursorY, this.canvasHeight - 1));
566
+ this.setObjects(this.objects.map((object) => this.shiftObjectInsideCanvas(object)));
567
+ this.pendingSelection = null;
568
+ this.pendingLine = null;
569
+ this.pendingBox = null;
570
+ this.pendingPaint = null;
571
+ this.dragState = null;
572
+ this.eraseState = null;
573
+ }
574
+ handlePointerEvent(event) {
575
+ if (event.type === "scroll") {
576
+ const direction = event.scrollDirection === "down" || event.scrollDirection === "left" ? -1 : 1;
577
+ if (this.mode === "line" || this.mode === "paint") {
578
+ this.cycleBrush(direction);
579
+ }
580
+ else if (this.mode === "box") {
581
+ this.cycleBoxStyle(direction);
582
+ }
583
+ return;
584
+ }
585
+ const canvasX = event.x - this.canvasLeftCol;
586
+ const canvasY = event.y - this.canvasTopRow;
587
+ const clampedX = clamp(canvasX, 0, this.canvasWidth - 1);
588
+ const clampedY = clamp(canvasY, 0, this.canvasHeight - 1);
589
+ const insideCanvas = this.isInsideCanvas(canvasX, canvasY);
590
+ const point = { x: clampedX, y: clampedY };
591
+ if (event.type === "up" || event.type === "drag-end") {
592
+ this.finishPointerInteraction(point, insideCanvas);
593
+ return;
594
+ }
595
+ if (event.type === "drag") {
596
+ this.cursorX = clampedX;
597
+ this.cursorY = clampedY;
598
+ if (this.dragState) {
599
+ this.updateDraggedObject(point);
600
+ return;
601
+ }
602
+ if (this.pendingSelection) {
603
+ this.pendingSelection.end = point;
604
+ this.setStatus(`Selecting ${this.describeRect(normalizeRect(this.pendingSelection.start, this.pendingSelection.end))}.`);
605
+ return;
606
+ }
607
+ if (this.pendingBox) {
608
+ this.pendingBox.end = point;
609
+ this.setStatus(`Sizing box ${this.describeRect(normalizeRect(this.pendingBox.start, this.pendingBox.end))}.`);
610
+ return;
611
+ }
612
+ if (this.pendingLine) {
613
+ this.pendingLine.end = point;
614
+ this.setStatus(`Sizing line to ${point.x + 1},${point.y + 1}.`);
615
+ return;
616
+ }
617
+ if (this.pendingPaint) {
618
+ this.pendingPaint.points = appendPaintSegment(this.pendingPaint.points, this.pendingPaint.lastPoint, point);
619
+ this.pendingPaint.lastPoint = point;
620
+ this.setStatus(`Painting to ${point.x + 1},${point.y + 1}.`);
621
+ return;
622
+ }
623
+ if (insideCanvas && this.eraseState) {
624
+ this.eraseObjectAt(point.x, point.y);
625
+ }
626
+ return;
627
+ }
628
+ if (event.type !== "down") {
629
+ return;
630
+ }
631
+ if (!insideCanvas) {
632
+ if (event.button === MouseButton.LEFT) {
633
+ this.setSelectedObjects([]);
634
+ this.activeTextObjectId = null;
635
+ this.setStatus("Selection cleared.");
636
+ }
637
+ return;
638
+ }
639
+ this.cursorX = canvasX;
640
+ this.cursorY = canvasY;
641
+ if (event.button === MouseButton.RIGHT) {
642
+ this.beginEraseSession();
643
+ this.eraseObjectAt(canvasX, canvasY);
644
+ return;
645
+ }
646
+ if (this.tryBeginObjectInteraction(canvasX, canvasY)) {
647
+ return;
648
+ }
649
+ switch (this.mode) {
650
+ case "select":
651
+ this.activeTextObjectId = null;
652
+ this.pendingSelection = {
653
+ start: { x: canvasX, y: canvasY },
654
+ end: { x: canvasX, y: canvasY },
655
+ };
656
+ this.setStatus(`Selection start at ${canvasX + 1},${canvasY + 1}. Drag to select multiple objects.`);
657
+ return;
658
+ case "box":
659
+ this.setSelectedObjects([]);
660
+ this.activeTextObjectId = null;
661
+ this.pendingBox = {
662
+ start: { x: canvasX, y: canvasY },
663
+ end: { x: canvasX, y: canvasY },
664
+ };
665
+ this.setStatus(`Box start at ${canvasX + 1},${canvasY + 1}. Drag to size, release to commit.`);
666
+ return;
667
+ case "line":
668
+ this.setSelectedObjects([]);
669
+ this.activeTextObjectId = null;
670
+ this.pendingLine = {
671
+ start: { x: canvasX, y: canvasY },
672
+ end: { x: canvasX, y: canvasY },
673
+ };
674
+ this.setStatus(`Line start at ${canvasX + 1},${canvasY + 1}. Drag to endpoint, release to commit.`);
675
+ return;
676
+ case "paint":
677
+ this.setSelectedObjects([]);
678
+ this.activeTextObjectId = null;
679
+ this.pendingPaint = {
680
+ points: [{ x: canvasX, y: canvasY }],
681
+ lastPoint: { x: canvasX, y: canvasY },
682
+ };
683
+ this.setStatus(`Paint start at ${canvasX + 1},${canvasY + 1}. Drag to paint.`);
684
+ return;
685
+ case "text":
686
+ this.placeTextCursor(canvasX, canvasY);
687
+ return;
688
+ }
689
+ }
690
+ getModeLabel() {
691
+ switch (this.mode) {
692
+ case "select":
693
+ return "SELECT";
694
+ case "line":
695
+ return "LINE";
696
+ case "box":
697
+ return "BOX";
698
+ case "paint":
699
+ return "PAINT";
700
+ case "text":
701
+ return "TEXT";
702
+ }
703
+ }
704
+ getActivePreviewCharacters() {
705
+ if (this.pendingPaint)
706
+ return this.getPaintPreviewCharacters();
707
+ if (this.pendingLine)
708
+ return this.getLinePreviewCharacters();
709
+ if (this.pendingBox)
710
+ return this.getBoxPreviewCharacters();
711
+ return new Map();
712
+ }
713
+ getSelectedCellKeys() {
714
+ const keys = new Set();
715
+ for (const selected of this.getSelectedObjects()) {
716
+ for (const point of getObjectRenderCells(selected)) {
717
+ if (!this.isInsideCanvas(point.x, point.y))
718
+ continue;
719
+ keys.add(`${point.x},${point.y}`);
720
+ }
721
+ if (selected.type === "text") {
722
+ for (const point of getRectPerimeterPoints(getTextSelectionBounds(selected))) {
723
+ if (!this.isInsideCanvas(point.x, point.y))
724
+ continue;
725
+ keys.add(`${point.x},${point.y}`);
726
+ }
727
+ }
728
+ }
729
+ return keys;
730
+ }
731
+ getSelectionMarqueeCharacters() {
732
+ const marquee = new Map();
733
+ if (!this.pendingSelection)
734
+ return marquee;
735
+ const rect = normalizeRect(this.pendingSelection.start, this.pendingSelection.end);
736
+ for (const point of getRectPerimeterPoints(rect)) {
737
+ if (!this.isInsideCanvas(point.x, point.y))
738
+ continue;
739
+ marquee.set(`${point.x},${point.y}`, "·");
740
+ }
741
+ return marquee;
742
+ }
743
+ getSelectionHandleCharacters() {
744
+ const handles = new Map();
745
+ if (this.selectedObjectIds.length !== 1)
746
+ return handles;
747
+ const selected = this.getSelectedObject();
748
+ if (!selected)
749
+ return handles;
750
+ if (selected.type === "box") {
751
+ for (const point of Object.values(getBoxCornerPoints(selected))) {
752
+ if (!this.isInsideCanvas(point.x, point.y))
753
+ continue;
754
+ handles.set(`${point.x},${point.y}`, HANDLE_CHARACTER);
755
+ }
756
+ return handles;
757
+ }
758
+ if (selected.type === "line") {
759
+ for (const point of Object.values(getLineEndpointPoints(selected))) {
760
+ if (!this.isInsideCanvas(point.x, point.y))
761
+ continue;
762
+ handles.set(`${point.x},${point.y}`, HANDLE_CHARACTER);
763
+ }
764
+ }
765
+ return handles;
766
+ }
767
+ clearSelection() {
768
+ const hadSelection = this.selectedObjectIds.length > 0 || this.activeTextObjectId !== null;
769
+ this.setSelectedObjects([]);
770
+ this.activeTextObjectId = null;
771
+ this.setStatus(hadSelection ? "Selection cleared." : "Nothing selected.");
772
+ return hadSelection;
773
+ }
774
+ getCompositeCell(x, y) {
775
+ this.ensureScene();
776
+ const ink = this.renderCanvas[y][x] ?? " ";
777
+ if (ink !== " ")
778
+ return ink;
779
+ return this.getConnectionGlyph(x, y);
780
+ }
781
+ getCompositeColor(x, y) {
782
+ this.ensureScene();
783
+ const ink = this.renderCanvas[y][x] ?? " ";
784
+ if (ink !== " ") {
785
+ return this.renderCanvasColors[y][x] ?? null;
786
+ }
787
+ return this.getConnectionGlyph(x, y) === " "
788
+ ? null
789
+ : (this.renderConnectionColors[y][x] ?? null);
790
+ }
791
+ moveCursor(dx, dy) {
792
+ this.cursorX = Math.max(0, Math.min(this.canvasWidth - 1, this.cursorX + dx));
793
+ this.cursorY = Math.max(0, Math.min(this.canvasHeight - 1, this.cursorY + dy));
794
+ if (this.mode === "text") {
795
+ this.activeTextObjectId = null;
796
+ }
797
+ this.setStatus(`Cursor ${this.cursorX + 1},${this.cursorY + 1}.`);
798
+ }
799
+ moveSelectedObjectBy(dx, dy) {
800
+ const selected = this.getSelectedObjects();
801
+ if (selected.length === 0) {
802
+ this.setStatus("No object selected.");
803
+ return;
804
+ }
805
+ const selectedTree = this.getSelectedObjectTrees();
806
+ const movedTree = this.translateObjectTreeWithinCanvas(selectedTree, dx, dy);
807
+ if (this.objectListsEqual(movedTree, selectedTree)) {
808
+ this.setStatus(selected.length === 1
809
+ ? `${this.describeObject(selected[0])} is already at the edge.`
810
+ : "Selection is already at the edge.");
811
+ return;
812
+ }
813
+ this.pushUndo();
814
+ this.replaceObjects(movedTree);
815
+ this.activeTextObjectId = null;
816
+ this.setStatus(selected.length === 1
817
+ ? `Moved ${this.describeObject(selected[0])}.`
818
+ : `Moved ${selected.length} objects.`);
819
+ }
820
+ setBrush(char) {
821
+ this.brush = normalizeCellCharacter(char);
822
+ const idx = BRUSHES.indexOf(this.brush);
823
+ this.brushIndex = idx >= 0 ? idx : 0;
824
+ this.setStatus(`Brush set to "${this.brush}".`);
825
+ }
826
+ cycleBrush(direction) {
827
+ this.brushIndex = (this.brushIndex + direction + BRUSHES.length) % BRUSHES.length;
828
+ this.brush = BRUSHES[this.brushIndex] ?? this.brush;
829
+ this.setStatus(`Brush set to "${this.brush}".`);
830
+ }
831
+ setInkColor(color) {
832
+ this.inkColor = color;
833
+ const idx = INK_COLORS.indexOf(color);
834
+ this.inkColorIndex = idx >= 0 ? idx : 0;
835
+ const selected = this.getSelectedObjects();
836
+ const recolorable = selected.filter((object) => object.color !== color);
837
+ if (recolorable.length === 0) {
838
+ this.setStatus(`Color set to ${this.describeInkColor(color)}.`);
839
+ return;
840
+ }
841
+ this.pushUndo();
842
+ this.replaceObjects(recolorable.map((object) => ({ ...object, color })));
843
+ this.setStatus(recolorable.length === 1
844
+ ? `Applied ${this.describeInkColor(color)} to ${this.describeObject(recolorable[0])}.`
845
+ : `Applied ${this.describeInkColor(color)} to ${recolorable.length} objects.`);
846
+ }
847
+ cycleInkColor(direction) {
848
+ this.inkColorIndex = (this.inkColorIndex + direction + INK_COLORS.length) % INK_COLORS.length;
849
+ this.inkColor = INK_COLORS[this.inkColorIndex] ?? this.inkColor;
850
+ this.setStatus(`Color set to ${this.describeInkColor(this.inkColor)}.`);
851
+ }
852
+ setBoxStyle(style) {
853
+ this.boxStyle = style;
854
+ const idx = BOX_STYLES.indexOf(style);
855
+ this.boxStyleIndex = idx >= 0 ? idx : 0;
856
+ this.setStatus(`Box style set to ${this.describeBoxStyle(style)}.`);
857
+ }
858
+ cycleBoxStyle(direction) {
859
+ this.boxStyleIndex = (this.boxStyleIndex + direction + BOX_STYLES.length) % BOX_STYLES.length;
860
+ this.boxStyle = BOX_STYLES[this.boxStyleIndex] ?? this.boxStyle;
861
+ this.setStatus(`Box style set to ${this.describeBoxStyle(this.boxStyle)}.`);
862
+ }
863
+ cycleMode() {
864
+ const order = ["select", "box", "line", "paint", "text"];
865
+ const currentIndex = order.indexOf(this.mode);
866
+ const next = order[(currentIndex + 1) % order.length] ?? "line";
867
+ this.setMode(next);
868
+ }
869
+ setMode(next) {
870
+ if (this.mode === next)
871
+ return;
872
+ this.mode = next;
873
+ this.pendingSelection = null;
874
+ this.pendingLine = null;
875
+ this.pendingBox = null;
876
+ this.pendingPaint = null;
877
+ this.dragState = null;
878
+ this.eraseState = null;
879
+ if (next !== "text") {
880
+ this.activeTextObjectId = null;
881
+ }
882
+ if (next === "select") {
883
+ this.setStatus("Select mode: click objects to select them, drag selected objects to move them, or drag empty space to marquee-select multiple objects.");
884
+ }
885
+ else if (next === "line") {
886
+ this.setStatus("Line mode: drag on empty space to create a line. Click objects to move them, or line endpoints to adjust.");
887
+ }
888
+ else if (next === "box") {
889
+ this.setStatus(`Box mode (${this.describeBoxStyle(this.boxStyle)}): drag on empty space to create a box. Click objects to move them, or drag box corners to resize.`);
890
+ }
891
+ else if (next === "paint") {
892
+ this.setStatus("Paint mode: drag on empty space to paint. Click objects to move them, and use the current brush for freehand strokes.");
893
+ }
894
+ else {
895
+ this.setStatus("Text mode: click empty space to type, click text to edit, and use its virtual selection box to move it.");
896
+ }
897
+ }
898
+ stampBrushAtCursor() {
899
+ this.pushUndo();
900
+ if (this.mode === "paint") {
901
+ const object = {
902
+ id: this.createObjectId(),
903
+ z: this.allocateZIndex(),
904
+ parentId: null,
905
+ color: this.inkColor,
906
+ type: "paint",
907
+ points: [{ x: this.cursorX, y: this.cursorY }],
908
+ brush: this.brush,
909
+ };
910
+ this.setObjects([...this.objects, object]);
911
+ this.setSelectedObjects([object.id], object.id);
912
+ this.activeTextObjectId = null;
913
+ this.setStatus(`Painted "${this.brush}" at ${this.cursorX + 1},${this.cursorY + 1}.`);
914
+ return;
915
+ }
916
+ const object = {
917
+ id: this.createObjectId(),
918
+ z: this.allocateZIndex(),
919
+ parentId: null,
920
+ color: this.inkColor,
921
+ type: "line",
922
+ x1: this.cursorX,
923
+ y1: this.cursorY,
924
+ x2: this.cursorX,
925
+ y2: this.cursorY,
926
+ brush: this.brush,
927
+ };
928
+ this.setObjects([...this.objects, object]);
929
+ this.setSelectedObjects([object.id], object.id);
930
+ this.activeTextObjectId = null;
931
+ this.setStatus(`Stamped "${this.brush}" at ${this.cursorX + 1},${this.cursorY + 1}.`);
932
+ }
933
+ eraseAtCursor() {
934
+ if (this.deleteTopmostObjectAt(this.cursorX, this.cursorY))
935
+ return;
936
+ this.setStatus(`Nothing to erase at ${this.cursorX + 1},${this.cursorY + 1}.`);
937
+ }
938
+ insertCharacter(input) {
939
+ const char = normalizeCellCharacter(input);
940
+ this.pushUndo();
941
+ const activeObject = this.getActiveTextObject();
942
+ if (activeObject) {
943
+ const updated = {
944
+ ...activeObject,
945
+ content: activeObject.content + char,
946
+ };
947
+ this.replaceObject(updated);
948
+ this.setSelectedObjects([updated.id], updated.id);
949
+ this.activeTextObjectId = updated.id;
950
+ this.cursorX = Math.min(this.canvasWidth - 1, updated.x + visibleCellCount(updated.content));
951
+ this.cursorY = updated.y;
952
+ this.setStatus(`Appended "${char}" to ${this.describeObject(updated)}.`);
953
+ return;
954
+ }
955
+ const object = {
956
+ id: this.createObjectId(),
957
+ z: this.allocateZIndex(),
958
+ parentId: null,
959
+ color: this.inkColor,
960
+ type: "text",
961
+ x: this.cursorX,
962
+ y: this.cursorY,
963
+ content: char,
964
+ };
965
+ this.setObjects([...this.objects, object]);
966
+ this.setSelectedObjects([object.id], object.id);
967
+ this.activeTextObjectId = object.id;
968
+ this.cursorX = Math.min(this.canvasWidth - 1, this.cursorX + 1);
969
+ this.setStatus(`Created ${this.describeObject(this.getObjectById(object.id) ?? object)}.`);
970
+ }
971
+ backspace() {
972
+ const activeObject = this.getActiveTextObject();
973
+ if (!activeObject) {
974
+ if (this.deleteTopmostObjectAt(this.cursorX, this.cursorY))
975
+ return;
976
+ this.setStatus(`Nothing to backspace at ${this.cursorX + 1},${this.cursorY + 1}.`);
977
+ return;
978
+ }
979
+ this.pushUndo();
980
+ const parts = splitGraphemes(activeObject.content);
981
+ parts.pop();
982
+ if (parts.length === 0) {
983
+ this.removeObjectById(activeObject.id);
984
+ this.setSelectedObjects([]);
985
+ this.activeTextObjectId = null;
986
+ this.cursorX = activeObject.x;
987
+ this.cursorY = activeObject.y;
988
+ this.setStatus(`Removed ${this.describeObject(activeObject)}.`);
989
+ return;
990
+ }
991
+ const updated = {
992
+ ...activeObject,
993
+ content: parts.join(""),
994
+ };
995
+ this.replaceObject(updated);
996
+ this.setSelectedObjects([updated.id], updated.id);
997
+ this.activeTextObjectId = updated.id;
998
+ this.cursorX = Math.min(this.canvasWidth - 1, updated.x + visibleCellCount(updated.content));
999
+ this.cursorY = updated.y;
1000
+ this.setStatus(`Backspaced ${this.describeObject(updated)}.`);
1001
+ }
1002
+ deleteAtCursor() {
1003
+ if (this.deleteSelectedObject())
1004
+ return;
1005
+ if (this.deleteTopmostObjectAt(this.cursorX, this.cursorY))
1006
+ return;
1007
+ this.setStatus(`Nothing to delete at ${this.cursorX + 1},${this.cursorY + 1}.`);
1008
+ }
1009
+ deleteSelectedObject() {
1010
+ const selected = this.getSelectedObjects();
1011
+ if (selected.length === 0)
1012
+ return false;
1013
+ this.pushUndo();
1014
+ const selectedIds = new Set(selected.map((object) => object.id));
1015
+ this.setObjects(this.objects.filter((object) => !selectedIds.has(object.id)));
1016
+ this.setSelectedObjects([]);
1017
+ this.activeTextObjectId = null;
1018
+ this.setStatus(selected.length === 1
1019
+ ? `Deleted ${this.describeObject(selected[0])}.`
1020
+ : `Deleted ${selected.length} objects.`);
1021
+ return true;
1022
+ }
1023
+ clearCanvas() {
1024
+ if (this.objects.length === 0) {
1025
+ this.setStatus("Canvas already clear.");
1026
+ return;
1027
+ }
1028
+ this.pushUndo();
1029
+ this.setObjects([]);
1030
+ this.setSelectedObjects([]);
1031
+ this.activeTextObjectId = null;
1032
+ this.pendingSelection = null;
1033
+ this.pendingLine = null;
1034
+ this.pendingBox = null;
1035
+ this.pendingPaint = null;
1036
+ this.dragState = null;
1037
+ this.eraseState = null;
1038
+ this.markSceneDirty();
1039
+ this.setStatus("Canvas cleared.");
1040
+ }
1041
+ undo() {
1042
+ const snapshot = this.undoStack.pop();
1043
+ if (!snapshot) {
1044
+ this.setStatus("Nothing to undo.");
1045
+ return;
1046
+ }
1047
+ this.redoStack.push(this.createSnapshot());
1048
+ if (this.redoStack.length > MAX_HISTORY) {
1049
+ this.redoStack.shift();
1050
+ }
1051
+ this.restoreSnapshot(snapshot);
1052
+ this.setStatus("Undid last change.");
1053
+ }
1054
+ redo() {
1055
+ const snapshot = this.redoStack.pop();
1056
+ if (!snapshot) {
1057
+ this.setStatus("Nothing to redo.");
1058
+ return;
1059
+ }
1060
+ this.undoStack.push(this.createSnapshot());
1061
+ if (this.undoStack.length > MAX_HISTORY) {
1062
+ this.undoStack.shift();
1063
+ }
1064
+ this.restoreSnapshot(snapshot);
1065
+ this.setStatus("Redid change.");
1066
+ }
1067
+ exportArt() {
1068
+ this.ensureScene();
1069
+ const lines = [];
1070
+ for (let y = 0; y < this.canvasHeight; y += 1) {
1071
+ let row = "";
1072
+ for (let x = 0; x < this.canvasWidth; x += 1) {
1073
+ const ink = this.renderCanvas[y][x] ?? " ";
1074
+ row += ink !== " " ? ink : this.getConnectionGlyph(x, y);
1075
+ }
1076
+ lines.push(row.replace(/\s+$/g, ""));
1077
+ }
1078
+ while (lines.length > 0 && (lines[0] ?? "") === "") {
1079
+ lines.shift();
1080
+ }
1081
+ while (lines.length > 0 && (lines[lines.length - 1] ?? "") === "") {
1082
+ lines.pop();
1083
+ }
1084
+ return lines.join("\n");
1085
+ }
1086
+ tryBeginObjectInteraction(x, y) {
1087
+ this.activeTextObjectId = null;
1088
+ const handleHit = this.findTopmostHandleAt(x, y);
1089
+ if (handleHit) {
1090
+ this.setSelectedObjects([handleHit.object.id], handleHit.object.id);
1091
+ if (handleHit.kind === "box-corner") {
1092
+ this.dragState = {
1093
+ kind: "resize-box",
1094
+ objectId: handleHit.object.id,
1095
+ startMouse: { x, y },
1096
+ originalObject: { ...handleHit.object },
1097
+ originalObjects: cloneObjects(this.getObjectTree(handleHit.object.id)),
1098
+ handle: handleHit.handle,
1099
+ pushedUndo: false,
1100
+ };
1101
+ this.setStatus(`Selected ${this.describeObject(handleHit.object)}. Drag corner to resize.`);
1102
+ return true;
1103
+ }
1104
+ this.dragState = {
1105
+ kind: "line-endpoint",
1106
+ objectId: handleHit.object.id,
1107
+ startMouse: { x, y },
1108
+ originalObject: { ...handleHit.object },
1109
+ endpoint: handleHit.endpoint,
1110
+ pushedUndo: false,
1111
+ };
1112
+ this.setStatus(`Selected ${this.describeObject(handleHit.object)}. Drag endpoint to adjust it.`);
1113
+ return true;
1114
+ }
1115
+ const hit = this.findTopmostObjectHitAt(x, y);
1116
+ if (!hit)
1117
+ return false;
1118
+ this.beginMoveInteraction(hit.object, x, y, this.mode === "text" && hit.object.type === "text" && hit.onTextContent);
1119
+ return true;
1120
+ }
1121
+ beginMoveInteraction(object, x, y, textEditOnClick) {
1122
+ const selectionIds = this.isObjectSelected(object.id) && this.selectedObjectIds.length > 0
1123
+ ? this.selectedObjectIds
1124
+ : [object.id];
1125
+ const moveSelection = this.getMoveSelectionForObject(object);
1126
+ const movingMultiple = selectionIds.length > 1;
1127
+ this.setSelectedObjects(selectionIds, object.id);
1128
+ this.activeTextObjectId = null;
1129
+ this.dragState = {
1130
+ kind: "move",
1131
+ objectId: object.id,
1132
+ startMouse: { x, y },
1133
+ originalObjects: cloneObjects(moveSelection),
1134
+ pushedUndo: false,
1135
+ textEditOnClick: textEditOnClick && selectionIds.length === 1,
1136
+ };
1137
+ this.setStatus(movingMultiple
1138
+ ? `Selected ${selectionIds.length} objects. Drag to move them.`
1139
+ : `Selected ${this.describeObject(object)}. Drag to move it.`);
1140
+ }
1141
+ placeTextCursor(x, y) {
1142
+ this.setSelectedObjects([]);
1143
+ this.activeTextObjectId = null;
1144
+ this.setStatus(`Text cursor ${x + 1},${y + 1}.`);
1145
+ }
1146
+ beginEraseSession() {
1147
+ this.pendingSelection = null;
1148
+ this.pendingLine = null;
1149
+ this.pendingBox = null;
1150
+ this.pendingPaint = null;
1151
+ this.dragState = null;
1152
+ this.activeTextObjectId = null;
1153
+ this.eraseState = {
1154
+ erasedIds: new Set(),
1155
+ pushedUndo: false,
1156
+ };
1157
+ }
1158
+ finishPointerInteraction(point, insideCanvas) {
1159
+ if (this.pendingSelection) {
1160
+ const rect = normalizeRect(this.pendingSelection.start, this.pendingSelection.end);
1161
+ this.pendingSelection = null;
1162
+ if (rect.left === rect.right && rect.top === rect.bottom) {
1163
+ this.setSelectedObjects([]);
1164
+ this.setStatus(`Selection cleared at ${rect.left + 1},${rect.top + 1}.`);
1165
+ return;
1166
+ }
1167
+ const selected = this.getObjectsWithinSelectionRect(rect);
1168
+ this.setSelectedObjects(selected.map((object) => object.id), selected.at(-1)?.id ?? null);
1169
+ this.activeTextObjectId = null;
1170
+ this.setStatus(selected.length === 0
1171
+ ? `No objects in ${this.describeRect(rect)}.`
1172
+ : selected.length === 1
1173
+ ? `Selected ${this.describeObject(selected[0])}.`
1174
+ : `Selected ${selected.length} objects.`);
1175
+ return;
1176
+ }
1177
+ if (this.pendingBox) {
1178
+ const rect = normalizeRect(this.pendingBox.start, this.pendingBox.end);
1179
+ this.pendingBox = null;
1180
+ if (rect.left === rect.right && rect.top === rect.bottom) {
1181
+ this.setStatus("Ignored zero-size box.");
1182
+ return;
1183
+ }
1184
+ this.pushUndo();
1185
+ const object = {
1186
+ id: this.createObjectId(),
1187
+ z: this.allocateZIndex(),
1188
+ parentId: null,
1189
+ color: this.inkColor,
1190
+ type: "box",
1191
+ left: rect.left,
1192
+ top: rect.top,
1193
+ right: rect.right,
1194
+ bottom: rect.bottom,
1195
+ style: this.boxStyle,
1196
+ };
1197
+ this.setObjects([...this.objects, object]);
1198
+ this.setSelectedObjects([object.id], object.id);
1199
+ this.setStatus(`Created ${this.describeObject(this.getObjectById(object.id) ?? object)}.`);
1200
+ return;
1201
+ }
1202
+ if (this.pendingLine) {
1203
+ const start = this.pendingLine.start;
1204
+ const end = this.pendingLine.end;
1205
+ this.pendingLine = null;
1206
+ if (start.x === end.x && start.y === end.y) {
1207
+ this.setStatus(`Line cancelled at ${start.x + 1},${start.y + 1}.`);
1208
+ return;
1209
+ }
1210
+ this.pushUndo();
1211
+ const object = {
1212
+ id: this.createObjectId(),
1213
+ z: this.allocateZIndex(),
1214
+ parentId: null,
1215
+ color: this.inkColor,
1216
+ type: "line",
1217
+ x1: start.x,
1218
+ y1: start.y,
1219
+ x2: end.x,
1220
+ y2: end.y,
1221
+ brush: this.brush,
1222
+ };
1223
+ this.setObjects([...this.objects, object]);
1224
+ this.setSelectedObjects([object.id], object.id);
1225
+ this.setStatus(`Created ${this.describeObject(this.getObjectById(object.id) ?? object)}.`);
1226
+ return;
1227
+ }
1228
+ if (this.pendingPaint) {
1229
+ const points = this.pendingPaint.points.map((pointEntry) => ({ ...pointEntry }));
1230
+ this.pendingPaint = null;
1231
+ this.pushUndo();
1232
+ const object = {
1233
+ id: this.createObjectId(),
1234
+ z: this.allocateZIndex(),
1235
+ parentId: null,
1236
+ color: this.inkColor,
1237
+ type: "paint",
1238
+ points,
1239
+ brush: this.brush,
1240
+ };
1241
+ this.setObjects([...this.objects, object]);
1242
+ this.setSelectedObjects([object.id], object.id);
1243
+ this.setStatus(`Created ${this.describeObject(this.getObjectById(object.id) ?? object)}.`);
1244
+ return;
1245
+ }
1246
+ if (this.dragState) {
1247
+ const dragState = this.dragState;
1248
+ this.dragState = null;
1249
+ const object = this.getObjectById(dragState.objectId);
1250
+ if (!dragState.pushedUndo) {
1251
+ if (dragState.kind === "move" && dragState.textEditOnClick && object?.type === "text") {
1252
+ this.setSelectedObjects([object.id], object.id);
1253
+ this.activeTextObjectId = object.id;
1254
+ this.cursorX = Math.min(this.canvasWidth - 1, object.x + visibleCellCount(object.content));
1255
+ this.cursorY = object.y;
1256
+ this.setStatus(`Editing ${this.describeObject(object)}.`);
1257
+ return;
1258
+ }
1259
+ if (object) {
1260
+ this.setStatus(dragState.kind === "move" && this.selectedObjectIds.length > 1
1261
+ ? `Selected ${this.selectedObjectIds.length} objects.`
1262
+ : `Selected ${this.describeObject(object)}.`);
1263
+ }
1264
+ return;
1265
+ }
1266
+ if (object) {
1267
+ if (dragState.kind === "resize-box") {
1268
+ this.setStatus(`Resized ${this.describeObject(object)}.`);
1269
+ }
1270
+ else if (dragState.kind === "line-endpoint") {
1271
+ this.setStatus(`Adjusted ${this.describeObject(object)}.`);
1272
+ }
1273
+ else if (this.selectedObjectIds.length > 1) {
1274
+ this.setStatus(`Moved ${this.selectedObjectIds.length} objects.`);
1275
+ }
1276
+ else {
1277
+ this.setStatus(`Moved ${this.describeObject(object)}.`);
1278
+ }
1279
+ }
1280
+ return;
1281
+ }
1282
+ if (this.eraseState) {
1283
+ this.eraseState = null;
1284
+ if (!insideCanvas) {
1285
+ this.setStatus(`Cursor ${point.x + 1},${point.y + 1}.`);
1286
+ }
1287
+ }
1288
+ }
1289
+ updateDraggedObject(point) {
1290
+ const dragState = this.dragState;
1291
+ if (!dragState)
1292
+ return;
1293
+ let nextObjects;
1294
+ let nextObject;
1295
+ switch (dragState.kind) {
1296
+ case "move": {
1297
+ const dx = point.x - dragState.startMouse.x;
1298
+ const dy = point.y - dragState.startMouse.y;
1299
+ nextObjects = this.translateObjectTreeWithinCanvas(dragState.originalObjects, dx, dy);
1300
+ nextObject = nextObjects.find((object) => object.id === dragState.objectId);
1301
+ break;
1302
+ }
1303
+ case "resize-box":
1304
+ nextObjects = this.resizeObjectTreeWithinCanvas(dragState.originalObjects, dragState.originalObject, dragState.handle, point);
1305
+ nextObject = nextObjects.find((object) => object.id === dragState.objectId);
1306
+ break;
1307
+ case "line-endpoint":
1308
+ nextObject = this.adjustLineEndpointWithinCanvas(dragState.originalObject, dragState.endpoint, point);
1309
+ nextObjects = [nextObject];
1310
+ break;
1311
+ }
1312
+ const changed = dragState.kind === "move"
1313
+ ? !this.objectListsEqual(nextObjects, dragState.originalObjects)
1314
+ : dragState.kind === "resize-box"
1315
+ ? !this.objectListsEqual(nextObjects, dragState.originalObjects)
1316
+ : !this.objectsEqual(nextObject, dragState.originalObject);
1317
+ if (!dragState.pushedUndo && changed) {
1318
+ this.pushUndo();
1319
+ dragState.pushedUndo = true;
1320
+ nextObjects = this.bringObjectsToFront(nextObjects);
1321
+ nextObject = nextObjects.find((object) => object.id === dragState.objectId);
1322
+ this.syncDragStateZ(nextObjects);
1323
+ }
1324
+ this.replaceObjects(nextObjects);
1325
+ this.setSelectedObjects(this.selectedObjectIds, nextObject.id);
1326
+ this.activeTextObjectId = null;
1327
+ if (dragState.kind === "resize-box") {
1328
+ this.setStatus(`Resizing ${this.describeObject(nextObject)}.`);
1329
+ }
1330
+ else if (dragState.kind === "line-endpoint") {
1331
+ this.setStatus(`Adjusting ${this.describeObject(nextObject)}.`);
1332
+ }
1333
+ else if (this.selectedObjectIds.length > 1) {
1334
+ this.setStatus(`Moving ${this.selectedObjectIds.length} objects.`);
1335
+ }
1336
+ else {
1337
+ this.setStatus(`Moving ${this.describeObject(nextObject)}.`);
1338
+ }
1339
+ }
1340
+ syncDragStateZ(objects) {
1341
+ if (!this.dragState)
1342
+ return;
1343
+ const zById = new Map(objects.map((object) => [object.id, object.z]));
1344
+ switch (this.dragState.kind) {
1345
+ case "move":
1346
+ this.dragState.originalObjects = this.dragState.originalObjects.map((object) => ({
1347
+ ...object,
1348
+ z: zById.get(object.id) ?? object.z,
1349
+ }));
1350
+ break;
1351
+ case "resize-box":
1352
+ this.dragState.originalObject = {
1353
+ ...this.dragState.originalObject,
1354
+ z: zById.get(this.dragState.originalObject.id) ?? this.dragState.originalObject.z,
1355
+ };
1356
+ this.dragState.originalObjects = this.dragState.originalObjects.map((object) => ({
1357
+ ...object,
1358
+ z: zById.get(object.id) ?? object.z,
1359
+ }));
1360
+ break;
1361
+ case "line-endpoint":
1362
+ this.dragState.originalObject = {
1363
+ ...this.dragState.originalObject,
1364
+ z: zById.get(this.dragState.originalObject.id) ?? this.dragState.originalObject.z,
1365
+ };
1366
+ break;
1367
+ }
1368
+ }
1369
+ eraseObjectAt(x, y) {
1370
+ const hit = this.findTopmostObjectAt(x, y);
1371
+ if (!hit || !this.eraseState)
1372
+ return;
1373
+ if (this.eraseState.erasedIds.has(hit.id))
1374
+ return;
1375
+ if (!this.eraseState.pushedUndo) {
1376
+ this.pushUndo();
1377
+ this.eraseState.pushedUndo = true;
1378
+ }
1379
+ this.eraseState.erasedIds.add(hit.id);
1380
+ this.removeObjectById(hit.id);
1381
+ if (this.isObjectSelected(hit.id)) {
1382
+ this.setSelectedObjects(this.selectedObjectIds.filter((id) => id !== hit.id));
1383
+ }
1384
+ if (this.activeTextObjectId === hit.id) {
1385
+ this.activeTextObjectId = null;
1386
+ }
1387
+ this.setStatus(`Deleted ${this.describeObject(hit)}.`);
1388
+ }
1389
+ deleteTopmostObjectAt(x, y) {
1390
+ const hit = this.findTopmostObjectAt(x, y);
1391
+ if (!hit)
1392
+ return false;
1393
+ this.pushUndo();
1394
+ this.removeObjectById(hit.id);
1395
+ this.setSelectedObjects([]);
1396
+ if (this.activeTextObjectId === hit.id) {
1397
+ this.activeTextObjectId = null;
1398
+ }
1399
+ this.setStatus(`Deleted ${this.describeObject(hit)}.`);
1400
+ return true;
1401
+ }
1402
+ createSnapshot() {
1403
+ return {
1404
+ objects: cloneObjects(this.objects),
1405
+ selectedObjectIds: [...this.selectedObjectIds],
1406
+ selectedObjectId: this.selectedObjectId,
1407
+ cursorX: this.cursorX,
1408
+ cursorY: this.cursorY,
1409
+ nextObjectNumber: this.nextObjectNumber,
1410
+ nextZIndex: this.nextZIndex,
1411
+ };
1412
+ }
1413
+ pushUndo() {
1414
+ this.undoStack.push(this.createSnapshot());
1415
+ if (this.undoStack.length > MAX_HISTORY) {
1416
+ this.undoStack.shift();
1417
+ }
1418
+ this.redoStack = [];
1419
+ }
1420
+ restoreSnapshot(snapshot) {
1421
+ this.objects = this.recomputeParentAssignments(cloneObjects(snapshot.objects).map((object) => this.shiftObjectInsideCanvas(object)));
1422
+ this.selectedObjectIds = [...snapshot.selectedObjectIds];
1423
+ this.selectedObjectId = snapshot.selectedObjectId;
1424
+ this.syncSelection();
1425
+ this.cursorX = Math.max(0, Math.min(snapshot.cursorX, this.canvasWidth - 1));
1426
+ this.cursorY = Math.max(0, Math.min(snapshot.cursorY, this.canvasHeight - 1));
1427
+ this.nextObjectNumber = snapshot.nextObjectNumber;
1428
+ this.nextZIndex = snapshot.nextZIndex;
1429
+ this.activeTextObjectId = null;
1430
+ this.pendingSelection = null;
1431
+ this.pendingLine = null;
1432
+ this.pendingBox = null;
1433
+ this.pendingPaint = null;
1434
+ this.dragState = null;
1435
+ this.eraseState = null;
1436
+ this.markSceneDirty();
1437
+ }
1438
+ ensureScene() {
1439
+ if (!this.sceneDirty)
1440
+ return;
1441
+ this.renderCanvas = createCanvas(this.canvasWidth, this.canvasHeight);
1442
+ this.renderCanvasColors = createColorGrid(this.canvasWidth, this.canvasHeight);
1443
+ this.renderConnections = createConnectionGrid(this.canvasWidth, this.canvasHeight);
1444
+ this.renderConnectionColors = createColorGrid(this.canvasWidth, this.canvasHeight);
1445
+ const indexedObjects = this.objects.map((object, index) => ({ object, index }));
1446
+ indexedObjects.sort((a, b) => a.object.z - b.object.z || a.index - b.index);
1447
+ for (const { object } of indexedObjects) {
1448
+ switch (object.type) {
1449
+ case "box": {
1450
+ const style = this.resolveBoxConnectionStyle(object, object.style, object.id);
1451
+ applyBoxPerimeter(object, (x, y, direction) => {
1452
+ adjustConnection(this.renderConnections, this.canvasWidth, this.canvasHeight, x, y, direction, style, 1);
1453
+ paintConnectionColor(this.renderConnectionColors, this.canvasWidth, this.canvasHeight, x, y, direction, object.color);
1454
+ });
1455
+ break;
1456
+ }
1457
+ case "line": {
1458
+ for (const point of getLinePoints(object.x1, object.y1, object.x2, object.y2)) {
1459
+ this.paintRenderCell(point.x, point.y, object.brush, object.color);
1460
+ }
1461
+ break;
1462
+ }
1463
+ case "paint": {
1464
+ for (const point of object.points) {
1465
+ this.paintRenderCell(point.x, point.y, object.brush, object.color);
1466
+ }
1467
+ break;
1468
+ }
1469
+ case "text": {
1470
+ for (const [index, segment] of splitGraphemes(object.content).entries()) {
1471
+ this.paintRenderCell(object.x + index, object.y, segment, object.color);
1472
+ }
1473
+ break;
1474
+ }
1475
+ }
1476
+ }
1477
+ this.sceneDirty = false;
1478
+ }
1479
+ paintRenderCell(x, y, char, color) {
1480
+ if (!this.isInsideCanvas(x, y))
1481
+ return;
1482
+ this.renderCanvas[y][x] = normalizeCellCharacter(char);
1483
+ this.renderCanvasColors[y][x] = color;
1484
+ }
1485
+ getConnectionGlyph(x, y) {
1486
+ if (!this.isInsideCanvas(x, y))
1487
+ return " ";
1488
+ let mask = 0;
1489
+ let hasHeavy = false;
1490
+ let hasDouble = false;
1491
+ for (const direction of DIRECTIONS) {
1492
+ const counts = this.renderConnections[y][x][direction];
1493
+ if (counts.light > 0 || counts.heavy > 0 || counts.double > 0) {
1494
+ mask |= DIRECTION_BITS[direction];
1495
+ }
1496
+ if (counts.heavy > 0) {
1497
+ hasHeavy = true;
1498
+ }
1499
+ if (counts.double > 0) {
1500
+ hasDouble = true;
1501
+ }
1502
+ }
1503
+ if (mask === 0)
1504
+ return " ";
1505
+ const table = hasDouble ? DOUBLE_GLYPHS : hasHeavy ? HEAVY_GLYPHS : LIGHT_GLYPHS;
1506
+ return table[mask] ?? (hasDouble ? "╬" : hasHeavy ? "╋" : "┼");
1507
+ }
1508
+ getLinePreviewCharacters() {
1509
+ const preview = new Map();
1510
+ if (!this.pendingLine)
1511
+ return preview;
1512
+ for (const point of getLinePoints(this.pendingLine.start.x, this.pendingLine.start.y, this.pendingLine.end.x, this.pendingLine.end.y)) {
1513
+ if (!this.isInsideCanvas(point.x, point.y))
1514
+ continue;
1515
+ preview.set(`${point.x},${point.y}`, this.brush);
1516
+ }
1517
+ return preview;
1518
+ }
1519
+ getPaintPreviewCharacters() {
1520
+ const preview = new Map();
1521
+ if (!this.pendingPaint)
1522
+ return preview;
1523
+ for (const point of this.pendingPaint.points) {
1524
+ if (!this.isInsideCanvas(point.x, point.y))
1525
+ continue;
1526
+ preview.set(`${point.x},${point.y}`, this.brush);
1527
+ }
1528
+ return preview;
1529
+ }
1530
+ getBoxPreviewCharacters() {
1531
+ const preview = new Map();
1532
+ if (!this.pendingBox)
1533
+ return preview;
1534
+ const rect = normalizeRect(this.pendingBox.start, this.pendingBox.end);
1535
+ const style = this.resolveBoxConnectionStyle(rect, this.boxStyle);
1536
+ const { horizontal, vertical, topLeft, topRight, bottomLeft, bottomRight } = getBoxBorderGlyphs(style);
1537
+ const setPreview = (x, y, value) => {
1538
+ if (!this.isInsideCanvas(x, y))
1539
+ return;
1540
+ preview.set(`${x},${y}`, value);
1541
+ };
1542
+ for (let x = rect.left; x <= rect.right; x += 1) {
1543
+ setPreview(x, rect.top, horizontal);
1544
+ setPreview(x, rect.bottom, horizontal);
1545
+ }
1546
+ for (let y = rect.top; y <= rect.bottom; y += 1) {
1547
+ setPreview(rect.left, y, vertical);
1548
+ setPreview(rect.right, y, vertical);
1549
+ }
1550
+ setPreview(rect.left, rect.top, topLeft);
1551
+ setPreview(rect.right, rect.top, topRight);
1552
+ setPreview(rect.left, rect.bottom, bottomLeft);
1553
+ setPreview(rect.right, rect.bottom, bottomRight);
1554
+ return preview;
1555
+ }
1556
+ resolveBoxConnectionStyle(rect, style, ignoreId) {
1557
+ if (style === "auto") {
1558
+ return this.getAutoBoxConnectionStyle(rect, ignoreId);
1559
+ }
1560
+ return style;
1561
+ }
1562
+ getAutoBoxConnectionStyle(rect, ignoreId) {
1563
+ const depth = this.objects.filter((object) => {
1564
+ if (object.type !== "box")
1565
+ return false;
1566
+ if (object.id === ignoreId)
1567
+ return false;
1568
+ return (rect.left > object.left &&
1569
+ rect.right < object.right &&
1570
+ rect.top > object.top &&
1571
+ rect.bottom < object.bottom);
1572
+ }).length;
1573
+ return depth % 2 === 0 ? "heavy" : "light";
1574
+ }
1575
+ getObjectById(id) {
1576
+ return this.objects.find((object) => object.id === id) ?? null;
1577
+ }
1578
+ isObjectSelected(id) {
1579
+ return this.selectedObjectIds.includes(id) || this.selectedObjectId === id;
1580
+ }
1581
+ getSelectedObject() {
1582
+ if (!this.selectedObjectId)
1583
+ return null;
1584
+ return this.getObjectById(this.selectedObjectId);
1585
+ }
1586
+ getSelectedObjects() {
1587
+ const ids = this.selectedObjectIds.length > 0
1588
+ ? this.selectedObjectIds
1589
+ : this.selectedObjectId
1590
+ ? [this.selectedObjectId]
1591
+ : [];
1592
+ return ids
1593
+ .map((id) => this.getObjectById(id))
1594
+ .filter((object) => object !== null);
1595
+ }
1596
+ getSelectedRootObjects() {
1597
+ const selectedIds = new Set(this.selectedObjectIds);
1598
+ return this.getSelectedObjects().filter((object) => {
1599
+ let parentId = object.parentId;
1600
+ while (parentId) {
1601
+ if (selectedIds.has(parentId)) {
1602
+ return false;
1603
+ }
1604
+ parentId = this.getObjectById(parentId)?.parentId ?? null;
1605
+ }
1606
+ return true;
1607
+ });
1608
+ }
1609
+ getSelectedObjectTrees() {
1610
+ const treeIds = new Set();
1611
+ for (const object of this.getSelectedRootObjects()) {
1612
+ for (const treeObject of this.getObjectTree(object.id)) {
1613
+ treeIds.add(treeObject.id);
1614
+ }
1615
+ }
1616
+ return this.objects.filter((object) => treeIds.has(object.id));
1617
+ }
1618
+ getMoveSelectionForObject(object) {
1619
+ if (!this.isObjectSelected(object.id) || this.selectedObjectIds.length <= 1) {
1620
+ return this.getObjectTree(object.id);
1621
+ }
1622
+ return this.getSelectedObjectTrees();
1623
+ }
1624
+ getActiveTextObject() {
1625
+ if (!this.activeTextObjectId)
1626
+ return null;
1627
+ const object = this.getObjectById(this.activeTextObjectId);
1628
+ return object?.type === "text" ? object : null;
1629
+ }
1630
+ getObjectTree(id, objects = this.objects) {
1631
+ const descendants = new Set([id]);
1632
+ let changed = true;
1633
+ while (changed) {
1634
+ changed = false;
1635
+ for (const object of objects) {
1636
+ if (object.parentId && descendants.has(object.parentId) && !descendants.has(object.id)) {
1637
+ descendants.add(object.id);
1638
+ changed = true;
1639
+ }
1640
+ }
1641
+ }
1642
+ return objects.filter((object) => descendants.has(object.id));
1643
+ }
1644
+ recomputeParentAssignments(objects) {
1645
+ return objects.map((object) => {
1646
+ const bounds = getObjectBounds(object);
1647
+ const candidates = objects
1648
+ .filter((candidate) => candidate.type === "box" && candidate.id !== object.id)
1649
+ .filter((candidate) => rectContainsRect(getBoxContentBounds(candidate), bounds))
1650
+ .sort((a, b) => getRectArea(getBoxContentBounds(a)) - getRectArea(getBoxContentBounds(b)) || a.z - b.z);
1651
+ return {
1652
+ ...object,
1653
+ parentId: candidates[0]?.id ?? null,
1654
+ };
1655
+ });
1656
+ }
1657
+ setObjects(nextObjects) {
1658
+ this.objects = this.recomputeParentAssignments(nextObjects);
1659
+ this.syncSelection();
1660
+ this.markSceneDirty();
1661
+ }
1662
+ replaceObject(nextObject) {
1663
+ this.replaceObjects([nextObject]);
1664
+ }
1665
+ replaceObjects(nextObjects) {
1666
+ const replacementMap = new Map(nextObjects.map((object) => [object.id, object]));
1667
+ this.setObjects(this.objects.map((object) => replacementMap.get(object.id) ?? object));
1668
+ }
1669
+ removeObjectById(id) {
1670
+ this.setObjects(this.objects.filter((object) => object.id !== id));
1671
+ }
1672
+ setSelectedObjects(ids, primaryId = ids.at(-1) ?? null) {
1673
+ const existingIds = new Set(this.objects.map((object) => object.id));
1674
+ const nextIds = [...new Set(ids)].filter((id) => existingIds.has(id));
1675
+ this.selectedObjectIds = nextIds;
1676
+ this.selectedObjectId =
1677
+ primaryId && nextIds.includes(primaryId) ? primaryId : (nextIds.at(-1) ?? null);
1678
+ if (this.activeTextObjectId !== null &&
1679
+ (nextIds.length !== 1 || this.activeTextObjectId !== this.selectedObjectId)) {
1680
+ this.activeTextObjectId = null;
1681
+ }
1682
+ }
1683
+ syncSelection() {
1684
+ const existingIds = new Set(this.objects.map((object) => object.id));
1685
+ this.selectedObjectIds = this.selectedObjectIds.filter((id) => existingIds.has(id));
1686
+ if (this.selectedObjectId && !existingIds.has(this.selectedObjectId)) {
1687
+ this.selectedObjectId = null;
1688
+ }
1689
+ if (this.selectedObjectId && !this.selectedObjectIds.includes(this.selectedObjectId)) {
1690
+ this.selectedObjectIds.push(this.selectedObjectId);
1691
+ }
1692
+ if (this.selectedObjectIds.length === 0) {
1693
+ this.selectedObjectId = null;
1694
+ }
1695
+ else if (!this.selectedObjectId) {
1696
+ this.selectedObjectId = this.selectedObjectIds.at(-1) ?? null;
1697
+ }
1698
+ if (this.activeTextObjectId !== null &&
1699
+ (!existingIds.has(this.activeTextObjectId) ||
1700
+ this.selectedObjectIds.length !== 1 ||
1701
+ this.activeTextObjectId !== this.selectedObjectId)) {
1702
+ this.activeTextObjectId = null;
1703
+ }
1704
+ }
1705
+ findTopmostHandleAt(x, y) {
1706
+ const indexedObjects = this.objects.map((object, index) => ({ object, index }));
1707
+ indexedObjects.sort((a, b) => b.object.z - a.object.z || b.index - a.index);
1708
+ for (const { object } of indexedObjects) {
1709
+ if (object.type === "box") {
1710
+ for (const [handle, point] of Object.entries(getBoxCornerPoints(object))) {
1711
+ if (point.x === x && point.y === y) {
1712
+ return { kind: "box-corner", object, handle };
1713
+ }
1714
+ }
1715
+ }
1716
+ if (object.type === "line") {
1717
+ for (const [endpoint, point] of Object.entries(getLineEndpointPoints(object))) {
1718
+ if (point.x === x && point.y === y) {
1719
+ return { kind: "line-endpoint", object, endpoint };
1720
+ }
1721
+ }
1722
+ }
1723
+ }
1724
+ return null;
1725
+ }
1726
+ findTopmostObjectAt(x, y) {
1727
+ const hit = this.findTopmostObjectHitAt(x, y);
1728
+ return hit?.object ?? null;
1729
+ }
1730
+ findTopmostObjectHitAt(x, y) {
1731
+ const indexedObjects = this.objects.map((object, index) => ({ object, index }));
1732
+ indexedObjects.sort((a, b) => b.object.z - a.object.z || b.index - a.index);
1733
+ for (const { object } of indexedObjects) {
1734
+ if (object.type === "text") {
1735
+ const onTextContent = objectContainsPoint(object, x, y);
1736
+ const inSelectedTextBounds = object.id === this.selectedObjectId &&
1737
+ rectContainsPoint(getTextSelectionBounds(object), x, y);
1738
+ if (onTextContent || inSelectedTextBounds) {
1739
+ return { object, onTextContent };
1740
+ }
1741
+ continue;
1742
+ }
1743
+ if (objectContainsPoint(object, x, y)) {
1744
+ return { object, onTextContent: false };
1745
+ }
1746
+ }
1747
+ return null;
1748
+ }
1749
+ getObjectsWithinSelectionRect(rect) {
1750
+ return this.objects.filter((object) => rectsIntersect(getObjectSelectionBounds(object), rect));
1751
+ }
1752
+ translateObjectWithinCanvas(object, desiredDx, desiredDy) {
1753
+ const bounds = getObjectBounds(object);
1754
+ const minDx = -bounds.left;
1755
+ const maxDx = this.canvasWidth - 1 - bounds.right;
1756
+ const minDy = -bounds.top;
1757
+ const maxDy = this.canvasHeight - 1 - bounds.bottom;
1758
+ const dx = minDx <= maxDx ? clamp(desiredDx, minDx, maxDx) : desiredDx;
1759
+ const dy = minDy <= maxDy ? clamp(desiredDy, minDy, maxDy) : desiredDy;
1760
+ return translateObject(object, dx, dy);
1761
+ }
1762
+ translateObjectTreeWithinCanvas(objects, desiredDx, desiredDy) {
1763
+ const bounds = getBoundsUnion(objects);
1764
+ if (!bounds)
1765
+ return objects;
1766
+ const minDx = -bounds.left;
1767
+ const maxDx = this.canvasWidth - 1 - bounds.right;
1768
+ const minDy = -bounds.top;
1769
+ const maxDy = this.canvasHeight - 1 - bounds.bottom;
1770
+ const dx = minDx <= maxDx ? clamp(desiredDx, minDx, maxDx) : desiredDx;
1771
+ const dy = minDy <= maxDy ? clamp(desiredDy, minDy, maxDy) : desiredDy;
1772
+ return objects.map((object) => translateObject(object, dx, dy));
1773
+ }
1774
+ resizeBoxWithinCanvas(box, handle, point) {
1775
+ const anchor = this.getOppositeBoxCorner(box, handle);
1776
+ const clampedPoint = this.clampPointInsideCanvas(point);
1777
+ const safePoint = this.ensureBoxDoesNotCollapse(anchor, clampedPoint);
1778
+ const rect = normalizeRect(anchor, safePoint);
1779
+ return {
1780
+ ...box,
1781
+ left: rect.left,
1782
+ top: rect.top,
1783
+ right: rect.right,
1784
+ bottom: rect.bottom,
1785
+ };
1786
+ }
1787
+ resizeObjectTreeWithinCanvas(originalObjects, originalBox, handle, point) {
1788
+ const resizedBox = this.resizeBoxWithinCanvas(originalBox, handle, point);
1789
+ const originalContentBounds = getBoxContentBounds(originalBox);
1790
+ const nextContentBounds = getBoxContentBounds(resizedBox);
1791
+ return originalObjects.map((object) => {
1792
+ if (object.id === originalBox.id) {
1793
+ return resizedBox;
1794
+ }
1795
+ return this.transformObjectForResizedParent(object, originalContentBounds, nextContentBounds);
1796
+ });
1797
+ }
1798
+ transformObjectForResizedParent(object, originalContentBounds, nextContentBounds) {
1799
+ if (!isValidRect(originalContentBounds) || !isValidRect(nextContentBounds)) {
1800
+ return object;
1801
+ }
1802
+ switch (object.type) {
1803
+ case "line": {
1804
+ const start = this.mapPointBetweenRects({ x: object.x1, y: object.y1 }, originalContentBounds, nextContentBounds);
1805
+ const end = this.mapPointBetweenRects({ x: object.x2, y: object.y2 }, originalContentBounds, nextContentBounds);
1806
+ return {
1807
+ ...object,
1808
+ x1: start.x,
1809
+ y1: start.y,
1810
+ x2: end.x,
1811
+ y2: end.y,
1812
+ };
1813
+ }
1814
+ case "paint": {
1815
+ const mappedPoints = object.points.map((point) => this.mapPointBetweenRects(point, originalContentBounds, nextContentBounds));
1816
+ return {
1817
+ ...object,
1818
+ points: mergeUniquePoints([], mappedPoints),
1819
+ };
1820
+ }
1821
+ case "text": {
1822
+ const mapped = this.mapPointBetweenRects({ x: object.x, y: object.y }, originalContentBounds, nextContentBounds);
1823
+ return this.clampTextIntoRect({
1824
+ ...object,
1825
+ x: mapped.x,
1826
+ y: mapped.y,
1827
+ }, nextContentBounds);
1828
+ }
1829
+ case "box": {
1830
+ const topLeft = this.mapPointBetweenRects({ x: object.left, y: object.top }, originalContentBounds, nextContentBounds);
1831
+ const bottomRight = this.mapPointBetweenRects({ x: object.right, y: object.bottom }, originalContentBounds, nextContentBounds);
1832
+ const rect = normalizeRect(topLeft, bottomRight);
1833
+ return {
1834
+ ...object,
1835
+ left: rect.left,
1836
+ top: rect.top,
1837
+ right: rect.right,
1838
+ bottom: rect.bottom,
1839
+ };
1840
+ }
1841
+ }
1842
+ }
1843
+ mapPointBetweenRects(point, from, to) {
1844
+ return {
1845
+ x: this.mapAxisBetweenRanges(point.x, from.left, from.right, to.left, to.right),
1846
+ y: this.mapAxisBetweenRanges(point.y, from.top, from.bottom, to.top, to.bottom),
1847
+ };
1848
+ }
1849
+ mapAxisBetweenRanges(value, fromStart, fromEnd, toStart, toEnd) {
1850
+ if (fromStart === fromEnd) {
1851
+ return toStart;
1852
+ }
1853
+ const ratio = (value - fromStart) / (fromEnd - fromStart);
1854
+ const mapped = Math.round(toStart + ratio * (toEnd - toStart));
1855
+ const min = Math.min(toStart, toEnd);
1856
+ const max = Math.max(toStart, toEnd);
1857
+ return clamp(mapped, min, max);
1858
+ }
1859
+ clampTextIntoRect(text, rect) {
1860
+ if (!isValidRect(rect))
1861
+ return text;
1862
+ const width = visibleCellCount(text.content);
1863
+ const minX = rect.left;
1864
+ const maxX = rect.right - width + 1;
1865
+ return {
1866
+ ...text,
1867
+ x: maxX >= minX ? clamp(text.x, minX, maxX) : rect.left,
1868
+ y: clamp(text.y, rect.top, rect.bottom),
1869
+ };
1870
+ }
1871
+ adjustLineEndpointWithinCanvas(line, endpoint, point) {
1872
+ const clampedPoint = this.clampPointInsideCanvas(point);
1873
+ if (endpoint === "start") {
1874
+ return {
1875
+ ...line,
1876
+ x1: clampedPoint.x,
1877
+ y1: clampedPoint.y,
1878
+ };
1879
+ }
1880
+ return {
1881
+ ...line,
1882
+ x2: clampedPoint.x,
1883
+ y2: clampedPoint.y,
1884
+ };
1885
+ }
1886
+ getOppositeBoxCorner(box, handle) {
1887
+ switch (handle) {
1888
+ case "top-left":
1889
+ return { x: box.right, y: box.bottom };
1890
+ case "top-right":
1891
+ return { x: box.left, y: box.bottom };
1892
+ case "bottom-left":
1893
+ return { x: box.right, y: box.top };
1894
+ case "bottom-right":
1895
+ return { x: box.left, y: box.top };
1896
+ }
1897
+ }
1898
+ clampPointInsideCanvas(point) {
1899
+ return {
1900
+ x: clamp(point.x, 0, this.canvasWidth - 1),
1901
+ y: clamp(point.y, 0, this.canvasHeight - 1),
1902
+ };
1903
+ }
1904
+ ensureBoxDoesNotCollapse(anchor, point) {
1905
+ if (anchor.x !== point.x || anchor.y !== point.y) {
1906
+ return point;
1907
+ }
1908
+ if (point.x > 0) {
1909
+ return { x: point.x - 1, y: point.y };
1910
+ }
1911
+ if (point.x < this.canvasWidth - 1) {
1912
+ return { x: point.x + 1, y: point.y };
1913
+ }
1914
+ if (point.y > 0) {
1915
+ return { x: point.x, y: point.y - 1 };
1916
+ }
1917
+ if (point.y < this.canvasHeight - 1) {
1918
+ return { x: point.x, y: point.y + 1 };
1919
+ }
1920
+ return point;
1921
+ }
1922
+ shiftObjectInsideCanvas(object) {
1923
+ const bounds = getObjectBounds(object);
1924
+ let dx = 0;
1925
+ let dy = 0;
1926
+ if (bounds.left < 0) {
1927
+ dx = -bounds.left;
1928
+ }
1929
+ else if (bounds.right >= this.canvasWidth) {
1930
+ dx = this.canvasWidth - 1 - bounds.right;
1931
+ }
1932
+ if (bounds.top < 0) {
1933
+ dy = -bounds.top;
1934
+ }
1935
+ else if (bounds.bottom >= this.canvasHeight) {
1936
+ dy = this.canvasHeight - 1 - bounds.bottom;
1937
+ }
1938
+ return translateObject(object, dx, dy);
1939
+ }
1940
+ bringObjectToFront(object) {
1941
+ return {
1942
+ ...object,
1943
+ z: this.allocateZIndex(),
1944
+ };
1945
+ }
1946
+ bringObjectsToFront(objects) {
1947
+ const byId = new Map();
1948
+ for (const object of [...objects].sort((a, b) => a.z - b.z || a.id.localeCompare(b.id))) {
1949
+ byId.set(object.id, this.bringObjectToFront(object));
1950
+ }
1951
+ return objects.map((object) => byId.get(object.id) ?? object);
1952
+ }
1953
+ createObjectId() {
1954
+ const id = `obj-${this.nextObjectNumber}`;
1955
+ this.nextObjectNumber += 1;
1956
+ return id;
1957
+ }
1958
+ allocateZIndex() {
1959
+ const z = this.nextZIndex;
1960
+ this.nextZIndex += 1;
1961
+ return z;
1962
+ }
1963
+ describeRect(rect) {
1964
+ return `${rect.left + 1},${rect.top + 1} → ${rect.right + 1},${rect.bottom + 1}`;
1965
+ }
1966
+ describeBoxStyle(style) {
1967
+ switch (style) {
1968
+ case "auto":
1969
+ return "Auto";
1970
+ case "light":
1971
+ return "Single";
1972
+ case "heavy":
1973
+ return "Heavy";
1974
+ case "double":
1975
+ return "Double";
1976
+ }
1977
+ }
1978
+ describeInkColor(color) {
1979
+ switch (color) {
1980
+ case "white":
1981
+ return "white";
1982
+ case "red":
1983
+ return "red";
1984
+ case "orange":
1985
+ return "orange";
1986
+ case "yellow":
1987
+ return "yellow";
1988
+ case "green":
1989
+ return "green";
1990
+ case "cyan":
1991
+ return "cyan";
1992
+ case "blue":
1993
+ return "blue";
1994
+ case "magenta":
1995
+ return "magenta";
1996
+ }
1997
+ }
1998
+ describeObject(object) {
1999
+ switch (object.type) {
2000
+ case "box":
2001
+ return `box ${this.describeRect(object)}`;
2002
+ case "line":
2003
+ return `line ${object.x1 + 1},${object.y1 + 1} → ${object.x2 + 1},${object.y2 + 1}`;
2004
+ case "paint": {
2005
+ const bounds = getObjectBounds(object);
2006
+ return `paint ${this.describeRect(bounds)}`;
2007
+ }
2008
+ case "text":
2009
+ return `text "${object.content}" at ${object.x + 1},${object.y + 1}`;
2010
+ }
2011
+ }
2012
+ objectsEqual(a, b) {
2013
+ if (a.type !== b.type)
2014
+ return false;
2015
+ if (a.parentId !== b.parentId)
2016
+ return false;
2017
+ if (a.color !== b.color)
2018
+ return false;
2019
+ switch (a.type) {
2020
+ case "box":
2021
+ return (a.left === b.left &&
2022
+ a.right === b.right &&
2023
+ a.top === b.top &&
2024
+ a.bottom === b.bottom &&
2025
+ a.style === b.style);
2026
+ case "line":
2027
+ return (a.x1 === b.x1 &&
2028
+ a.y1 === b.y1 &&
2029
+ a.x2 === b.x2 &&
2030
+ a.y2 === b.y2 &&
2031
+ a.brush === b.brush);
2032
+ case "paint":
2033
+ return (a.brush === b.brush && pointsEqual(a.points, b.points));
2034
+ case "text":
2035
+ return (a.x === b.x &&
2036
+ a.y === b.y &&
2037
+ a.content === b.content);
2038
+ }
2039
+ }
2040
+ objectListsEqual(a, b) {
2041
+ if (a.length !== b.length)
2042
+ return false;
2043
+ const byId = new Map(b.map((object) => [object.id, object]));
2044
+ return a.every((object) => {
2045
+ const other = byId.get(object.id);
2046
+ return other ? this.objectsEqual(object, other) : false;
2047
+ });
2048
+ }
2049
+ isInsideCanvas(x, y) {
2050
+ return x >= 0 && y >= 0 && x < this.canvasWidth && y < this.canvasHeight;
2051
+ }
2052
+ markSceneDirty() {
2053
+ this.sceneDirty = true;
2054
+ }
2055
+ setStatus(message) {
2056
+ this.status = message;
2057
+ }
2058
+ }