@teachinglab/omd
Version:
omd
458 lines (413 loc) • 16 kB
JavaScript
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);
}
}