UNPKG

@teachinglab/omd

Version:

omd

458 lines (413 loc) 16 kB
import { Tool } from './tool.js'; import { BoundingBox } from '../utils/boundingBox.js'; import { Stroke } from '../drawing/stroke.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(); } /** * Handles the pointer down event to start a selection. * @param {PointerEvent} event - The pointer event. */ onPointerDown(event) { if (!this.canUse()) return; this.startPoint = { x: event.x, y: event.y }; const segmentSelection = this._findSegmentAtPoint(event.x, event.y); if (segmentSelection) { this._handleSegmentClick(segmentSelection, event.shiftKey); } else { this._startBoxSelection(event.x, event.y, event.shiftKey); } } /** * Handles the pointer move event to update the selection box. * @param {PointerEvent} event - The pointer event. */ onPointerMove(event) { 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) { if (this.isSelecting) { this._finishBoxSelection(); } this.isSelecting = false; this._removeSelectionBox(); } /** * Cancels the current selection operation. */ onCancel() { this.isSelecting = false; this._removeSelectionBox(); this.clearSelection(); super.onCancel(); } /** * 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() { return 'default'; } /** * Clears the current selection. */ clearSelection() { this.selectedSegments.clear(); this._updateSegmentSelectionVisuals(); this.canvas.emit('selectionChanged', { selected: [] }); } /** * @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; } /** * 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; } /** * 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); this.selectionBox.setAttribute('fill', this.config.selectionColor); this.selectionBox.setAttribute('fill-opacity', this.config.selectionOpacity); this.selectionBox.setAttribute('stroke', this.config.selectionColor); this.selectionBox.setAttribute('stroke-width', 1); this.selectionBox.setAttribute('stroke-dasharray', '5,5'); this.selectionBox.style.pointerEvents = 'none'; this.canvas.uiLayer.appendChild(this.selectionBox); } /** * 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); 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); } } } 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() { const selectionLayer = this.canvas.uiLayer.querySelector('.segment-selection-layer') || this._createSelectionLayer(); selectionLayer.innerHTML = ''; 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]; const highlight = document.createElementNS('http://www.w3.org/2000/svg', 'line'); highlight.setAttribute('x1', p1.x); highlight.setAttribute('y1', p1.y); highlight.setAttribute('x2', p2.x); highlight.setAttribute('y2', p2.y); highlight.setAttribute('stroke', omdColor.hiliteColor); highlight.setAttribute('stroke-width', '3'); highlight.setAttribute('stroke-opacity', '0.7'); highlight.style.pointerEvents = 'none'; selectionLayer.appendChild(highlight); } } } /** * @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 on the canvas. * @private */ _selectAllSegments() { this.selectedSegments.clear(); 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); } this.selectedSegments.set(id, segmentIndices); } this._updateSegmentSelectionVisuals(); } /** * Deletes all currently selected segments using the eraser tool. * @private */ _deleteSelectedSegments() { const eraser = this.canvas.toolManager?.getTool('eraser') || this.canvas.eraserTool; if (!eraser || typeof eraser._eraseInRadius !== 'function') { console.warn('Eraser tool not found or _eraseInRadius not available'); return; } const originalRadius = eraser.config.size; const eraseRadius = 15; eraser.config.size = eraseRadius; 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]; // Erase along the segment with 30 points const numPoints = 30; for (let i = 0; i <= numPoints; i++) { const t = i / numPoints; const x = p1.x + t * (p2.x - p1.x); const y = p1.y + t * (p2.y - p1.y); eraser._eraseInRadius(x, y); } } } eraser.config.size = originalRadius; // Restore original radius this.clearSelection(); } /** * Creates a new stroke from a set of points. * @private * @param {Array<object>} points - The points for the new stroke. * @param {Stroke} originalStroke - The original stroke to copy properties from. */ _createNewStroke(points, originalStroke) { const newStroke = new Stroke({ strokeWidth: originalStroke.strokeWidth, strokeColor: originalStroke.strokeColor, strokeOpacity: originalStroke.strokeOpacity, tool: originalStroke.tool, }); newStroke.points = points; newStroke.finish(); this.canvas.addStroke(newStroke); } }