UNPKG

@teachinglab/omd

Version:

omd

1,268 lines (1,073 loc) 44.4 kB
import { Tool } from './tool.js'; import { BoundingBox } from '../utils/boundingBox.js'; import { Stroke } from '../drawing/stroke.js'; import { ResizeHandleManager } from '../features/resizeHandleManager.js'; import {omdColor} from '../../src/omdColor.js'; const SELECTION_TOLERANCE = 10; const SELECTION_COLOR = '#007bff'; const SELECTION_OPACITY = 0.3; /** * A tool for selecting, moving, and deleting stroke segments. * @extends Tool */ export class SelectTool extends Tool { /** * @param {OMDCanvas} canvas - The canvas instance. * @param {object} [options={}] - Configuration options for the tool. */ constructor(canvas, options = {}) { super(canvas, { selectionColor: SELECTION_COLOR, selectionOpacity: SELECTION_OPACITY, ...options }); this.displayName = 'Select'; this.description = 'Select and manipulate segments'; this.icon = 'select'; this.shortcut = 'S'; this.category = 'selection'; /** @private */ this.isSelecting = false; /** @private */ this.selectionBox = null; /** @private */ this.startPoint = null; /** @type {Map<string, Set<number>>} */ this.selectedSegments = new Map(); /** @private - OMD dragging state */ this.isDraggingOMD = false; this.draggedOMDElement = null; this.selectedOMDElements = new Set(); /** @private - Stroke dragging state */ this.isDraggingStrokes = false; this.dragStartPoint = null; this.potentialDeselect = null; this.hasSeparatedForDrag = false; // Initialize resize handle manager for OMD visuals this.resizeHandleManager = new ResizeHandleManager(canvas); // Store reference on canvas for makeDraggable to access if (canvas) { canvas.resizeHandleManager = this.resizeHandleManager; } } /** * Handles the pointer down event to start a selection. * @param {PointerEvent} event - The pointer event. */ onPointerDown(event) { if (!this.canUse()) { return; } // Check for resize handle first (highest priority) const handle = this.resizeHandleManager.getHandleAtPoint(event.x, event.y); if (handle) { // Start resize operation this.resizeHandleManager.startResize(handle, event.x, event.y, event.shiftKey); if (this.canvas.eventManager) { this.canvas.eventManager.isDrawing = true; } return; } const segmentSelection = this._findSegmentAtPoint(event.x, event.y); let omdElement = this._findOMDElementAtPoint(event.x, event.y); if (omdElement?.dataset?.locked === 'true') { omdElement = null; } if (segmentSelection) { // Check if already selected const isSelected = this._isSegmentSelected(segmentSelection); if (isSelected) { // Already selected - prepare for drag, but don't deselect yet this.isDraggingStrokes = true; this.hasSeparatedForDrag = false; this.dragStartPoint = { x: event.x, y: event.y }; this.potentialDeselect = segmentSelection; // Set isDrawing so we get pointermove events if (this.canvas.eventManager) { this.canvas.eventManager.isDrawing = true; } return; } else { // Clicking on a stroke segment if (!event.shiftKey) { this.resizeHandleManager.clearSelection(); this.selectedOMDElements.clear(); } this._handleSegmentClick(segmentSelection, event.shiftKey); // Prepare for drag immediately after selection this.isDraggingStrokes = true; this.hasSeparatedForDrag = false; this.dragStartPoint = { x: event.x, y: event.y }; if (this.canvas.eventManager) { this.canvas.eventManager.isDrawing = true; } } } else if (omdElement) { // Clicking on an OMD visual // Check if already selected if (this.selectedOMDElements.has(omdElement)) { // Already selected - prepare for drag this.isDraggingOMD = true; this.draggedOMDElement = omdElement; // Primary drag target this.startPoint = { x: event.x, y: event.y }; // Show resize handles if this is the only selected element if (this.selectedOMDElements.size === 1) { this.resizeHandleManager.selectElement(omdElement); } if (this.canvas.eventManager) { this.canvas.eventManager.isDrawing = true; } return; } // New selection if (!event.shiftKey) { this.selectedSegments.clear(); this._clearSelectionVisuals(); this.selectedOMDElements.clear(); this.resizeHandleManager.clearSelection(); } this.selectedOMDElements.add(omdElement); this.resizeHandleManager.selectElement(omdElement); // Start tracking for potential drag operation this.isDraggingOMD = true; this.draggedOMDElement = omdElement; this.startPoint = { x: event.x, y: event.y }; // Set isDrawing so we get pointermove events if (this.canvas.eventManager) { this.canvas.eventManager.isDrawing = true; } return; } else { // Check if clicking inside existing selection bounds const selectionBounds = this._getSelectionBounds(); if (selectionBounds && event.x >= selectionBounds.x && event.x <= selectionBounds.x + selectionBounds.width && event.y >= selectionBounds.y && event.y <= selectionBounds.y + selectionBounds.height) { // Drag the selection (strokes AND OMD elements) this.isDraggingStrokes = true; // We reuse this flag for general dragging this.isDraggingOMD = true; // Also set this for OMD elements this.hasSeparatedForDrag = false; this.dragStartPoint = { x: event.x, y: event.y }; this.startPoint = { x: event.x, y: event.y }; // For OMD dragging if (this.canvas.eventManager) { this.canvas.eventManager.isDrawing = true; } return; } // Clicking on empty space - clear all selections and start box selection this.resizeHandleManager.clearSelection(); this.selectedOMDElements.clear(); this._startBoxSelection(event.x, event.y, event.shiftKey); // CRITICAL: Set startPoint AFTER _startBoxSelection so it doesn't get cleared! this.startPoint = { x: event.x, y: event.y }; // CRITICAL: Tell the event manager we're "drawing" so pointer move events get sent to us if (this.canvas.eventManager) { this.canvas.eventManager.isDrawing = true; } } } /** * Handles the pointer move event to update the selection box. * @param {PointerEvent} event - The pointer event. */ onPointerMove(event) { // Handle resize operation if in progress if (this.resizeHandleManager.isResizing) { this.resizeHandleManager.updateResize(event.x, event.y); return; } let handled = false; // Handle OMD dragging if in progress if (this.isDraggingOMD) { this._dragOMDElements(event.x, event.y); handled = true; } // Handle stroke dragging if in progress if (this.isDraggingStrokes && this.dragStartPoint) { const dx = event.x - this.dragStartPoint.x; const dy = event.y - this.dragStartPoint.y; if (dx !== 0 || dy !== 0) { // If we moved, it's a drag, so cancel potential deselect this.potentialDeselect = null; // Separate selected parts if needed if (!this.hasSeparatedForDrag) { this._separateSelectedParts(); this.hasSeparatedForDrag = true; } // Move all selected strokes const movedStrokes = new Set(); for (const [strokeId, _] of this.selectedSegments) { const stroke = this.canvas.strokes.get(strokeId); if (stroke) { stroke.move(dx, dy); movedStrokes.add(strokeId); } } this.dragStartPoint = { x: event.x, y: event.y }; this._updateSegmentSelectionVisuals(); // Emit event this.canvas.emit('strokesMoved', { dx, dy, strokeIds: Array.from(movedStrokes) }); } handled = true; } if (handled) return; // Handle box selection if in progress if (!this.isSelecting || !this.selectionBox) return; this._updateSelectionBox(event.x, event.y); this._updateBoxSelection(); } /** * Handles the pointer up event to complete the selection. * @param {PointerEvent} event - The pointer event. */ onPointerUp(event) { // Handle resize completion if (this.resizeHandleManager.isResizing) { this.resizeHandleManager.finishResize(); if (this.canvas.eventManager) { this.canvas.eventManager.isDrawing = false; } return; } // Handle OMD drag completion if (this.isDraggingOMD) { this.isDraggingOMD = false; this.draggedOMDElement = null; this.startPoint = null; if (this.canvas.eventManager) { this.canvas.eventManager.isDrawing = false; } return; } // Handle stroke drag completion if (this.isDraggingStrokes) { if (this.potentialDeselect) { // We clicked a selected segment but didn't drag -> toggle selection this._handleSegmentClick(this.potentialDeselect, event.shiftKey); this.potentialDeselect = null; } this.isDraggingStrokes = false; this.dragStartPoint = null; if (this.canvas.eventManager) { this.canvas.eventManager.isDrawing = false; } return; } // Handle box selection completion if (this.isSelecting) { this._finishBoxSelection(); } this.isSelecting = false; this._removeSelectionBox(); // CRITICAL: Tell the event manager we're done "drawing" if (this.canvas.eventManager) { this.canvas.eventManager.isDrawing = false; } } /** * Cancels the current selection operation. */ onCancel() { this.isSelecting = false; this._removeSelectionBox(); this.clearSelection(); // Reset OMD drag state this.isDraggingOMD = false; this.draggedOMDElement = null; // CRITICAL: Tell the event manager we're done "drawing" if (this.canvas.eventManager) { this.canvas.eventManager.isDrawing = false; } super.onCancel(); } /** * Handle tool deactivation - clean up everything. */ onDeactivate() { // Clear active state this.isActive = false; // Reset drag and resize states this.isDraggingOMD = false; this.draggedOMDElement = null; this.isSelecting = false; this.startPoint = null; // Clear the event manager drawing state if (this.canvas.eventManager) { this.canvas.eventManager.isDrawing = false; } // Clean up selection this.clearSelection(); super.onDeactivate(); } /** * Handle tool activation. */ onActivate() { // Set active state first this.isActive = true; // Reset all state flags this.isDraggingOMD = false; this.draggedOMDElement = null; this.isSelecting = false; this.startPoint = null; // Ensure cursor is visible and properly set if (this.canvas.cursor) { this.canvas.cursor.show(); this.canvas.cursor.setShape('select'); } // Clear any existing selection to start fresh this.clearSelection(); super.onActivate(); } /** * Handles keyboard shortcuts for selection-related actions. * @param {string} key - The key that was pressed. * @param {KeyboardEvent} event - The keyboard event. * @returns {boolean} - True if the shortcut was handled, false otherwise. */ onKeyboardShortcut(key, event) { if (event.ctrlKey || event.metaKey) { if (key === 'a') { this._selectAllSegments(); return true; } } if (key === 'delete' || key === 'backspace') { this._deleteSelectedSegments(); return true; } return false; } /** * Gets the cursor for the tool. * @returns {string} The CSS cursor name. */ getCursor() { // If resizing, return appropriate resize cursor if (this.resizeHandleManager.isResizing) { return this.resizeHandleManager.getCursorForHandle( this.resizeHandleManager.resizeData?.handle?.type || 'se' ); } return 'select'; // Use custom SVG select cursor } /** * Check if tool can be used * @returns {boolean} */ canUse() { // Use the base class method which checks isActive and canvas state const result = super.canUse(); return result; } /** * Clears the current selection. */ clearSelection() { // Clear stroke selection data this.selectedSegments.clear(); // Clear OMD selection this.selectedOMDElements.clear(); this.resizeHandleManager.clearSelection(); // Remove selection box if it exists this._removeSelectionBox(); // Clear visual highlights this._clearSelectionVisuals(); // Reset selection state this.isSelecting = false; this.startPoint = null; // Emit selection change event this.canvas.emit('selectionChanged', { selected: [] }); } /** * Thoroughly clears all selection visuals. * @private */ _clearSelectionVisuals() { const selectionLayer = this.canvas.uiLayer.querySelector('.segment-selection-layer'); if (selectionLayer) { // Remove all children while (selectionLayer.firstChild) { selectionLayer.removeChild(selectionLayer.firstChild); } } // Also remove any orphaned selection elements const orphanedHighlights = this.canvas.uiLayer.querySelectorAll('[stroke="' + this.config.selectionColor + '"]'); orphanedHighlights.forEach(el => { if (el.parentNode) { el.parentNode.removeChild(el); } }); } /** * @private */ _handleSegmentClick({ strokeId, segmentIndex }, shiftKey) { const segmentSet = this.selectedSegments.get(strokeId) || new Set(); if (segmentSet.has(segmentIndex)) { segmentSet.delete(segmentIndex); if (segmentSet.size === 0) { this.selectedSegments.delete(strokeId); } } else { if (!shiftKey) { this.selectedSegments.clear(); } if (!this.selectedSegments.has(strokeId)) { this.selectedSegments.set(strokeId, new Set()); } this.selectedSegments.get(strokeId).add(segmentIndex); } this._updateSegmentSelectionVisuals(); this.canvas.emit('selectionChanged', { selected: this._getSelectedSegmentsAsArray() }); } /** * @private */ _startBoxSelection(x, y, shiftKey) { if (!shiftKey) { this.clearSelection(); } this.isSelecting = true; this._createSelectionBox(x, y); } /** * @private */ _finishBoxSelection() { this._updateSegmentSelectionVisuals(); this.canvas.emit('selectionChanged', { selected: this._getSelectedSegmentsAsArray() }); } /** * @private */ _getSelectedSegmentsAsArray() { const selected = []; for (const [strokeId, segmentSet] of this.selectedSegments.entries()) { for (const segmentIndex of segmentSet) { selected.push({ strokeId, segmentIndex }); } } return selected; } /** * Checks if a segment is currently selected. * @private * @param {{strokeId: string, segmentIndex: number}} selection - The selection to check. * @returns {boolean} */ _isSegmentSelected({ strokeId, segmentIndex }) { const segmentSet = this.selectedSegments.get(strokeId); return segmentSet ? segmentSet.has(segmentIndex) : false; } /** * Gets the bounding box of the current selection (strokes + OMD). * @private * @returns {{x: number, y: number, width: number, height: number}|null} */ _getSelectionBounds() { let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; let hasPoints = false; // 1. Check strokes if (this.selectedSegments.size > 0) { for (const [strokeId, segmentSet] of this.selectedSegments.entries()) { const stroke = this.canvas.strokes.get(strokeId); if (!stroke || !stroke.points) continue; for (const segmentIndex of segmentSet) { if (segmentIndex >= stroke.points.length - 1) continue; const p1 = stroke.points[segmentIndex]; const p2 = stroke.points[segmentIndex + 1]; minX = Math.min(minX, p1.x, p2.x); minY = Math.min(minY, p1.y, p2.y); maxX = Math.max(maxX, p1.x, p2.x); maxY = Math.max(maxY, p1.y, p2.y); hasPoints = true; } } } // 2. Check OMD elements if (this.selectedOMDElements.size > 0) { for (const element of this.selectedOMDElements) { const bbox = this._getOMDElementBounds(element); if (bbox) { minX = Math.min(minX, bbox.x); minY = Math.min(minY, bbox.y); maxX = Math.max(maxX, bbox.x + bbox.width); maxY = Math.max(maxY, bbox.y + bbox.height); hasPoints = true; } } } if (!hasPoints) return null; // Add padding to match the visual box const padding = 8; return { x: minX - padding, y: minY - padding, width: (maxX + padding) - (minX - padding), height: (maxY + padding) - (minY - padding) }; } /** * Drags all selected OMD elements * @private * @param {number} x - Current pointer x coordinate * @param {number} y - Current pointer y coordinate */ _dragOMDElements(x, y) { if (!this.startPoint) return; const dx = x - this.startPoint.x; const dy = y - this.startPoint.y; if (dx === 0 && dy === 0) return; for (const element of this.selectedOMDElements) { this._moveOMDElement(element, dx, dy); } // Update start point for next move this.startPoint = { x, y }; // Update resize handles if we have a single selection if (this.selectedOMDElements.size === 1) { const element = this.selectedOMDElements.values().next().value; this.resizeHandleManager.updateIfSelected(element); } this._updateSegmentSelectionVisuals(); } /** * Moves a single OMD element * @private */ _moveOMDElement(element, dx, dy) { // Parse current transform const currentTransform = element.getAttribute('transform') || ''; const translateMatch = currentTransform.match(/translate\(\s*([^,]+)\s*,\s*([^)]+)\s*\)/); const scaleMatch = currentTransform.match(/scale\(\s*([^,)]+)(?:\s*,\s*([^)]+))?\s*\)/); // Get current translate values let currentX = translateMatch ? parseFloat(translateMatch[1]) : 0; let currentY = translateMatch ? parseFloat(translateMatch[2]) : 0; // Calculate new position const newX = currentX + dx; const newY = currentY + dy; // Build new transform preserving scale let newTransform = `translate(${newX}, ${newY})`; if (scaleMatch) { const scaleX = parseFloat(scaleMatch[1]) || 1; const scaleY = scaleMatch[2] ? parseFloat(scaleMatch[2]) : scaleX; newTransform += ` scale(${scaleX}, ${scaleY})`; } element.setAttribute('transform', newTransform); } /** * Gets the bounds of an OMD element including transform * @private */ _getOMDElementBounds(item) { try { const bbox = item.getBBox(); const transform = item.getAttribute('transform') || ''; let offsetX = 0, offsetY = 0, scaleX = 1, scaleY = 1; const translateMatch = transform.match(/translate\(\s*([^,]+)\s*,\s*([^)]+)\s*\)/); if (translateMatch) { offsetX = parseFloat(translateMatch[1]) || 0; offsetY = parseFloat(translateMatch[2]) || 0; } const scaleMatch = transform.match(/scale\(\s*([^,)]+)(?:\s*,\s*([^)]+))?\s*\)/); if (scaleMatch) { scaleX = parseFloat(scaleMatch[1]) || 1; scaleY = scaleMatch[2] ? parseFloat(scaleMatch[2]) : scaleX; } return { x: offsetX + (bbox.x * scaleX), y: offsetY + (bbox.y * scaleY), width: bbox.width * scaleX, height: bbox.height * scaleY }; } catch (e) { return null; } } /** * Finds the closest segment to a given point. * @private * @param {number} x - The x-coordinate of the point. * @param {number} y - The y-coordinate of the point. * @returns {{strokeId: string, segmentIndex: number}|null} */ _findSegmentAtPoint(x, y) { let closest = null; let minDist = SELECTION_TOLERANCE; for (const [id, stroke] of this.canvas.strokes) { if (!stroke.points || stroke.points.length < 2) continue; for (let i = 0; i < stroke.points.length - 1; i++) { const p1 = stroke.points[i]; const p2 = stroke.points[i + 1]; const dist = this._pointToSegmentDistance(x, y, p1, p2); if (dist < minDist) { minDist = dist; closest = { strokeId: id, segmentIndex: i }; } } } return closest; } /** * Check if point is inside an OMD visual element * @private */ _findOMDElementAtPoint(x, y) { // Find OMD layer directly from canvas const omdLayer = this.canvas.drawingLayer?.querySelector('.omd-layer'); if (!omdLayer) { return null; } // Check all OMD items in the layer const omdItems = omdLayer.querySelectorAll('.omd-item'); for (const item of omdItems) { if (item?.dataset?.locked === 'true') { continue; } try { // Get the bounding box of the item const bbox = item.getBBox(); // Parse transform to get the actual position const transform = item.getAttribute('transform') || ''; let offsetX = 0, offsetY = 0, scaleX = 1, scaleY = 1; // Parse translate const translateMatch = transform.match(/translate\(\s*([^,]+)\s*,\s*([^)]+)\s*\)/); if (translateMatch) { offsetX = parseFloat(translateMatch[1]) || 0; offsetY = parseFloat(translateMatch[2]) || 0; } // Parse scale const scaleMatch = transform.match(/scale\(\s*([^,)]+)(?:\s*,\s*([^)]+))?\s*\)/); if (scaleMatch) { scaleX = parseFloat(scaleMatch[1]) || 1; scaleY = scaleMatch[2] ? parseFloat(scaleMatch[2]) : scaleX; } // Calculate the actual bounds including transform const actualX = offsetX + (bbox.x * scaleX); const actualY = offsetY + (bbox.y * scaleY); const actualWidth = bbox.width * scaleX; const actualHeight = bbox.height * scaleY; // Add some padding for easier clicking const padding = 10; // Check if point is within the bounds (with padding) if (x >= actualX - padding && x <= actualX + actualWidth + padding && y >= actualY - padding && y <= actualY + actualHeight + padding) { return item; } } catch (error) { // Skip items that can't be measured (continue with next) continue; } } return null; } /** * Calculates the distance from a point to a line segment. * @private */ _pointToSegmentDistance(x, y, p1, p2) { const l2 = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2; if (l2 === 0) return Math.hypot(x - p1.x, y - p1.y); let t = ((x - p1.x) * (p2.x - p1.x) + (y - p1.y) * (p2.y - p1.y)) / l2; t = Math.max(0, Math.min(1, t)); const projX = p1.x + t * (p2.x - p1.x); const projY = p1.y + t * (p2.y - p1.y); return Math.hypot(x - projX, y - projY); } /** * Creates the selection box element. * @private */ _createSelectionBox(x, y) { this.selectionBox = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); this.selectionBox.setAttribute('x', x); this.selectionBox.setAttribute('y', y); this.selectionBox.setAttribute('width', 0); this.selectionBox.setAttribute('height', 0); // Clean blue selection box this.selectionBox.setAttribute('fill', 'rgba(0, 123, 255, 0.2)'); // Blue with transparency this.selectionBox.setAttribute('stroke', '#007bff'); // Blue stroke this.selectionBox.setAttribute('stroke-width', '1'); // Thin stroke this.selectionBox.setAttribute('stroke-dasharray', '4,2'); // Small dashes this.selectionBox.style.pointerEvents = 'none'; this.selectionBox.setAttribute('data-selection-box', 'true'); if (this.canvas.uiLayer) { this.canvas.uiLayer.appendChild(this.selectionBox); } else if (this.canvas.svg) { this.canvas.svg.appendChild(this.selectionBox); } else { console.error('No canvas layer found to add selection box!'); } } /** * Updates the dimensions of the selection box. * @private */ _updateSelectionBox(x, y) { if (!this.selectionBox || !this.startPoint) return; const minX = Math.min(this.startPoint.x, x); const minY = Math.min(this.startPoint.y, y); const width = Math.abs(this.startPoint.x - x); const height = Math.abs(this.startPoint.y - y); this.selectionBox.setAttribute('x', minX); this.selectionBox.setAttribute('y', minY); this.selectionBox.setAttribute('width', width); this.selectionBox.setAttribute('height', height); } /** * Removes the selection box element. * @private */ _removeSelectionBox() { if (this.selectionBox) { this.selectionBox.remove(); this.selectionBox = null; } this.startPoint = null; } /** * Updates the set of selected segments based on the selection box. * @private */ _updateBoxSelection() { if (!this.selectionBox) return; const x = parseFloat(this.selectionBox.getAttribute('x')); const y = parseFloat(this.selectionBox.getAttribute('y')); const width = parseFloat(this.selectionBox.getAttribute('width')); const height = parseFloat(this.selectionBox.getAttribute('height')); const selectionBounds = new BoundingBox(x, y, width, height); // 1. Select strokes for (const [id, stroke] of this.canvas.strokes) { if (!stroke.points || stroke.points.length < 2) continue; for (let i = 0; i < stroke.points.length - 1; i++) { const p1 = stroke.points[i]; const p2 = stroke.points[i + 1]; if (this._segmentIntersectsBox(p1, p2, selectionBounds)) { if (!this.selectedSegments.has(id)) { this.selectedSegments.set(id, new Set()); } this.selectedSegments.get(id).add(i); } } } // 2. Select OMD elements const omdLayer = this.canvas.drawingLayer?.querySelector('.omd-layer'); if (omdLayer) { const omdItems = omdLayer.querySelectorAll('.omd-item'); for (const item of omdItems) { if (item?.dataset?.locked === 'true') continue; const itemBounds = this._getOMDElementBounds(item); if (itemBounds) { // Check intersection const intersects = !(itemBounds.x > x + width || itemBounds.x + itemBounds.width < x || itemBounds.y > y + height || itemBounds.y + itemBounds.height < y); if (intersects) { this.selectedOMDElements.add(item); } } } } // Update resize handles this.resizeHandleManager.clearSelection(); this._updateSegmentSelectionVisuals(); } /** * Checks if a line segment intersects with a bounding box. * @private * @param {{x: number, y: number}} p1 - The start point of the segment. * @param {{x: number, y: number}} p2 - The end point of the segment. * @param {BoundingBox} box - The bounding box. * @returns {boolean} */ _segmentIntersectsBox(p1, p2, box) { if (box.containsPoint(p1.x, p1.y) || box.containsPoint(p2.x, p2.y)) { return true; } const { x, y, width, height } = box; const right = x + width; const bottom = y + height; const lines = [ { a: { x, y }, b: { x: right, y } }, { a: { x: right, y }, b: { x: right, y: bottom } }, { a: { x: right, y: bottom }, b: { x, y: bottom } }, { a: { x, y: bottom }, b: { x, y } } ]; for (const line of lines) { if (this._lineIntersectsLine(p1, p2, line.a, line.b)) { return true; } } return false; } /** * Checks if two line segments intersect. * @private */ _lineIntersectsLine(p1, p2, p3, p4) { const det = (p2.x - p1.x) * (p4.y - p3.y) - (p2.y - p1.y) * (p4.x - p3.x); if (det === 0) return false; const t = ((p3.x - p1.x) * (p4.y - p3.y) - (p3.y - p1.y) * (p4.x - p3.x)) / det; const u = -((p2.x - p1.x) * (p3.y - p1.y) - (p2.y - p1.y) * (p3.x - p1.x)) / det; return t >= 0 && t <= 1 && u >= 0 && u <= 1; } /** * Updates the visual representation of selected segments. * @private */ _updateSegmentSelectionVisuals() { // Get or create selection layer const selectionLayer = this.canvas.uiLayer.querySelector('.segment-selection-layer') || this._createSelectionLayer(); // Clear existing visuals efficiently while (selectionLayer.firstChild) { selectionLayer.removeChild(selectionLayer.firstChild); } const bounds = this._getSelectionBounds(); if (!bounds) return; // Create bounding box element const box = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); box.setAttribute('x', bounds.x); box.setAttribute('y', bounds.y); box.setAttribute('width', bounds.width); box.setAttribute('height', bounds.height); box.setAttribute('fill', 'none'); box.setAttribute('stroke', '#007bff'); // Selection color box.setAttribute('stroke-width', '1.5'); box.setAttribute('stroke-dasharray', '6, 4'); // Dotted/Dashed box.setAttribute('stroke-opacity', '0.6'); // Light box.setAttribute('rx', '8'); // Rounded corners box.setAttribute('ry', '8'); box.style.pointerEvents = 'none'; box.classList.add('selection-bounds'); selectionLayer.appendChild(box); } /** * @private */ _createSelectionLayer() { const layer = document.createElementNS('http://www.w3.org/2000/svg', 'g'); layer.classList.add('segment-selection-layer'); this.canvas.uiLayer.appendChild(layer); return layer; } /** * Selects all segments and OMD elements on the canvas. * @private */ _selectAllSegments() { // Clear current selection this.selectedSegments.clear(); this.selectedOMDElements.clear(); let totalSegments = 0; // Select all valid segments from all strokes for (const [id, stroke] of this.canvas.strokes) { if (!stroke.points || stroke.points.length < 2) continue; const segmentIndices = new Set(); for (let i = 0; i < stroke.points.length - 1; i++) { segmentIndices.add(i); totalSegments++; } if (segmentIndices.size > 0) { this.selectedSegments.set(id, segmentIndices); } } // Select all OMD elements const omdLayer = this.canvas.drawingLayer?.querySelector('.omd-layer'); if (omdLayer) { const omdItems = omdLayer.querySelectorAll('.omd-item'); for (const item of omdItems) { if (item?.dataset?.locked !== 'true') { this.selectedOMDElements.add(item); } } } // Update visuals this._updateSegmentSelectionVisuals(); // Emit selection change event this.canvas.emit('selectionChanged', { selected: this._getSelectedSegmentsAsArray() }); } /** * Deletes all currently selected items (segments and OMD elements). * @private */ _deleteSelectedSegments() { let changed = false; // Delete OMD elements if (this.selectedOMDElements.size > 0) { for (const element of this.selectedOMDElements) { element.remove(); } this.selectedOMDElements.clear(); this.resizeHandleManager.clearSelection(); changed = true; } if (this.selectedSegments.size > 0) { // Process each stroke that has selected segments const strokesToProcess = Array.from(this.selectedSegments.entries()); for (const [strokeId, segmentIndices] of strokesToProcess) { const stroke = this.canvas.strokes.get(strokeId); if (!stroke || !stroke.points || stroke.points.length < 2) continue; const sortedIndices = Array.from(segmentIndices).sort((a, b) => a - b); // If all or most segments are selected, just delete the whole stroke const totalSegments = stroke.points.length - 1; const selectedCount = sortedIndices.length; if (selectedCount >= totalSegments * 0.8) { // Delete entire stroke this.canvas.removeStroke(strokeId); continue; } // Split the stroke, keeping only unselected segments this._splitStrokeKeepingUnselected(stroke, sortedIndices); } changed = true; } if (changed) { // Clear selection and update UI this.clearSelection(); // Emit deletion event this.canvas.emit('selectionDeleted'); } } /** * Splits a stroke, keeping only the segments that weren't selected for deletion. * @private */ _splitStrokeKeepingUnselected(stroke, selectedSegmentIndices) { const totalSegments = stroke.points.length - 1; const keepSegments = []; // Determine which segments to keep for (let i = 0; i < totalSegments; i++) { if (!selectedSegmentIndices.includes(i)) { keepSegments.push(i); } } if (keepSegments.length === 0) { // All segments were selected, delete the whole stroke this.canvas.removeStroke(stroke.id); return; } // Group consecutive segments into separate strokes const strokeSegments = this._groupConsecutiveSegments(keepSegments); // Remove the original stroke this.canvas.removeStroke(stroke.id); // Create new strokes for each group of consecutive segments strokeSegments.forEach(segmentGroup => { this._createStrokeFromSegments(stroke, segmentGroup); }); } /** * Separates selected segments into new strokes so they can be moved independently. * @private */ _separateSelectedParts() { const newSelection = new Map(); const strokesToProcess = Array.from(this.selectedSegments.entries()); for (const [strokeId, selectedIndices] of strokesToProcess) { const stroke = this.canvas.strokes.get(strokeId); if (!stroke || !stroke.points || stroke.points.length < 2) continue; const totalSegments = stroke.points.length - 1; // If fully selected, just keep it as is if (selectedIndices.size === totalSegments) { newSelection.set(strokeId, selectedIndices); continue; } // It's a partial selection - we need to split const sortedSelectedIndices = Array.from(selectedIndices).sort((a, b) => a - b); // Determine unselected indices const unselectedIndices = []; for (let i = 0; i < totalSegments; i++) { if (!selectedIndices.has(i)) { unselectedIndices.push(i); } } // Group segments const selectedGroups = this._groupConsecutiveSegments(sortedSelectedIndices); const unselectedGroups = this._groupConsecutiveSegments(unselectedIndices); // Create new strokes for selected parts selectedGroups.forEach(group => { const newStroke = this._createStrokeFromSegments(stroke, group); if (newStroke) { // Add to new selection (all segments selected) const newIndices = new Set(); for (let i = 0; i < newStroke.points.length - 1; i++) { newIndices.add(i); } newSelection.set(newStroke.id, newIndices); } }); // Create new strokes for unselected parts (don't add to selection) unselectedGroups.forEach(group => { this._createStrokeFromSegments(stroke, group); }); // Remove original stroke this.canvas.removeStroke(strokeId); } this.selectedSegments = newSelection; } /** * Groups consecutive segment indices into separate arrays. * @private */ _groupConsecutiveSegments(segmentIndices) { if (segmentIndices.length === 0) return []; const groups = []; let currentGroup = [segmentIndices[0]]; for (let i = 1; i < segmentIndices.length; i++) { const current = segmentIndices[i]; const previous = segmentIndices[i - 1]; if (current === previous + 1) { // Consecutive segment currentGroup.push(current); } else { // Gap found, start new group groups.push(currentGroup); currentGroup = [current]; } } // Add the last group groups.push(currentGroup); return groups; } /** * Creates a new stroke from a group of segment indices. * @private * @returns {Stroke|null} The newly created stroke */ _createStrokeFromSegments(originalStroke, segmentIndices) { if (segmentIndices.length === 0) return null; // Collect points for the new stroke const newPoints = []; // Add the first point of the first segment newPoints.push(originalStroke.points[segmentIndices[0]]); // Add all subsequent points for (const segmentIndex of segmentIndices) { newPoints.push(originalStroke.points[segmentIndex + 1]); } // Only create stroke if we have at least 2 points if (newPoints.length < 2) return null; // Create new stroke with same properties const newStroke = new Stroke({ strokeWidth: originalStroke.strokeWidth, strokeColor: originalStroke.strokeColor, strokeOpacity: originalStroke.strokeOpacity, tool: originalStroke.tool }); // Add all points newPoints.forEach(point => { newStroke.addPoint(point); }); newStroke.finish(); this.canvas.addStroke(newStroke); return newStroke; } }