@teachinglab/omd
Version:
omd
438 lines (376 loc) • 14.5 kB
JavaScript
export class Cursor {
/**
* @param {OMDCanvas} canvas - Canvas instance
*/
constructor(canvas) {
this.canvas = canvas;
this.isVisible = true;
this.currentShape = 'pencil';
this.size = 20;
this.color = '#007bff';
// Create cursor element
this._createElement();
// Add to UI layer
this.canvas.uiLayer.appendChild(this.element);
}
/**
* Create the cursor SVG element
* @private
*/
_createElement() {
this.element = document.createElementNS('http://www.w3.org/2000/svg', 'g');
this.element.setAttribute('class', 'omd-cursor');
this.element.style.pointerEvents = 'none';
this.element.style.opacity = '0.8';
// Create different cursor shapes
this._createShapes();
// Initially hidden
this.hide();
}
/**
* Create different cursor shape elements
* @private
*/
_createShapes() {
this.shapes = {};
// Default cursor (crosshair)
this.shapes.default = this._createCrosshair();
// Pencil cursor
this.shapes.pencil = this._createPencilCursor();
// Eraser cursor
this.shapes.eraser = this._createEraserCursor();
// Select cursor
this.shapes.select = this._createSelectCursor();
// Add all shapes to cursor element
Object.values(this.shapes).forEach(shape => {
this.element.appendChild(shape);
});
// Show default shape initially
this.setShape('default');
}
/**
* Create crosshair cursor
* @private
*/
_createCrosshair() {
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
group.setAttribute('data-shape', 'default');
// Horizontal line
const hLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
hLine.setAttribute('x1', '-10');
hLine.setAttribute('y1', '0');
hLine.setAttribute('x2', '10');
hLine.setAttribute('y2', '0');
hLine.setAttribute('stroke', this.color);
hLine.setAttribute('stroke-width', '1');
// Vertical line
const vLine = document.createElementNS('http://www.w3.org/2000/svg', 'line');
vLine.setAttribute('x1', '0');
vLine.setAttribute('y1', '-10');
vLine.setAttribute('x2', '0');
vLine.setAttribute('y2', '10');
vLine.setAttribute('stroke', this.color);
vLine.setAttribute('stroke-width', '1');
group.appendChild(hLine);
group.appendChild(vLine);
return group;
}
/**
* Create pencil cursor
* @private
*/
_createPencilCursor() {
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
group.setAttribute('data-shape', 'pencil');
// Solid dot cursor
this.brushCircle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
this.brushCircle.setAttribute('cx', '0');
this.brushCircle.setAttribute('cy', '0');
this.brushCircle.setAttribute('r', this.size / 2);
this.brushCircle.setAttribute('fill', this.color);
this.brushCircle.setAttribute('stroke', 'none');
group.appendChild(this.brushCircle);
return group;
}
/**
* Create eraser cursor
* @private
*/
_createEraserCursor() {
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
group.setAttribute('data-shape', 'eraser');
// Circle eraser indicator
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('r', this.size / 2);
circle.setAttribute('fill', 'none');
circle.setAttribute('stroke', '#dc3545');
circle.setAttribute('stroke-width', '1.5');
circle.setAttribute('class', 'eraser-circle');
// Mode indicator (inner fill for radius mode)
const modeIndicator = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
modeIndicator.setAttribute('r', this.size / 3);
modeIndicator.setAttribute('fill', 'rgba(220, 53, 69, 0.15)');
modeIndicator.setAttribute('class', 'eraser-mode-indicator');
modeIndicator.style.display = 'none';
// X mark inside (for stroke mode)
const line1 = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line1.setAttribute('x1', -this.size / 5);
line1.setAttribute('y1', -this.size / 5);
line1.setAttribute('x2', this.size / 5);
line1.setAttribute('y2', this.size / 5);
line1.setAttribute('stroke', '#dc3545');
line1.setAttribute('stroke-width', '1.5');
line1.setAttribute('class', 'eraser-x1');
const line2 = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line2.setAttribute('x1', this.size / 5);
line2.setAttribute('y1', -this.size / 5);
line2.setAttribute('x2', -this.size / 5);
line2.setAttribute('y2', this.size / 5);
line2.setAttribute('stroke', '#dc3545');
line2.setAttribute('stroke-width', '1.5');
line2.setAttribute('class', 'eraser-x2');
group.appendChild(circle);
group.appendChild(modeIndicator);
group.appendChild(line1);
group.appendChild(line2);
return group;
}
/**
* Create select cursor
* @private
*/
_createSelectCursor() {
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
group.setAttribute('data-shape', 'select');
// Arrow pointer
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', 'M 0,0 L 0,12 L 4,8 L 8,12 L 12,8 L 4,4 Z');
path.setAttribute('fill', this.color);
path.setAttribute('stroke', 'white');
path.setAttribute('stroke-width', '1');
group.appendChild(path);
return group;
}
/**
* Set cursor shape
* @param {string} shape - Shape name ('default', 'pencil', 'eraser', 'select')
*/
setShape(shape) {
this.currentShape = shape;
// Hide all shapes
Object.values(this.shapes).forEach(shapeElement => {
shapeElement.style.display = 'none';
});
// Show current shape
if (this.shapes[shape]) {
this.shapes[shape].style.display = 'block';
} else {
this.shapes.default.style.display = 'block';
}
// Update brush size for pencil
if (shape === 'pencil' && this.brushCircle) {
this._updateBrushSize();
}
}
/**
* Set cursor position
* @param {number} x - X coordinate
* @param {number} y - Y coordinate
*/
setPosition(x, y) {
this.element.setAttribute('transform', `translate(${x}, ${y})`);
}
/**
* Show cursor
*/
show() {
this.isVisible = true;
this.element.style.display = 'block';
}
/**
* Hide cursor
*/
hide() {
this.isVisible = false;
this.element.style.display = 'none';
}
/**
* Set cursor size (for tools that support it)
* @param {number} size - Cursor size
*/
setSize(size) {
this.size = size;
this._updateBrushSize();
}
/**
* Update brush size for pencil cursor
* @private
*/
_updateBrushSize() {
if (this.brushCircle) {
this.brushCircle.setAttribute('r', this.size / 2);
}
// Update eraser size if applicable
const eraserShape = this.shapes.eraser;
if (eraserShape) {
const circle = eraserShape.querySelector('.eraser-circle');
const modeIndicator = eraserShape.querySelector('.eraser-mode-indicator');
const x1 = eraserShape.querySelector('.eraser-x1');
const x2 = eraserShape.querySelector('.eraser-x2');
if (circle) {
circle.setAttribute('r', this.size / 2);
}
if (modeIndicator) {
modeIndicator.setAttribute('r', this.size / 3);
}
if (x1) {
x1.setAttribute('x1', -this.size / 5);
x1.setAttribute('y1', -this.size / 5);
x1.setAttribute('x2', this.size / 5);
x1.setAttribute('y2', this.size / 5);
}
if (x2) {
x2.setAttribute('x1', this.size / 5);
x2.setAttribute('y1', -this.size / 5);
x2.setAttribute('x2', -this.size / 5);
x2.setAttribute('y2', this.size / 5);
}
}
}
/**
* Set cursor color
* @param {string} color - CSS color value
*/
setColor(color) {
this.color = color;
// Update all shape colors
this.element.querySelectorAll('[stroke]').forEach(element => {
if (element.getAttribute('stroke') === this.color) {
element.setAttribute('stroke', color);
}
});
this.element.querySelectorAll('[fill]').forEach(element => {
if (element.getAttribute('fill') === this.color) {
element.setAttribute('fill', color);
}
});
}
/**
* Set cursor opacity
* @param {number} opacity - Opacity value (0-1)
*/
setOpacity(opacity) {
this.element.style.opacity = opacity;
}
/**
* Enable pressure feedback (for pressure-sensitive devices)
* @param {number} pressure - Pressure value (0-1)
*/
setPressureFeedback(pressure) {
if (this.currentShape === 'pencil' && this.brushCircle) {
// Scale brush circle based on pressure
const scale = 0.5 + (pressure * 0.5); // Scale from 50% to 100%
this.brushCircle.setAttribute('transform', `scale(${scale})`);
// Adjust opacity based on pressure
const opacity = 0.3 + (pressure * 0.5); // Opacity from 30% to 80%
this.brushCircle.style.opacity = opacity;
}
}
/**
* Add temporary visual feedback
* @param {string} type - Feedback type ('success', 'error', 'info')
* @param {number} [duration=500] - Duration in milliseconds
*/
showFeedback(type, duration = 500) {
const colors = {
success: '#28a745',
error: '#dc3545',
info: '#17a2b8'
};
const originalColor = this.color;
this.setColor(colors[type] || colors.info);
// Pulse animation
this.element.style.animation = 'pulse 0.3s ease-in-out';
setTimeout(() => {
this.setColor(originalColor);
this.element.style.animation = '';
}, duration);
}
/**
* Update cursor based on tool configuration
* @param {Object} toolConfig - Tool configuration
*/
updateFromToolConfig(toolConfig) {
if (toolConfig.strokeWidth) {
// Scale the cursor size for better visibility - multiply by 4 for pencil to make it clearly visible
const scaledSize = this.currentShape === 'pencil' ? toolConfig.strokeWidth * 4 : toolConfig.strokeWidth;
this.setSize(scaledSize);
}
if (toolConfig.strokeColor) {
this.setColor(toolConfig.strokeColor);
}
if (toolConfig.size) {
this.setSize(toolConfig.size);
}
// Update eraser mode indicator
if (toolConfig.mode !== undefined && this.currentShape === 'eraser') {
this._updateEraserMode(toolConfig.mode);
}
}
/**
* Update eraser mode visual indicator
* @private
*/
_updateEraserMode(mode) {
const eraserShape = this.shapes.eraser;
if (!eraserShape) return;
const circle = eraserShape.querySelector('.eraser-circle');
const modeIndicator = eraserShape.querySelector('.eraser-mode-indicator');
const x1 = eraserShape.querySelector('.eraser-x1');
const x2 = eraserShape.querySelector('.eraser-x2');
if (mode === 'radius') {
// Radius mode: show filled circle, hide X, use orange color
if (circle) {
circle.setAttribute('stroke', '#ff9f43');
circle.setAttribute('stroke-dasharray', '3,3');
}
if (modeIndicator) {
modeIndicator.style.display = 'block';
modeIndicator.setAttribute('fill', 'rgba(255, 159, 67, 0.3)');
}
if (x1) x1.style.display = 'none';
if (x2) x2.style.display = 'none';
} else {
// Stroke mode: show X, hide fill, use red color
if (circle) {
circle.setAttribute('stroke', '#dc3545');
circle.setAttribute('stroke-dasharray', 'none');
}
if (modeIndicator) {
modeIndicator.style.display = 'none';
}
if (x1) x1.style.display = 'block';
if (x2) x2.style.display = 'block';
}
}
/**
* Get current cursor state
* @returns {Object} Cursor state
*/
getState() {
return {
isVisible: this.isVisible,
shape: this.currentShape,
size: this.size,
color: this.color
};
}
/**
* Clean up cursor resources
*/
destroy() {
if (this.element.parentNode) {
this.element.parentNode.removeChild(this.element);
}
}
}