@teachinglab/omd
Version:
omd
487 lines (416 loc) • 16.9 kB
JavaScript
/**
* Manages resize handles for OMD visuals
* Provides functionality for creating, positioning, and handling resize operations
*/
export class ResizeHandleManager {
constructor(canvas) {
this.canvas = canvas;
this.selectedElement = null;
this.handles = [];
this.isResizing = false;
this.resizeData = null;
// Handle configuration
this.handleSize = 8;
this.handleColor = '#007bff';
this.handleStrokeColor = '#ffffff';
this.handleStrokeWidth = 1;
// Selection border config
this.selectionBorderColor = '#007bff';
this.selectionBorderWidth = 2;
this.selectionBorder = null;
// Resize constraints
this.minSize = 20;
this.maxSize = 800;
this.maintainAspectRatio = false; // Can be toggled with shift key
}
/**
* Select an OMD visual element and show resize handles
* @param {SVGElement} element - The OMD wrapper element to select
*/
selectElement(element) {
this.clearSelection();
if (!element || !element.classList.contains('omd-item')) {
return;
}
if (element?.dataset?.locked === 'true') {
return;
}
this.selectedElement = element;
this._createSelectionBorder();
this._createResizeHandles();
this._updateHandlePositions();
// Add selected class for CSS styling
element.classList.add('omd-selected');
// Emit selection event
this.canvas.emit('omdElementSelected', { element });
}
/**
* Clear current selection and remove all handles
*/
clearSelection() {
if (this.selectedElement) {
this.selectedElement.classList.remove('omd-selected');
this.selectedElement = null;
}
this._removeSelectionBorder();
this._removeResizeHandles();
this.isResizing = false;
this.resizeData = null;
// Emit deselection event
this.canvas.emit('omdElementDeselected');
}
/**
* Check if a point is over a resize handle
* @param {number} x - X coordinate in canvas coordinates
* @param {number} y - Y coordinate in canvas coordinates
* @returns {Object|null} Handle data if hit, null otherwise
*/
getHandleAtPoint(x, y) {
const tolerance = this.handleSize / 2 + 3; // Slightly larger hit area
for (const handle of this.handles) {
// Get handle position from its attributes
const handleX = parseFloat(handle.element.getAttribute('x')) + this.handleSize / 2;
const handleY = parseFloat(handle.element.getAttribute('y')) + this.handleSize / 2;
const distance = Math.hypot(x - handleX, y - handleY);
if (distance <= tolerance) {
return handle;
}
}
return null;
}
/**
* Start resize operation
* @param {Object} handle - Handle data
* @param {number} startX - Starting X coordinate
* @param {number} startY - Starting Y coordinate
* @param {boolean} maintainAspectRatio - Whether to maintain aspect ratio
*/
startResize(handle, startX, startY, maintainAspectRatio = false) {
if (!this.selectedElement || !handle) return;
this.isResizing = true;
this.maintainAspectRatio = maintainAspectRatio;
// Get current transform and bounds
const transform = this.selectedElement.getAttribute('transform') || 'translate(0,0)';
const match = transform.match(/translate\(\s*([^,]+)\s*,\s*([^)]+)\s*\)/);
const currentX = match ? parseFloat(match[1]) : 0;
const currentY = match ? parseFloat(match[2]) : 0;
// Get current scale
const scaleMatch = transform.match(/scale\(\s*([^,)]+)(?:\s*,\s*([^)]+))?\s*\)/);
const currentScaleX = scaleMatch ? parseFloat(scaleMatch[1]) : 1;
const currentScaleY = scaleMatch ? (scaleMatch[2] ? parseFloat(scaleMatch[2]) : currentScaleX) : 1;
// Get bounding box of the content
const bbox = this._getElementBounds();
this.resizeData = {
handle,
startX,
startY,
originalTransform: transform,
currentX,
currentY,
currentScaleX,
currentScaleY,
originalBounds: bbox,
startWidth: bbox.width * currentScaleX,
startHeight: bbox.height * currentScaleY
};
// Add resizing class
this.selectedElement.classList.add('omd-resizing');
}
/**
* Update resize operation
* @param {number} currentX - Current X coordinate
* @param {number} currentY - Current Y coordinate
*/
updateResize(currentX, currentY) {
if (!this.isResizing || !this.resizeData) return;
const { handle, startX, startY, originalBounds, startWidth, startHeight } = this.resizeData;
const deltaX = currentX - startX;
const deltaY = currentY - startY;
let newWidth = startWidth;
let newHeight = startHeight;
let offsetX = 0;
let offsetY = 0;
// Calculate new dimensions based on handle type
switch (handle.type) {
case 'nw': // Top-left corner
newWidth = startWidth - deltaX;
newHeight = startHeight - deltaY;
offsetX = deltaX;
offsetY = deltaY;
break;
case 'ne': // Top-right corner
newWidth = startWidth + deltaX;
newHeight = startHeight - deltaY;
offsetY = deltaY;
break;
case 'sw': // Bottom-left corner
newWidth = startWidth - deltaX;
newHeight = startHeight + deltaY;
offsetX = deltaX;
break;
case 'se': // Bottom-right corner
newWidth = startWidth + deltaX;
newHeight = startHeight + deltaY;
break;
case 'n': // Top edge
newHeight = startHeight - deltaY;
offsetY = deltaY;
break;
case 's': // Bottom edge
newHeight = startHeight + deltaY;
break;
case 'w': // Left edge
newWidth = startWidth - deltaX;
offsetX = deltaX;
break;
case 'e': // Right edge
newWidth = startWidth + deltaX;
break;
}
// Maintain aspect ratio if requested
if (this.maintainAspectRatio) {
const aspectRatio = startWidth / startHeight;
if (handle.type.includes('e') || handle.type.includes('w')) {
// Width-based resize
newHeight = newWidth / aspectRatio;
} else if (handle.type.includes('n') || handle.type.includes('s')) {
// Height-based resize
newWidth = newHeight * aspectRatio;
} else {
// Corner resize - use the dimension with larger change
const widthChange = Math.abs(newWidth - startWidth);
const heightChange = Math.abs(newHeight - startHeight);
if (widthChange > heightChange) {
newHeight = newWidth / aspectRatio;
} else {
newWidth = newHeight * aspectRatio;
}
}
}
// Apply size constraints
newWidth = Math.max(this.minSize, Math.min(this.maxSize, newWidth));
newHeight = Math.max(this.minSize, Math.min(this.maxSize, newHeight));
// Calculate scale factors
const scaleX = newWidth / originalBounds.width;
const scaleY = newHeight / originalBounds.height;
// Calculate new position
const newX = this.resizeData.currentX + offsetX;
const newY = this.resizeData.currentY + offsetY;
// Apply transform
this.selectedElement.setAttribute('transform',
`translate(${newX}, ${newY}) scale(${scaleX}, ${scaleY})`);
// Update handle positions
this._updateHandlePositions();
this._updateSelectionBorder();
}
/**
* Finish resize operation
*/
finishResize() {
if (!this.isResizing) return;
this.isResizing = false;
if (this.selectedElement) {
this.selectedElement.classList.remove('omd-resizing');
}
// Update handle positions after resize is complete
this._updateHandlePositions();
this._updateSelectionBorder();
// Emit resize complete event
this.canvas.emit('omdElementResized', {
element: this.selectedElement,
transform: this.selectedElement.getAttribute('transform')
});
this.resizeData = null;
}
/**
* Update handle positions for currently selected element (called externally)
*/
updateIfSelected(element) {
if (this.selectedElement && this.selectedElement === element) {
this._updateHandlePositions();
this._updateSelectionBorder();
}
}
/**
* Get cursor for handle type
* @param {string} handleType - Type of handle
* @returns {string} CSS cursor value
*/
getCursorForHandle(handleType) {
const cursors = {
'nw': 'nw-resize',
'n': 'n-resize',
'ne': 'ne-resize',
'e': 'e-resize',
'se': 'se-resize',
's': 's-resize',
'sw': 'sw-resize',
'w': 'w-resize'
};
return cursors[handleType] || 'default';
}
/**
* Create selection border around element
* @private
*/
_createSelectionBorder() {
if (!this.selectedElement) return;
this.selectionBorder = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
this.selectionBorder.setAttribute('fill', 'none');
this.selectionBorder.setAttribute('stroke', this.selectionBorderColor);
this.selectionBorder.setAttribute('stroke-width', this.selectionBorderWidth);
this.selectionBorder.setAttribute('stroke-dasharray', '4,2');
this.selectionBorder.style.pointerEvents = 'none';
this.selectionBorder.classList.add('omd-selection-border');
this.canvas.uiLayer.appendChild(this.selectionBorder);
this._updateSelectionBorder();
}
/**
* Update selection border position and size
* @private
*/
_updateSelectionBorder() {
if (!this.selectionBorder || !this.selectedElement) return;
const bounds = this._getTransformedBounds();
const padding = 3;
this.selectionBorder.setAttribute('x', bounds.x - padding);
this.selectionBorder.setAttribute('y', bounds.y - padding);
this.selectionBorder.setAttribute('width', bounds.width + padding * 2);
this.selectionBorder.setAttribute('height', bounds.height + padding * 2);
}
/**
* Remove selection border
* @private
*/
_removeSelectionBorder() {
if (this.selectionBorder) {
this.selectionBorder.remove();
this.selectionBorder = null;
}
}
/**
* Create resize handles around the selected element
* @private
*/
_createResizeHandles() {
if (!this.selectedElement) return;
const handleTypes = [
{ type: 'nw', pos: 'top-left' },
{ type: 'n', pos: 'top-center' },
{ type: 'ne', pos: 'top-right' },
{ type: 'e', pos: 'middle-right' },
{ type: 'se', pos: 'bottom-right' },
{ type: 's', pos: 'bottom-center' },
{ type: 'sw', pos: 'bottom-left' },
{ type: 'w', pos: 'middle-left' }
];
handleTypes.forEach(handleDef => {
const handle = this._createHandle(handleDef.type, handleDef.pos);
this.handles.push(handle);
this.canvas.uiLayer.appendChild(handle.element);
});
}
/**
* Create individual resize handle
* @private
*/
_createHandle(type, position) {
const handle = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
handle.setAttribute('width', this.handleSize);
handle.setAttribute('height', this.handleSize);
handle.setAttribute('fill', this.handleColor);
handle.setAttribute('stroke', this.handleStrokeColor);
handle.setAttribute('stroke-width', this.handleStrokeWidth);
handle.setAttribute('rx', 1);
handle.style.cursor = this.getCursorForHandle(type);
handle.classList.add('resize-handle', `resize-handle-${type}`);
// Add hover effects
handle.addEventListener('mouseenter', () => {
handle.setAttribute('fill', '#0056b3');
});
handle.addEventListener('mouseleave', () => {
handle.setAttribute('fill', this.handleColor);
});
return {
element: handle,
type,
position
};
}
/**
* Update positions of all resize handles
* @private
*/
_updateHandlePositions() {
if (!this.selectedElement || this.handles.length === 0) return;
const bounds = this._getTransformedBounds();
const halfHandle = this.handleSize / 2;
const positions = {
'nw': { x: bounds.x - halfHandle, y: bounds.y - halfHandle },
'n': { x: bounds.x + bounds.width/2 - halfHandle, y: bounds.y - halfHandle },
'ne': { x: bounds.x + bounds.width - halfHandle, y: bounds.y - halfHandle },
'e': { x: bounds.x + bounds.width - halfHandle, y: bounds.y + bounds.height/2 - halfHandle },
'se': { x: bounds.x + bounds.width - halfHandle, y: bounds.y + bounds.height - halfHandle },
's': { x: bounds.x + bounds.width/2 - halfHandle, y: bounds.y + bounds.height - halfHandle },
'sw': { x: bounds.x - halfHandle, y: bounds.y + bounds.height - halfHandle },
'w': { x: bounds.x - halfHandle, y: bounds.y + bounds.height/2 - halfHandle }
};
this.handles.forEach(handle => {
const pos = positions[handle.type];
if (pos) {
handle.element.setAttribute('x', pos.x);
handle.element.setAttribute('y', pos.y);
}
});
}
/**
* Remove all resize handles
* @private
*/
_removeResizeHandles() {
this.handles.forEach(handle => {
handle.element.remove();
});
this.handles = [];
}
/**
* Get element bounds without transform
* @private
*/
_getElementBounds() {
if (!this.selectedElement) return { x: 0, y: 0, width: 0, height: 0 };
try {
// Get bounding box of the content inside the wrapper
const content = this.selectedElement.firstElementChild;
if (content) {
return content.getBBox();
} else {
return this.selectedElement.getBBox();
}
} catch (error) {
// Fallback if getBBox fails
return { x: 0, y: 0, width: 100, height: 100 };
}
}
/**
* Get element bounds with current transform applied
* @private
*/
_getTransformedBounds() {
if (!this.selectedElement) return { x: 0, y: 0, width: 0, height: 0 };
const transform = this.selectedElement.getAttribute('transform') || '';
const translateMatch = transform.match(/translate\(\s*([^,]+)\s*,\s*([^)]+)\s*\)/);
const scaleMatch = transform.match(/scale\(\s*([^,)]+)(?:\s*,\s*([^)]+))?\s*\)/);
const translateX = translateMatch ? parseFloat(translateMatch[1]) : 0;
const translateY = translateMatch ? parseFloat(translateMatch[2]) : 0;
const scaleX = scaleMatch ? parseFloat(scaleMatch[1]) : 1;
const scaleY = scaleMatch ? (scaleMatch[2] ? parseFloat(scaleMatch[2]) : scaleX) : 1;
const bounds = this._getElementBounds();
return {
x: translateX + (bounds.x * scaleX),
y: translateY + (bounds.y * scaleY),
width: bounds.width * scaleX,
height: bounds.height * scaleY
};
}
}