@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.
- package/canvas/tools/PointerTool.js +1280 -29
- package/canvas/ui/cursor.js +4 -2
- package/jsvg/jsvg.js +272 -36
- package/jsvg/package.json +13 -0
- package/package.json +1 -1
|
@@ -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/
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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, {
|
|
21
|
+
super(canvas, {
|
|
22
|
+
selectionColor: SELECTION_COLOR,
|
|
23
|
+
selectionOpacity: SELECTION_OPACITY,
|
|
24
|
+
...options
|
|
25
|
+
});
|
|
15
26
|
|
|
16
27
|
this.displayName = 'Pointer';
|
|
17
|
-
this.description = '
|
|
28
|
+
this.description = 'Select, move, and interact';
|
|
18
29
|
this.icon = 'pointer';
|
|
19
30
|
this.shortcut = 'V';
|
|
20
|
-
this.category = '
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
532
|
+
/**
|
|
533
|
+
* @private
|
|
534
|
+
*/
|
|
535
|
+
_finishBoxSelection() {
|
|
536
|
+
this._updateSegmentSelectionVisuals();
|
|
537
|
+
this.canvas.emit('selectionChanged', { selected: this._getSelectedSegmentsAsArray() });
|
|
59
538
|
}
|
|
60
539
|
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
|