@teachinglab/omd 0.7.15 → 0.7.17

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