@teachinglab/omd 0.7.14 → 0.7.16

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.
@@ -1,9 +1,16 @@
1
1
  import { Tool } from './tool.js';
2
+ import { BoundingBox } from '../utils/boundingBox.js';
3
+ import { Stroke } from '../drawing/stroke.js';
4
+ import { ResizeHandleManager } from '../features/resizeHandleManager.js';
5
+ import {omdColor} from '../../src/omdColor.js';
6
+ const SELECTION_TOLERANCE = 10;
7
+ const SELECTION_COLOR = '#007bff';
8
+ const SELECTION_OPACITY = 0.3;
2
9
 
3
10
  /**
4
- * Pointer/Browse tool
5
- * Allows browsing and interacting with interactive components without selecting or moving items.
6
- * Use this tool to click buttons, interact with UI elements, etc. without modifying the canvas.
11
+ * Pointer/Select tool
12
+ * Combined tool for browsing, selecting, moving, and interacting with elements.
13
+ * @extends Tool
7
14
  */
8
15
  export class PointerTool extends Tool {
9
16
  /**
@@ -11,61 +18,1305 @@ export class PointerTool extends Tool {
11
18
  * @param {object} [options={}]
12
19
  */
13
20
  constructor(canvas, options = {}) {
14
- super(canvas, { ...options });
21
+ super(canvas, {
22
+ selectionColor: SELECTION_COLOR,
23
+ selectionOpacity: SELECTION_OPACITY,
24
+ ...options
25
+ });
15
26
 
16
27
  this.displayName = 'Pointer';
17
- this.description = 'Browse and interact with components';
28
+ this.description = 'Select, move, and interact';
18
29
  this.icon = 'pointer';
19
30
  this.shortcut = 'V';
20
- this.category = 'navigation';
31
+ this.category = 'selection';
32
+
33
+ /** @private */
34
+ this.isSelecting = false;
35
+ /** @private */
36
+ this.selectionBox = null;
37
+ /** @private */
38
+ this.startPoint = null;
39
+ /** @type {Map<string, Set<number>>} */
40
+ this.selectedSegments = new Map();
41
+
42
+ /** @private - OMD dragging state */
43
+ this.isDraggingOMD = false;
44
+ this.draggedOMDElement = null;
45
+ this.selectedOMDElements = new Set();
46
+
47
+ /** @private - Stroke dragging state */
48
+ this.isDraggingStrokes = false;
49
+ this.dragStartPoint = null;
50
+ this.potentialDeselect = null;
51
+ this.hasSeparatedForDrag = false;
52
+
53
+ // Initialize resize handle manager for OMD visuals
54
+ this.resizeHandleManager = new ResizeHandleManager(canvas);
55
+
56
+ // Store reference on canvas for makeDraggable to access
57
+ if (canvas) {
58
+ canvas.resizeHandleManager = this.resizeHandleManager;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Handles the pointer down event to start a selection.
64
+ * @param {PointerEvent} event - The pointer event.
65
+ */
66
+ onPointerDown(event) {
67
+ if (!this.canUse()) {
68
+ return;
69
+ }
70
+
71
+ // Check for resize handle first (highest priority)
72
+ const handle = this.resizeHandleManager.getHandleAtPoint(event.x, event.y);
73
+ if (handle) {
74
+ // Start resize operation
75
+ this.resizeHandleManager.startResize(handle, event.x, event.y, event.shiftKey);
76
+ if (this.canvas.eventManager) {
77
+ this.canvas.eventManager.isDrawing = true;
78
+ }
79
+ return;
80
+ }
81
+
82
+ const segmentSelection = this._findSegmentAtPoint(event.x, event.y);
83
+ let omdElement = this._findOMDElementAtPoint(event.x, event.y);
84
+ if (omdElement?.dataset?.locked === 'true') {
85
+ omdElement = null;
86
+ }
87
+
88
+ if (segmentSelection) {
89
+ // Check if already selected
90
+ const isSelected = this._isSegmentSelected(segmentSelection);
91
+
92
+ if (isSelected) {
93
+ // Already selected - prepare for drag, but don't deselect yet
94
+ this.isDraggingStrokes = true;
95
+ this.hasSeparatedForDrag = false;
96
+ this.dragStartPoint = { x: event.x, y: event.y };
97
+ this.potentialDeselect = segmentSelection;
98
+
99
+ // Also enable OMD dragging if we have selected OMD elements
100
+ if (this.selectedOMDElements.size > 0) {
101
+ this.isDraggingOMD = true;
102
+ this.startPoint = { x: event.x, y: event.y };
103
+ }
104
+
105
+ // Set isDrawing so we get pointermove events
106
+ if (this.canvas.eventManager) {
107
+ this.canvas.eventManager.isDrawing = true;
108
+ }
109
+ return;
110
+ } else {
111
+ // Clicking on a stroke segment
112
+ if (!event.shiftKey) {
113
+ this.resizeHandleManager.clearSelection();
114
+ this.selectedOMDElements.clear();
115
+ }
116
+ this._handleSegmentClick(segmentSelection, event.shiftKey);
117
+
118
+ // Prepare for drag immediately after selection
119
+ this.isDraggingStrokes = true;
120
+ this.hasSeparatedForDrag = false;
121
+ this.dragStartPoint = { x: event.x, y: event.y };
122
+
123
+ if (this.canvas.eventManager) {
124
+ this.canvas.eventManager.isDrawing = true;
125
+ }
126
+ }
127
+ } else if (omdElement) {
128
+ // Clicking on an OMD visual
129
+
130
+ // Check if already selected
131
+ if (this.selectedOMDElements.has(omdElement)) {
132
+ // Already selected - prepare for drag
133
+ this.isDraggingOMD = true;
134
+ this.draggedOMDElement = omdElement; // Primary drag target
135
+ this.startPoint = { x: event.x, y: event.y };
136
+
137
+ // Also enable stroke dragging if we have selected strokes
138
+ if (this.selectedSegments.size > 0) {
139
+ this.isDraggingStrokes = true;
140
+ this.dragStartPoint = { x: event.x, y: event.y };
141
+ this.hasSeparatedForDrag = false;
142
+ }
143
+
144
+ // Show resize handles if this is the only selected element
145
+ if (this.selectedOMDElements.size === 1) {
146
+ this.resizeHandleManager.selectElement(omdElement);
147
+ }
148
+
149
+ if (this.canvas.eventManager) {
150
+ this.canvas.eventManager.isDrawing = true;
151
+ }
152
+ return;
153
+ }
154
+
155
+ // New selection
156
+ if (!event.shiftKey) {
157
+ this.selectedSegments.clear();
158
+ this._clearSelectionVisuals();
159
+ this.selectedOMDElements.clear();
160
+ this.resizeHandleManager.clearSelection();
161
+ }
162
+
163
+ this.selectedOMDElements.add(omdElement);
164
+ this.resizeHandleManager.selectElement(omdElement);
165
+
166
+ // Start tracking for potential drag operation
167
+ this.isDraggingOMD = true;
168
+ this.draggedOMDElement = omdElement;
169
+ this.startPoint = { x: event.x, y: event.y };
170
+
171
+ // Set isDrawing so we get pointermove events
172
+ if (this.canvas.eventManager) {
173
+ this.canvas.eventManager.isDrawing = true;
174
+ }
175
+
176
+ return;
177
+ } else {
178
+ // Check if clicking inside existing selection bounds
179
+ const selectionBounds = this._getSelectionBounds();
180
+ if (selectionBounds &&
181
+ event.x >= selectionBounds.x &&
182
+ event.x <= selectionBounds.x + selectionBounds.width &&
183
+ event.y >= selectionBounds.y &&
184
+ event.y <= selectionBounds.y + selectionBounds.height) {
185
+
186
+ // Drag the selection (strokes AND OMD elements)
187
+ this.isDraggingStrokes = true; // We reuse this flag for general dragging
188
+ this.isDraggingOMD = true; // Also set this for OMD elements
189
+ this.hasSeparatedForDrag = false;
190
+ this.dragStartPoint = { x: event.x, y: event.y };
191
+ this.startPoint = { x: event.x, y: event.y }; // For OMD dragging
192
+
193
+ if (this.canvas.eventManager) {
194
+ this.canvas.eventManager.isDrawing = true;
195
+ }
196
+ return;
197
+ }
198
+
199
+ // Clicking on empty space - clear all selections and start box selection
200
+ this.resizeHandleManager.clearSelection();
201
+ this.selectedOMDElements.clear();
202
+ this._startBoxSelection(event.x, event.y, event.shiftKey);
203
+
204
+ // CRITICAL: Set startPoint AFTER _startBoxSelection so it doesn't get cleared!
205
+ this.startPoint = { x: event.x, y: event.y };
206
+
207
+ // CRITICAL: Tell the event manager we're "drawing" so pointer move events get sent to us
208
+ if (this.canvas.eventManager) {
209
+ this.canvas.eventManager.isDrawing = true;
210
+ }
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Handles the pointer move event to update the selection box.
216
+ * @param {PointerEvent} event - The pointer event.
217
+ */
218
+ onPointerMove(event) {
219
+ // Handle resize operation if in progress
220
+ if (this.resizeHandleManager.isResizing) {
221
+ this.resizeHandleManager.updateResize(event.x, event.y);
222
+ return;
223
+ }
224
+
225
+ let handled = false;
226
+
227
+ // Handle OMD dragging if in progress
228
+ if (this.isDraggingOMD) {
229
+ this._dragOMDElements(event.x, event.y);
230
+ handled = true;
231
+ }
232
+
233
+ // Handle stroke dragging if in progress
234
+ if (this.isDraggingStrokes && this.dragStartPoint) {
235
+ const dx = event.x - this.dragStartPoint.x;
236
+ const dy = event.y - this.dragStartPoint.y;
237
+
238
+ if (dx !== 0 || dy !== 0) {
239
+ // If we moved, it's a drag, so cancel potential deselect
240
+ this.potentialDeselect = null;
241
+
242
+ // Separate selected parts if needed
243
+ if (!this.hasSeparatedForDrag) {
244
+ this._separateSelectedParts();
245
+ this.hasSeparatedForDrag = true;
246
+ }
247
+
248
+ // Move all selected strokes
249
+ const movedStrokes = new Set();
250
+ for (const [strokeId, _] of this.selectedSegments) {
251
+ const stroke = this.canvas.strokes.get(strokeId);
252
+ if (stroke) {
253
+ stroke.move(dx, dy);
254
+ movedStrokes.add(strokeId);
255
+ }
256
+ }
257
+
258
+ this.dragStartPoint = { x: event.x, y: event.y };
259
+ this._updateSegmentSelectionVisuals();
260
+
261
+ // Emit event
262
+ this.canvas.emit('strokesMoved', {
263
+ dx, dy,
264
+ strokeIds: Array.from(movedStrokes)
265
+ });
266
+ }
267
+ handled = true;
268
+ }
269
+
270
+ if (handled) return;
271
+
272
+ // Handle box selection if in progress
273
+ if (!this.isSelecting || !this.selectionBox) return;
274
+
275
+ this._updateSelectionBox(event.x, event.y);
276
+ this._updateBoxSelection();
21
277
  }
22
278
 
279
+ /**
280
+ * Handles the pointer up event to complete the selection.
281
+ * @param {PointerEvent} event - The pointer event.
282
+ */
283
+ onPointerUp(event) {
284
+ // Handle resize completion
285
+ if (this.resizeHandleManager.isResizing) {
286
+ this.resizeHandleManager.finishResize();
287
+ if (this.canvas.eventManager) {
288
+ this.canvas.eventManager.isDrawing = false;
289
+ }
290
+ return;
291
+ }
292
+
293
+ // Handle OMD drag completion
294
+ if (this.isDraggingOMD) {
295
+ this.isDraggingOMD = false;
296
+ this.draggedOMDElement = null;
297
+ this.startPoint = null;
298
+ if (this.canvas.eventManager) {
299
+ this.canvas.eventManager.isDrawing = false;
300
+ }
301
+ return;
302
+ }
303
+
304
+ // Handle stroke drag completion
305
+ if (this.isDraggingStrokes) {
306
+ if (this.potentialDeselect) {
307
+ // We clicked a selected segment but didn't drag -> toggle selection
308
+ this._handleSegmentClick(this.potentialDeselect, event.shiftKey);
309
+ this.potentialDeselect = null;
310
+ }
311
+
312
+ this.isDraggingStrokes = false;
313
+ this.dragStartPoint = null;
314
+
315
+ if (this.canvas.eventManager) {
316
+ this.canvas.eventManager.isDrawing = false;
317
+ }
318
+ return;
319
+ }
320
+
321
+ // Handle box selection completion
322
+ if (this.isSelecting) {
323
+ this._finishBoxSelection();
324
+ }
325
+ this.isSelecting = false;
326
+ this._removeSelectionBox();
327
+
328
+ // CRITICAL: Tell the event manager we're done "drawing"
329
+ if (this.canvas.eventManager) {
330
+ this.canvas.eventManager.isDrawing = false;
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Cancels the current selection operation.
336
+ */
337
+ onCancel() {
338
+ this.isSelecting = false;
339
+ this._removeSelectionBox();
340
+ this.clearSelection();
341
+
342
+ // Reset OMD drag state
343
+ this.isDraggingOMD = false;
344
+ this.draggedOMDElement = null;
345
+
346
+ // CRITICAL: Tell the event manager we're done "drawing"
347
+ if (this.canvas.eventManager) {
348
+ this.canvas.eventManager.isDrawing = false;
349
+ }
350
+
351
+ super.onCancel();
352
+ }
353
+
354
+ /**
355
+ * Handle tool deactivation - clean up everything.
356
+ */
357
+ onDeactivate() {
358
+ // Clear active state
359
+ this.isActive = false;
360
+
361
+ // Reset drag and resize states
362
+ this.isDraggingOMD = false;
363
+ this.draggedOMDElement = null;
364
+ this.isSelecting = false;
365
+ this.startPoint = null;
366
+
367
+ // Clear the event manager drawing state
368
+ if (this.canvas.eventManager) {
369
+ this.canvas.eventManager.isDrawing = false;
370
+ }
371
+
372
+ // Clean up selection
373
+ this.clearSelection();
374
+
375
+ super.onDeactivate();
376
+ }
377
+
378
+ /**
379
+ * Handle tool activation.
380
+ */
23
381
  onActivate() {
382
+ // Set active state first
24
383
  this.isActive = true;
384
+
385
+ // Reset all state flags
386
+ this.isDraggingOMD = false;
387
+ this.draggedOMDElement = null;
388
+ this.isSelecting = false;
389
+ this.startPoint = null;
390
+
391
+ // Ensure cursor is visible and properly set
25
392
  if (this.canvas.cursor) {
26
393
  this.canvas.cursor.show();
27
- this.canvas.cursor.setShape('pointer');
394
+ this.canvas.cursor.setShape('pointer'); // Use pointer arrow cursor
28
395
  }
396
+
397
+ // Clear any existing selection to start fresh
398
+ this.clearSelection();
399
+
29
400
  super.onActivate();
30
401
  }
31
402
 
32
- onDeactivate() {
33
- this.isActive = false;
34
- super.onDeactivate();
403
+ /**
404
+ * Handles keyboard shortcuts for selection-related actions.
405
+ * @param {string} key - The key that was pressed.
406
+ * @param {KeyboardEvent} event - The keyboard event.
407
+ * @returns {boolean} - True if the shortcut was handled, false otherwise.
408
+ */
409
+ onKeyboardShortcut(key, event) {
410
+ if (event.ctrlKey || event.metaKey) {
411
+ if (key === 'a') {
412
+ this._selectAllSegments();
413
+ return true;
414
+ }
415
+ }
416
+
417
+ if (key === 'delete' || key === 'backspace') {
418
+ this._deleteSelectedSegments();
419
+ return true;
420
+ }
421
+
422
+ return false;
35
423
  }
36
424
 
37
- onPointerDown(event) {
38
- // Pointer tool is completely passive - it does nothing
39
- // This allows click events to pass through to interactive components
40
- // but prevents any canvas manipulation (drawing, selecting, moving)
425
+ /**
426
+ * Gets the cursor for the tool.
427
+ * @returns {string} The CSS cursor name.
428
+ */
429
+ getCursor() {
430
+ // If resizing, return appropriate resize cursor
431
+ if (this.resizeHandleManager.isResizing) {
432
+ return this.resizeHandleManager.getCursorForHandle(
433
+ this.resizeHandleManager.resizeData?.handle?.type || 'se'
434
+ );
435
+ }
41
436
 
42
- // Explicitly ensure we are not entering a drawing/dragging state
43
- if (this.canvas?.eventManager) {
44
- this.canvas.eventManager.isDrawing = false;
437
+ return 'default'; // Use default cursor instead of 'select'
438
+ }
439
+
440
+ /**
441
+ * Check if tool can be used
442
+ * @returns {boolean}
443
+ */
444
+ canUse() {
445
+ // Use the base class method which checks isActive and canvas state
446
+ const result = super.canUse();
447
+
448
+ return result;
449
+ }
450
+
451
+ /**
452
+ * Clears the current selection.
453
+ */
454
+ clearSelection() {
455
+ // Clear stroke selection data
456
+ this.selectedSegments.clear();
457
+
458
+ // Clear OMD selection
459
+ this.selectedOMDElements.clear();
460
+ this.resizeHandleManager.clearSelection();
461
+
462
+ // Remove selection box if it exists
463
+ this._removeSelectionBox();
464
+
465
+ // Clear visual highlights
466
+ this._clearSelectionVisuals();
467
+
468
+ // Reset selection state
469
+ this.isSelecting = false;
470
+ this.startPoint = null;
471
+
472
+ // Emit selection change event
473
+ this.canvas.emit('selectionChanged', { selected: [] });
474
+ }
475
+
476
+ /**
477
+ * Thoroughly clears all selection visuals.
478
+ * @private
479
+ */
480
+ _clearSelectionVisuals() {
481
+ const selectionLayer = this.canvas.uiLayer.querySelector('.segment-selection-layer');
482
+ if (selectionLayer) {
483
+ // Remove all children
484
+ while (selectionLayer.firstChild) {
485
+ selectionLayer.removeChild(selectionLayer.firstChild);
486
+ }
45
487
  }
488
+
489
+ // Also remove any orphaned selection elements
490
+ const orphanedHighlights = this.canvas.uiLayer.querySelectorAll('[stroke="' + this.config.selectionColor + '"]');
491
+ orphanedHighlights.forEach(el => {
492
+ if (el.parentNode) {
493
+ el.parentNode.removeChild(el);
494
+ }
495
+ });
46
496
  }
47
497
 
48
- onPointerMove(event) {
49
- // No-op: allow hovering without any interaction
498
+ /**
499
+ * @private
500
+ */
501
+ _handleSegmentClick({ strokeId, segmentIndex }, shiftKey) {
502
+ const segmentSet = this.selectedSegments.get(strokeId) || new Set();
503
+ if (segmentSet.has(segmentIndex)) {
504
+ segmentSet.delete(segmentIndex);
505
+ if (segmentSet.size === 0) {
506
+ this.selectedSegments.delete(strokeId);
507
+ }
508
+ } else {
509
+ if (!shiftKey) {
510
+ this.selectedSegments.clear();
511
+ }
512
+ if (!this.selectedSegments.has(strokeId)) {
513
+ this.selectedSegments.set(strokeId, new Set());
514
+ }
515
+ this.selectedSegments.get(strokeId).add(segmentIndex);
516
+ }
517
+ this._updateSegmentSelectionVisuals();
518
+ this.canvas.emit('selectionChanged', { selected: this._getSelectedSegmentsAsArray() });
50
519
  }
51
520
 
52
- onPointerUp(event) {
53
- // No-op: do not modify selections or positions
521
+ /**
522
+ * @private
523
+ */
524
+ _startBoxSelection(x, y, shiftKey) {
525
+ if (!shiftKey) {
526
+ this.clearSelection();
527
+ }
528
+ this.isSelecting = true;
529
+ this._createSelectionBox(x, y);
54
530
  }
55
531
 
56
- onKeyboardShortcut(key, event) {
57
- // No shortcuts to handle for passive pointer tool
58
- return false;
532
+ /**
533
+ * @private
534
+ */
535
+ _finishBoxSelection() {
536
+ this._updateSegmentSelectionVisuals();
537
+ this.canvas.emit('selectionChanged', { selected: this._getSelectedSegmentsAsArray() });
59
538
  }
60
539
 
61
- onKeyboardShortcut(key, event) {
62
- // No shortcuts to handle for passive pointer tool
540
+ /**
541
+ * @private
542
+ */
543
+ _getSelectedSegmentsAsArray() {
544
+ const selected = [];
545
+ for (const [strokeId, segmentSet] of this.selectedSegments.entries()) {
546
+ for (const segmentIndex of segmentSet) {
547
+ selected.push({ strokeId, segmentIndex });
548
+ }
549
+ }
550
+ return selected;
551
+ }
552
+
553
+ /**
554
+ * Checks if a segment is currently selected.
555
+ * @private
556
+ * @param {{strokeId: string, segmentIndex: number}} selection - The selection to check.
557
+ * @returns {boolean}
558
+ */
559
+ _isSegmentSelected({ strokeId, segmentIndex }) {
560
+ const segmentSet = this.selectedSegments.get(strokeId);
561
+ return segmentSet ? segmentSet.has(segmentIndex) : false;
562
+ }
563
+
564
+ /**
565
+ * Gets the bounding box of the current selection (strokes + OMD).
566
+ * @private
567
+ * @returns {{x: number, y: number, width: number, height: number}|null}
568
+ */
569
+ _getSelectionBounds() {
570
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
571
+ let hasPoints = false;
572
+
573
+ // 1. Check strokes
574
+ if (this.selectedSegments.size > 0) {
575
+ for (const [strokeId, segmentSet] of this.selectedSegments.entries()) {
576
+ const stroke = this.canvas.strokes.get(strokeId);
577
+ if (!stroke || !stroke.points) continue;
578
+
579
+ for (const segmentIndex of segmentSet) {
580
+ if (segmentIndex >= stroke.points.length - 1) continue;
581
+ const p1 = stroke.points[segmentIndex];
582
+ const p2 = stroke.points[segmentIndex + 1];
583
+
584
+ minX = Math.min(minX, p1.x, p2.x);
585
+ minY = Math.min(minY, p1.y, p2.y);
586
+ maxX = Math.max(maxX, p1.x, p2.x);
587
+ maxY = Math.max(maxY, p1.y, p2.y);
588
+ hasPoints = true;
589
+ }
590
+ }
591
+ }
592
+
593
+ // 2. Check OMD elements
594
+ if (this.selectedOMDElements.size > 0) {
595
+ for (const element of this.selectedOMDElements) {
596
+ const bbox = this._getOMDElementBounds(element);
597
+ if (bbox) {
598
+ minX = Math.min(minX, bbox.x);
599
+ minY = Math.min(minY, bbox.y);
600
+ maxX = Math.max(maxX, bbox.x + bbox.width);
601
+ maxY = Math.max(maxY, bbox.y + bbox.height);
602
+ hasPoints = true;
603
+ }
604
+ }
605
+ }
606
+
607
+ if (!hasPoints) return null;
608
+
609
+ // Add padding to match the visual box
610
+ const padding = 8;
611
+ return {
612
+ x: minX - padding,
613
+ y: minY - padding,
614
+ width: (maxX + padding) - (minX - padding),
615
+ height: (maxY + padding) - (minY - padding)
616
+ };
617
+ }
618
+
619
+ /**
620
+ * Drags all selected OMD elements
621
+ * @private
622
+ * @param {number} x - Current pointer x coordinate
623
+ * @param {number} y - Current pointer y coordinate
624
+ */
625
+ _dragOMDElements(x, y) {
626
+ if (!this.startPoint) return;
627
+
628
+ const dx = x - this.startPoint.x;
629
+ const dy = y - this.startPoint.y;
630
+
631
+ if (dx === 0 && dy === 0) return;
632
+
633
+ for (const element of this.selectedOMDElements) {
634
+ this._moveOMDElement(element, dx, dy);
635
+ }
636
+
637
+ // Update start point for next move
638
+ this.startPoint = { x, y };
639
+
640
+ // Update resize handles if we have a single selection
641
+ if (this.selectedOMDElements.size === 1) {
642
+ const element = this.selectedOMDElements.values().next().value;
643
+ this.resizeHandleManager.updateIfSelected(element);
644
+ }
645
+
646
+ this._updateSegmentSelectionVisuals();
647
+ }
648
+
649
+ /**
650
+ * Moves a single OMD element
651
+ * @private
652
+ */
653
+ _moveOMDElement(element, dx, dy) {
654
+ // Parse current transform
655
+ const currentTransform = element.getAttribute('transform') || '';
656
+ const translateMatch = currentTransform.match(/translate\(\s*([^,]+)\s*,\s*([^)]+)\s*\)/);
657
+ const scaleMatch = currentTransform.match(/scale\(\s*([^,)]+)(?:\s*,\s*([^)]+))?\s*\)/);
658
+
659
+ // Get current translate values
660
+ let currentX = translateMatch ? parseFloat(translateMatch[1]) : 0;
661
+ let currentY = translateMatch ? parseFloat(translateMatch[2]) : 0;
662
+
663
+ // Calculate new position
664
+ const newX = currentX + dx;
665
+ const newY = currentY + dy;
666
+
667
+ // Build new transform preserving scale
668
+ let newTransform = `translate(${newX}, ${newY})`;
669
+ if (scaleMatch) {
670
+ const scaleX = parseFloat(scaleMatch[1]) || 1;
671
+ const scaleY = scaleMatch[2] ? parseFloat(scaleMatch[2]) : scaleX;
672
+ newTransform += ` scale(${scaleX}, ${scaleY})`;
673
+ }
674
+
675
+ element.setAttribute('transform', newTransform);
676
+ }
677
+
678
+ /**
679
+ * Gets the bounds of an OMD element including transform
680
+ * Uses clip paths for accurate bounds when present (e.g., coordinate planes)
681
+ * @private
682
+ */
683
+ _getOMDElementBounds(item) {
684
+ try {
685
+ const transform = item.getAttribute('transform') || '';
686
+ let offsetX = 0, offsetY = 0, scaleX = 1, scaleY = 1;
687
+
688
+ const translateMatch = transform.match(/translate\(\s*([^,]+)\s*,\s*([^)]+)\s*\)/);
689
+ if (translateMatch) {
690
+ offsetX = parseFloat(translateMatch[1]) || 0;
691
+ offsetY = parseFloat(translateMatch[2]) || 0;
692
+ }
693
+
694
+ const scaleMatch = transform.match(/scale\(\s*([^,)]+)(?:\s*,\s*([^)]+))?\s*\)/);
695
+ if (scaleMatch) {
696
+ scaleX = parseFloat(scaleMatch[1]) || 1;
697
+ scaleY = scaleMatch[2] ? parseFloat(scaleMatch[2]) : scaleX;
698
+ }
699
+
700
+ // Get the content element (usually an SVG inside the wrapper)
701
+ const content = item.firstElementChild;
702
+ let bbox;
703
+
704
+ if (content) {
705
+ // Check for clip paths to get accurate visible bounds
706
+ // This is important for coordinate planes where graph lines extend beyond visible area
707
+ const clipPaths = content.querySelectorAll('clipPath');
708
+
709
+ if (clipPaths.length > 0) {
710
+ let maxArea = 0;
711
+ let clipBounds = null;
712
+
713
+ for (const clipPath of clipPaths) {
714
+ const rect = clipPath.querySelector('rect');
715
+ if (rect) {
716
+ const w = parseFloat(rect.getAttribute('width')) || 0;
717
+ const h = parseFloat(rect.getAttribute('height')) || 0;
718
+ const x = parseFloat(rect.getAttribute('x')) || 0;
719
+ const y = parseFloat(rect.getAttribute('y')) || 0;
720
+
721
+ const area = w * h;
722
+ if (area > maxArea) {
723
+ maxArea = area;
724
+
725
+ // Find the transform on the content group
726
+ const contentGroup = content.firstElementChild;
727
+ let tx = 0, ty = 0;
728
+ if (contentGroup) {
729
+ const contentTransform = contentGroup.getAttribute('transform');
730
+ if (contentTransform) {
731
+ const contentTranslateMatch = contentTransform.match(/translate\(\s*([^,]+)(?:,\s*([^)]+))?\s*\)/);
732
+ if (contentTranslateMatch) {
733
+ tx = parseFloat(contentTranslateMatch[1]) || 0;
734
+ ty = parseFloat(contentTranslateMatch[2]) || 0;
735
+ }
736
+ }
737
+ }
738
+
739
+ clipBounds = {
740
+ x: x + tx,
741
+ y: y + ty,
742
+ width: w,
743
+ height: h
744
+ };
745
+ }
746
+ }
747
+ }
748
+
749
+ if (clipBounds) {
750
+ bbox = clipBounds;
751
+ }
752
+ }
753
+
754
+ // Fallback to getBBox if no clip path found
755
+ if (!bbox) {
756
+ bbox = content.getBBox();
757
+ }
758
+ } else {
759
+ bbox = item.getBBox();
760
+ }
761
+
762
+ return {
763
+ x: offsetX + (bbox.x * scaleX),
764
+ y: offsetY + (bbox.y * scaleY),
765
+ width: bbox.width * scaleX,
766
+ height: bbox.height * scaleY
767
+ };
768
+ } catch (e) {
769
+ return null;
770
+ }
771
+ }
772
+
773
+ /**
774
+ * Finds the closest segment to a given point.
775
+ * @private
776
+ * @param {number} x - The x-coordinate of the point.
777
+ * @param {number} y - The y-coordinate of the point.
778
+ * @returns {{strokeId: string, segmentIndex: number}|null}
779
+ */
780
+ _findSegmentAtPoint(x, y) {
781
+ let closest = null;
782
+ let minDist = SELECTION_TOLERANCE;
783
+ for (const [id, stroke] of this.canvas.strokes) {
784
+ if (!stroke.points || stroke.points.length < 2) continue;
785
+ for (let i = 0; i < stroke.points.length - 1; i++) {
786
+ const p1 = stroke.points[i];
787
+ const p2 = stroke.points[i + 1];
788
+ const dist = this._pointToSegmentDistance(x, y, p1, p2);
789
+ if (dist < minDist) {
790
+ minDist = dist;
791
+ closest = { strokeId: id, segmentIndex: i };
792
+ }
793
+ }
794
+ }
795
+ return closest;
796
+ }
797
+
798
+ /**
799
+ * Check if point is inside an OMD visual element
800
+ * @private
801
+ */
802
+ _findOMDElementAtPoint(x, y) {
803
+ // Find OMD layer directly from canvas
804
+ const omdLayer = this.canvas.drawingLayer?.querySelector('.omd-layer');
805
+ if (!omdLayer) {
806
+ return null;
807
+ }
808
+
809
+ // Check all OMD items in the layer
810
+ const omdItems = omdLayer.querySelectorAll('.omd-item');
811
+
812
+ for (const item of omdItems) {
813
+ if (item?.dataset?.locked === 'true') {
814
+ continue;
815
+ }
816
+ try {
817
+ // Use the shared bounds calculation method (handles clip paths correctly)
818
+ const itemBounds = this._getOMDElementBounds(item);
819
+
820
+ if (itemBounds) {
821
+ // Add some padding for easier clicking
822
+ const padding = 10;
823
+
824
+ // Check if point is within the bounds (with padding)
825
+ if (x >= itemBounds.x - padding &&
826
+ x <= itemBounds.x + itemBounds.width + padding &&
827
+ y >= itemBounds.y - padding &&
828
+ y <= itemBounds.y + itemBounds.height + padding) {
829
+ return item;
830
+ }
831
+ }
832
+ } catch (error) {
833
+ // Skip items that can't be measured (continue with next)
834
+ continue;
835
+ }
836
+ }
837
+
838
+ return null;
839
+ }
840
+
841
+ /**
842
+ * Calculates the distance from a point to a line segment.
843
+ * @private
844
+ */
845
+ _pointToSegmentDistance(x, y, p1, p2) {
846
+ const l2 = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2;
847
+ if (l2 === 0) return Math.hypot(x - p1.x, y - p1.y);
848
+ let t = ((x - p1.x) * (p2.x - p1.x) + (y - p1.y) * (p2.y - p1.y)) / l2;
849
+ t = Math.max(0, Math.min(1, t));
850
+ const projX = p1.x + t * (p2.x - p1.x);
851
+ const projY = p1.y + t * (p2.y - p1.y);
852
+ return Math.hypot(x - projX, y - projY);
853
+ }
854
+
855
+ /**
856
+ * Creates the selection box element.
857
+ * @private
858
+ */
859
+ _createSelectionBox(x, y) {
860
+ this.selectionBox = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
861
+ this.selectionBox.setAttribute('x', x);
862
+ this.selectionBox.setAttribute('y', y);
863
+ this.selectionBox.setAttribute('width', 0);
864
+ this.selectionBox.setAttribute('height', 0);
865
+
866
+ // Clean blue selection box
867
+ this.selectionBox.setAttribute('fill', 'rgba(0, 123, 255, 0.2)'); // Blue with transparency
868
+ this.selectionBox.setAttribute('stroke', '#007bff'); // Blue stroke
869
+ this.selectionBox.setAttribute('stroke-width', '1'); // Thin stroke
870
+ this.selectionBox.setAttribute('stroke-dasharray', '4,2'); // Small dashes
871
+ this.selectionBox.style.pointerEvents = 'none';
872
+ this.selectionBox.setAttribute('data-selection-box', 'true');
873
+
874
+
875
+ if (this.canvas.uiLayer) {
876
+ this.canvas.uiLayer.appendChild(this.selectionBox);
877
+ } else if (this.canvas.svg) {
878
+ this.canvas.svg.appendChild(this.selectionBox);
879
+ } else {
880
+ console.error('No canvas layer found to add selection box!');
881
+ }
882
+ }
883
+
884
+ /**
885
+ * Updates the dimensions of the selection box.
886
+ * @private
887
+ */
888
+ _updateSelectionBox(x, y) {
889
+ if (!this.selectionBox || !this.startPoint) return;
890
+
891
+ const minX = Math.min(this.startPoint.x, x);
892
+ const minY = Math.min(this.startPoint.y, y);
893
+ const width = Math.abs(this.startPoint.x - x);
894
+ const height = Math.abs(this.startPoint.y - y);
895
+
896
+ this.selectionBox.setAttribute('x', minX);
897
+ this.selectionBox.setAttribute('y', minY);
898
+ this.selectionBox.setAttribute('width', width);
899
+ this.selectionBox.setAttribute('height', height);
900
+ }
901
+
902
+ /**
903
+ * Removes the selection box element.
904
+ * @private
905
+ */
906
+ _removeSelectionBox() {
907
+ if (this.selectionBox) {
908
+ this.selectionBox.remove();
909
+ this.selectionBox = null;
910
+ }
911
+ this.startPoint = null;
912
+ }
913
+
914
+ /**
915
+ * Updates the set of selected segments based on the selection box.
916
+ * @private
917
+ */
918
+ _updateBoxSelection() {
919
+ if (!this.selectionBox) return;
920
+
921
+ const x = parseFloat(this.selectionBox.getAttribute('x'));
922
+ const y = parseFloat(this.selectionBox.getAttribute('y'));
923
+ const width = parseFloat(this.selectionBox.getAttribute('width'));
924
+ const height = parseFloat(this.selectionBox.getAttribute('height'));
925
+ const selectionBounds = new BoundingBox(x, y, width, height);
926
+
927
+ // 1. Select strokes
928
+ for (const [id, stroke] of this.canvas.strokes) {
929
+ if (!stroke.points || stroke.points.length < 2) continue;
930
+ for (let i = 0; i < stroke.points.length - 1; i++) {
931
+ const p1 = stroke.points[i];
932
+ const p2 = stroke.points[i + 1];
933
+ if (this._segmentIntersectsBox(p1, p2, selectionBounds)) {
934
+ if (!this.selectedSegments.has(id)) {
935
+ this.selectedSegments.set(id, new Set());
936
+ }
937
+ this.selectedSegments.get(id).add(i);
938
+ }
939
+ }
940
+ }
941
+
942
+ // 2. Select OMD elements
943
+ const omdLayer = this.canvas.drawingLayer?.querySelector('.omd-layer');
944
+ if (omdLayer) {
945
+ const omdItems = omdLayer.querySelectorAll('.omd-item');
946
+ for (const item of omdItems) {
947
+ if (item?.dataset?.locked === 'true') continue;
948
+
949
+ const itemBounds = this._getOMDElementBounds(item);
950
+ if (itemBounds) {
951
+ // Check intersection
952
+ const intersects = !(itemBounds.x > x + width ||
953
+ itemBounds.x + itemBounds.width < x ||
954
+ itemBounds.y > y + height ||
955
+ itemBounds.y + itemBounds.height < y);
956
+
957
+ if (intersects) {
958
+ this.selectedOMDElements.add(item);
959
+ }
960
+ }
961
+ }
962
+ }
963
+
964
+ // Update resize handles
965
+ this.resizeHandleManager.clearSelection();
966
+
967
+ this._updateSegmentSelectionVisuals();
968
+ }
969
+
970
+ /**
971
+ * Checks if a line segment intersects with a bounding box.
972
+ * @private
973
+ * @param {{x: number, y: number}} p1 - The start point of the segment.
974
+ * @param {{x: number, y: number}} p2 - The end point of the segment.
975
+ * @param {BoundingBox} box - The bounding box.
976
+ * @returns {boolean}
977
+ */
978
+ _segmentIntersectsBox(p1, p2, box) {
979
+ if (box.containsPoint(p1.x, p1.y) || box.containsPoint(p2.x, p2.y)) {
980
+ return true;
981
+ }
982
+
983
+ const { x, y, width, height } = box;
984
+ const right = x + width;
985
+ const bottom = y + height;
986
+
987
+ const lines = [
988
+ { a: { x, y }, b: { x: right, y } },
989
+ { a: { x: right, y }, b: { x: right, y: bottom } },
990
+ { a: { x: right, y: bottom }, b: { x, y: bottom } },
991
+ { a: { x, y: bottom }, b: { x, y } }
992
+ ];
993
+
994
+ for (const line of lines) {
995
+ if (this._lineIntersectsLine(p1, p2, line.a, line.b)) {
996
+ return true;
997
+ }
998
+ }
63
999
  return false;
64
1000
  }
65
1001
 
66
- getCursor() {
67
- return 'pointer';
1002
+ /**
1003
+ * Checks if two line segments intersect.
1004
+ * @private
1005
+ */
1006
+ _lineIntersectsLine(p1, p2, p3, p4) {
1007
+ const det = (p2.x - p1.x) * (p4.y - p3.y) - (p2.y - p1.y) * (p4.x - p3.x);
1008
+ if (det === 0) return false;
1009
+ const t = ((p3.x - p1.x) * (p4.y - p3.y) - (p3.y - p1.y) * (p4.x - p3.x)) / det;
1010
+ const u = -((p2.x - p1.x) * (p3.y - p1.y) - (p2.y - p1.y) * (p3.x - p1.x)) / det;
1011
+ return t >= 0 && t <= 1 && u >= 0 && u <= 1;
68
1012
  }
69
- }
70
1013
 
1014
+ /**
1015
+ * Updates the visual representation of selected segments.
1016
+ * @private
1017
+ */
1018
+ _updateSegmentSelectionVisuals() {
1019
+ // Get or create selection layer
1020
+ const selectionLayer = this.canvas.uiLayer.querySelector('.segment-selection-layer') || this._createSelectionLayer();
1021
+
1022
+ // Clear existing visuals efficiently
1023
+ while (selectionLayer.firstChild) {
1024
+ selectionLayer.removeChild(selectionLayer.firstChild);
1025
+ }
1026
+
1027
+ const bounds = this._getSelectionBounds();
1028
+ if (!bounds) return;
1029
+
1030
+ // Create bounding box element
1031
+ const box = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
1032
+ box.setAttribute('x', bounds.x);
1033
+ box.setAttribute('y', bounds.y);
1034
+ box.setAttribute('width', bounds.width);
1035
+ box.setAttribute('height', bounds.height);
1036
+ box.setAttribute('fill', 'none');
1037
+ box.setAttribute('stroke', '#007bff'); // Selection color
1038
+ box.setAttribute('stroke-width', '1.5');
1039
+ box.setAttribute('stroke-dasharray', '6, 4'); // Dotted/Dashed
1040
+ box.setAttribute('stroke-opacity', '0.6'); // Light
1041
+ box.setAttribute('rx', '8'); // Rounded corners
1042
+ box.setAttribute('ry', '8');
1043
+ box.style.pointerEvents = 'none';
1044
+ box.classList.add('selection-bounds');
1045
+
1046
+ selectionLayer.appendChild(box);
1047
+ }
1048
+
1049
+ /**
1050
+ * @private
1051
+ */
1052
+ _createSelectionLayer() {
1053
+ const layer = document.createElementNS('http://www.w3.org/2000/svg', 'g');
1054
+ layer.classList.add('segment-selection-layer');
1055
+ this.canvas.uiLayer.appendChild(layer);
1056
+ return layer;
1057
+ }
1058
+
1059
+ /**
1060
+ * Selects all segments and OMD elements on the canvas.
1061
+ * @private
1062
+ */
1063
+ _selectAllSegments() {
1064
+ // Clear current selection
1065
+ this.selectedSegments.clear();
1066
+ this.selectedOMDElements.clear();
1067
+
1068
+ let totalSegments = 0;
1069
+
1070
+ // Select all valid segments from all strokes
1071
+ for (const [id, stroke] of this.canvas.strokes) {
1072
+ if (!stroke.points || stroke.points.length < 2) continue;
1073
+
1074
+ const segmentIndices = new Set();
1075
+ for (let i = 0; i < stroke.points.length - 1; i++) {
1076
+ segmentIndices.add(i);
1077
+ totalSegments++;
1078
+ }
1079
+
1080
+ if (segmentIndices.size > 0) {
1081
+ this.selectedSegments.set(id, segmentIndices);
1082
+ }
1083
+ }
1084
+
1085
+ // Select all OMD elements
1086
+ const omdLayer = this.canvas.drawingLayer?.querySelector('.omd-layer');
1087
+ if (omdLayer) {
1088
+ const omdItems = omdLayer.querySelectorAll('.omd-item');
1089
+ for (const item of omdItems) {
1090
+ if (item?.dataset?.locked !== 'true') {
1091
+ this.selectedOMDElements.add(item);
1092
+ }
1093
+ }
1094
+ }
1095
+
1096
+ // Update visuals
1097
+ this._updateSegmentSelectionVisuals();
1098
+
1099
+ // Emit selection change event
1100
+ this.canvas.emit('selectionChanged', {
1101
+ selected: this._getSelectedSegmentsAsArray()
1102
+ });
1103
+
1104
+ }
1105
+
1106
+ /**
1107
+ * Deletes all currently selected items (segments and OMD elements).
1108
+ * @private
1109
+ */
1110
+ _deleteSelectedSegments() {
1111
+ let changed = false;
1112
+
1113
+ // Delete OMD elements
1114
+ if (this.selectedOMDElements.size > 0) {
1115
+ for (const element of this.selectedOMDElements) {
1116
+ element.remove();
1117
+ }
1118
+ this.selectedOMDElements.clear();
1119
+ this.resizeHandleManager.clearSelection();
1120
+ changed = true;
1121
+ }
1122
+
1123
+ if (this.selectedSegments.size > 0) {
1124
+ // Process each stroke that has selected segments
1125
+ const strokesToProcess = Array.from(this.selectedSegments.entries());
1126
+
1127
+ for (const [strokeId, segmentIndices] of strokesToProcess) {
1128
+ const stroke = this.canvas.strokes.get(strokeId);
1129
+ if (!stroke || !stroke.points || stroke.points.length < 2) continue;
1130
+
1131
+ const sortedIndices = Array.from(segmentIndices).sort((a, b) => a - b);
1132
+
1133
+ // If all or most segments are selected, just delete the whole stroke
1134
+ const totalSegments = stroke.points.length - 1;
1135
+ const selectedCount = sortedIndices.length;
1136
+
1137
+ if (selectedCount >= totalSegments * 0.8) {
1138
+ // Delete entire stroke
1139
+ this.canvas.removeStroke(strokeId);
1140
+ continue;
1141
+ }
1142
+
1143
+ // Split the stroke, keeping only unselected segments
1144
+ this._splitStrokeKeepingUnselected(stroke, sortedIndices);
1145
+ }
1146
+ changed = true;
1147
+ }
1148
+
1149
+ if (changed) {
1150
+ // Clear selection and update UI
1151
+ this.clearSelection();
1152
+
1153
+ // Emit deletion event
1154
+ this.canvas.emit('selectionDeleted');
1155
+ }
1156
+ }
1157
+
1158
+ /**
1159
+ * Splits a stroke, keeping only the segments that weren't selected for deletion.
1160
+ * @private
1161
+ */
1162
+ _splitStrokeKeepingUnselected(stroke, selectedSegmentIndices) {
1163
+ const totalSegments = stroke.points.length - 1;
1164
+ const keepSegments = [];
1165
+
1166
+ // Determine which segments to keep
1167
+ for (let i = 0; i < totalSegments; i++) {
1168
+ if (!selectedSegmentIndices.includes(i)) {
1169
+ keepSegments.push(i);
1170
+ }
1171
+ }
1172
+
1173
+ if (keepSegments.length === 0) {
1174
+ // All segments were selected, delete the whole stroke
1175
+ this.canvas.removeStroke(stroke.id);
1176
+ return;
1177
+ }
1178
+
1179
+ // Group consecutive segments into separate strokes
1180
+ const strokeSegments = this._groupConsecutiveSegments(keepSegments);
1181
+
1182
+ // Remove the original stroke
1183
+ this.canvas.removeStroke(stroke.id);
1184
+
1185
+ // Create new strokes for each group of consecutive segments
1186
+ strokeSegments.forEach(segmentGroup => {
1187
+ this._createStrokeFromSegments(stroke, segmentGroup);
1188
+ });
1189
+ }
1190
+
1191
+ /**
1192
+ * Separates selected segments into new strokes so they can be moved independently.
1193
+ * @private
1194
+ */
1195
+ _separateSelectedParts() {
1196
+ const newSelection = new Map();
1197
+ const strokesToProcess = Array.from(this.selectedSegments.entries());
1198
+
1199
+ for (const [strokeId, selectedIndices] of strokesToProcess) {
1200
+ const stroke = this.canvas.strokes.get(strokeId);
1201
+ if (!stroke || !stroke.points || stroke.points.length < 2) continue;
1202
+
1203
+ const totalSegments = stroke.points.length - 1;
1204
+
1205
+ // If fully selected, just keep it as is
1206
+ if (selectedIndices.size === totalSegments) {
1207
+ newSelection.set(strokeId, selectedIndices);
1208
+ continue;
1209
+ }
1210
+
1211
+ // It's a partial selection - we need to split
1212
+ const sortedSelectedIndices = Array.from(selectedIndices).sort((a, b) => a - b);
1213
+
1214
+ // Determine unselected indices
1215
+ const unselectedIndices = [];
1216
+ for (let i = 0; i < totalSegments; i++) {
1217
+ if (!selectedIndices.has(i)) {
1218
+ unselectedIndices.push(i);
1219
+ }
1220
+ }
1221
+
1222
+ // Group segments
1223
+ const selectedGroups = this._groupConsecutiveSegments(sortedSelectedIndices);
1224
+ const unselectedGroups = this._groupConsecutiveSegments(unselectedIndices);
1225
+
1226
+ // Create new strokes for selected parts
1227
+ selectedGroups.forEach(group => {
1228
+ const newStroke = this._createStrokeFromSegments(stroke, group);
1229
+ if (newStroke) {
1230
+ // Add to new selection (all segments selected)
1231
+ const newIndices = new Set();
1232
+ for (let i = 0; i < newStroke.points.length - 1; i++) {
1233
+ newIndices.add(i);
1234
+ }
1235
+ newSelection.set(newStroke.id, newIndices);
1236
+ }
1237
+ });
1238
+
1239
+ // Create new strokes for unselected parts (don't add to selection)
1240
+ unselectedGroups.forEach(group => {
1241
+ this._createStrokeFromSegments(stroke, group);
1242
+ });
1243
+
1244
+ // Remove original stroke
1245
+ this.canvas.removeStroke(strokeId);
1246
+ }
1247
+
1248
+ this.selectedSegments = newSelection;
1249
+ }
1250
+
1251
+ /**
1252
+ * Groups consecutive segment indices into separate arrays.
1253
+ * @private
1254
+ */
1255
+ _groupConsecutiveSegments(segmentIndices) {
1256
+ if (segmentIndices.length === 0) return [];
1257
+
1258
+ const groups = [];
1259
+ let currentGroup = [segmentIndices[0]];
1260
+
1261
+ for (let i = 1; i < segmentIndices.length; i++) {
1262
+ const current = segmentIndices[i];
1263
+ const previous = segmentIndices[i - 1];
1264
+
1265
+ if (current === previous + 1) {
1266
+ // Consecutive segment
1267
+ currentGroup.push(current);
1268
+ } else {
1269
+ // Gap found, start new group
1270
+ groups.push(currentGroup);
1271
+ currentGroup = [current];
1272
+ }
1273
+ }
1274
+
1275
+ // Add the last group
1276
+ groups.push(currentGroup);
1277
+
1278
+ return groups;
1279
+ }
1280
+
1281
+ /**
1282
+ * Creates a new stroke from a group of segment indices.
1283
+ * @private
1284
+ * @returns {Stroke|null} The newly created stroke
1285
+ */
1286
+ _createStrokeFromSegments(originalStroke, segmentIndices) {
1287
+ if (segmentIndices.length === 0) return null;
1288
+
1289
+ // Collect points for the new stroke
1290
+ const newPoints = [];
1291
+
1292
+ // Add the first point of the first segment
1293
+ newPoints.push(originalStroke.points[segmentIndices[0]]);
1294
+
1295
+ // Add all subsequent points
1296
+ for (const segmentIndex of segmentIndices) {
1297
+ newPoints.push(originalStroke.points[segmentIndex + 1]);
1298
+ }
1299
+
1300
+ // Only create stroke if we have at least 2 points
1301
+ if (newPoints.length < 2) return null;
1302
+
1303
+ // Create new stroke with same properties
1304
+ const newStroke = new Stroke({
1305
+ strokeWidth: originalStroke.strokeWidth,
1306
+ strokeColor: originalStroke.strokeColor,
1307
+ strokeOpacity: originalStroke.strokeOpacity,
1308
+ tool: originalStroke.tool
1309
+ });
1310
+
1311
+ // Add all points
1312
+ newPoints.forEach(point => {
1313
+ newStroke.addPoint(point);
1314
+ });
1315
+
1316
+ newStroke.finish();
1317
+ this.canvas.addStroke(newStroke);
1318
+ return newStroke;
1319
+ }
1320
+
1321
+ }
71
1322