UNPKG

@teachinglab/omd

Version:

omd

386 lines (333 loc) 12 kB
import { BoundingBox } from '../utils/boundingBox.js'; import { jsvgPath } from '@teachinglab/jsvg'; /** * Represents a drawing stroke made up of connected points */ export class Stroke { /** * @param {Object} options - Stroke configuration * @param {number} options.x - Starting X coordinate * @param {number} options.y - Starting Y coordinate * @param {number} [options.strokeWidth=5] - Stroke width * @param {string} [options.strokeColor='#000000'] - Stroke color * @param {number} [options.strokeOpacity=1] - Stroke opacity * @param {string} [options.tool='pencil'] - Tool that created this stroke */ constructor(options = {}) { this.id = options.id || this._generateId(); this.tool = options.tool || 'pencil'; // Stroke properties this.strokeWidth = options.strokeWidth || 5; this.strokeColor = options.strokeColor || '#000000'; this.strokeOpacity = options.strokeOpacity || 1; // Drawing data this.points = []; this.isFinished = false; this.isSelected = false; // Bounding box for hit testing and selection this.boundingBox = new BoundingBox(); // Create SVG element this._createElement(); // Add starting point if provided if (options.x !== undefined && options.y !== undefined) { this.addPoint({ x: options.x, y: options.y, pressure: 0.5, width: this.strokeWidth, timestamp: Date.now() }); } } /** * Create the SVG path element * @private */ _createElement() { this.jsvgPath = new jsvgPath(); this.element = this.jsvgPath.svgObject; // Get the underlying SVG element this.jsvgPath.setFillColor('none'); this.jsvgPath.setStrokeColor(this.strokeColor); this.jsvgPath.setStrokeWidth(this.strokeWidth); this.element.setAttribute('stroke-opacity', this.strokeOpacity); this.element.setAttribute('stroke-linecap', 'round'); this.element.setAttribute('stroke-linejoin', 'round'); this.element.setAttribute('data-stroke-id', this.id); this.element.setAttribute('data-tool', this.tool); } /** * Add a point to the stroke * @param {Object} point - Point data * @param {number} point.x - X coordinate * @param {number} point.y - Y coordinate * @param {number} [point.pressure=0.5] - Pressure value * @param {number} [point.width] - Stroke width at this point * @param {number} [point.timestamp] - Timestamp */ addPoint(point) { const normalizedPoint = { x: point.x, y: point.y, pressure: point.pressure || 0.5, width: point.width || this.strokeWidth, timestamp: point.timestamp || Date.now() }; this.points.push(normalizedPoint); this._updatePath(); this._updateBoundingBox(); } /** * Update the SVG path based on current points * @private */ _updatePath() { if (this.points.length === 0) { this.jsvgPath.clearPoints(); return; } this.jsvgPath.clearPoints(); // Clear existing points before adding new ones if (this.points.length === 1) { // Single point - draw a small circle const point = this.points[0]; this.jsvgPath.addPoint(point.x, point.y); this.jsvgPath.addPoint(point.x + 0.1, point.y); // Add a second point for a tiny line } else { // Multiple points - create smooth path this._generateSmoothPath(); } this.jsvgPath.updatePath(); } /** * Generate smooth path using cubic Bézier curves * @private */ _generateSmoothPath() { if (this.points.length < 2) return ''; this.jsvgPath.addPoint(this.points[0].x, this.points[0].y); if (this.points.length === 2) { // Simple line for 2 points this.jsvgPath.addPoint(this.points[1].x, this.points[1].y); return; } // Use cubic Bézier curves for smoother paths for (let i = 1; i < this.points.length - 1; i++) { const prev = this.points[i - 1]; const curr = this.points[i]; const next = this.points[i + 1]; // Calculate control points for smooth curve const cp1x = curr.x + (next.x - prev.x) * 0.25; const cp1y = curr.y + (next.y - prev.y) * 0.25; const cp2x = next.x - (next.x - curr.x) * 0.25; const cp2y = next.y - (next.y - curr.y) * 0.25; // jsvgPath doesn't directly support cubic bezier curves, so we'll approximate with more points // For a true cubic bezier, we'd need to extend jsvgPath or use raw SVG path data. // For now, we'll just add the next point. this.jsvgPath.addPoint(next.x, next.y); } } /** * Update bounding box based on current points * @private */ _updateBoundingBox() { if (this.points.length === 0) return; let minX = Infinity, minY = Infinity; let maxX = -Infinity, maxY = -Infinity; this.points.forEach(point => { const radius = point.width / 2; minX = Math.min(minX, point.x - radius); minY = Math.min(minY, point.y - radius); maxX = Math.max(maxX, point.x + radius); maxY = Math.max(maxY, point.y + radius); }); this.boundingBox.set(minX, minY, maxX - minX, maxY - minY); } /** * Finish the stroke (no more points will be added) */ finish() { this.isFinished = true; this.element.setAttribute('data-finished', 'true'); } /** * Set stroke selection state * @param {boolean} selected - Whether stroke is selected */ setSelected(selected) { this.isSelected = selected; if (selected) { this.element.setAttribute('stroke-dasharray', '5,5'); this.element.setAttribute('data-selected', 'true'); } else { this.element.removeAttribute('stroke-dasharray'); this.element.removeAttribute('data-selected'); } } /** * Update stroke configuration * @param {Object} config - New configuration */ updateConfig(config) { if (config.strokeColor !== undefined) { this.strokeColor = config.strokeColor; this.jsvgPath.setStrokeColor(this.strokeColor); } if (config.strokeWidth !== undefined) { this.strokeWidth = config.strokeWidth; this.jsvgPath.setStrokeWidth(this.strokeWidth); } if (config.strokeOpacity !== undefined) { this.strokeOpacity = config.strokeOpacity; this.element.setAttribute('stroke-opacity', this.strokeOpacity); } } /** * Check if a point is near this stroke * @param {number} x - X coordinate * @param {number} y - Y coordinate * @param {number} [tolerance=10] - Distance tolerance * @returns {boolean} True if point is near stroke */ isNearPoint(x, y, tolerance = 10) { // Quick bounding box check first if (!this.boundingBox.containsPoint(x, y, tolerance)) { return false; } // Check distance to each line segment for (let i = 1; i < this.points.length; i++) { const p1 = this.points[i - 1]; const p2 = this.points[i]; const distance = this._distanceToLineSegment(x, y, p1.x, p1.y, p2.x, p2.y); if (distance <= tolerance) { return true; } } return false; } /** * Calculate distance from point to line segment * @private */ _distanceToLineSegment(px, py, x1, y1, x2, y2) { const dx = x2 - x1; const dy = y2 - y1; const length = Math.sqrt(dx * dx + dy * dy); if (length === 0) { // Points are the same return Math.sqrt((px - x1) * (px - x1) + (py - y1) * (py - y1)); } const t = Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / (length * length))); const projX = x1 + t * dx; const projY = y1 + t * dy; return Math.sqrt((px - projX) * (px - projX) + (py - projY) * (py - projY)); } /** * Get stroke length * @returns {number} Total stroke length */ getLength() { let length = 0; for (let i = 1; i < this.points.length; i++) { const p1 = this.points[i - 1]; const p2 = this.points[i]; const dx = p2.x - p1.x; const dy = p2.y - p1.y; length += Math.sqrt(dx * dx + dy * dy); } return length; } /** * Get stroke data for serialization * @returns {Object} Serializable stroke data */ toJSON() { return { id: this.id, tool: this.tool, strokeWidth: this.strokeWidth, strokeColor: this.strokeColor, strokeOpacity: this.strokeOpacity, points: this.points, isFinished: this.isFinished, boundingBox: this.boundingBox.toJSON() }; } /** * Create stroke from serialized data * @param {Object} data - Serialized stroke data * @returns {Stroke} New stroke instance */ static fromJSON(data) { const stroke = new Stroke({ id: data.id, tool: data.tool, strokeWidth: data.strokeWidth, strokeColor: data.strokeColor, strokeOpacity: data.strokeOpacity }); // Add all points data.points.forEach(point => stroke.addPoint(point)); if (data.isFinished) { stroke.finish(); } return stroke; } /** * Generate unique stroke ID * @private */ _generateId() { return `stroke_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; } /** * Clone this stroke * @returns {Stroke} New stroke instance */ clone() { return Stroke.fromJSON(this.toJSON()); } /** * Get point count * @returns {number} Number of points in stroke */ getPointCount() { return this.points.length; } /** * Get bounding box * @returns {BoundingBox} Stroke bounding box */ getBoundingBox() { return this.boundingBox; } /** * Move stroke by offset * @param {number} dx - X offset * @param {number} dy - Y offset */ move(dx, dy) { this.points.forEach(point => { point.x += dx; point.y += dy; }); this._updatePath(); this._updateBoundingBox(); } /** * Scale stroke by factor * @param {number} scale - Scale factor * @param {number} [originX=0] - Scale origin X * @param {number} [originY=0] - Scale origin Y */ scale(scale, originX = 0, originY = 0) { this.points.forEach(point => { point.x = originX + (point.x - originX) * scale; point.y = originY + (point.y - originY) * scale; point.width *= scale; }); this.strokeWidth *= scale; this.jsvgPath.setStrokeWidth(this.strokeWidth); this._updatePath(); this._updateBoundingBox(); } }