feat(dashboard): implement widget resize with 8-direction handles (Task 1.6)

- Add ResizeHandler class with 8 resize handles (4 corners + 4 edges)
- Implement unified mouse + touch resize events
- Add real-time dimension overlay showing current size
- Grid overlay with cell highlighting during resize
- Enforce min/max size constraints (2×2 to 12×10)
- Support resizing from all 8 directions with proper cursors
- Escape key cancels resize and restores original size
- Handle position adjustment when resizing from top/left
- Touch delay (150ms) for mobile scroll compatibility
- Create mobile-ready test harness with:
  - Hover-activated resize handles with fade transitions
  - Touch-optimized UI
  - Real-time statistics
  - Event logging
  - Works on desktop and mobile
- 550 lines core code, 920 lines test suite
- Comprehensive JSDoc documentation
This commit is contained in:
Lucas 'Paperboy' Rose-Winters
2025-10-23 10:03:44 +11:00
parent e30f02f9fe
commit 73af519128
3 changed files with 1591 additions and 19 deletions
+588
View File
@@ -0,0 +1,588 @@
/**
* Widget Resize Handler
*
* Handles widget resizing with mouse and touch support.
* Provides visual feedback, grid snapping, and size constraints.
*/
/**
* @typedef {Object} ResizeState
* @property {HTMLElement} element - Element being resized
* @property {Object} widget - Widget data object
* @property {string} handle - Handle being dragged (e.g., 'se', 'nw', 'n', 's', 'e', 'w')
* @property {number} startX - Initial pointer X
* @property {number} startY - Initial pointer Y
* @property {number} startWidth - Initial widget width (grid units)
* @property {number} startHeight - Initial widget height (grid units)
* @property {number} startGridX - Initial widget X (grid units)
* @property {number} startGridY - Initial widget Y (grid units)
* @property {HTMLElement} overlay - Dimension overlay element
* @property {boolean} isResizing - Whether resize is in progress
*/
export class ResizeHandler {
/**
* @param {Object} gridEngine - GridEngine instance
* @param {Object} options - Configuration options
*/
constructor(gridEngine, options = {}) {
this.gridEngine = gridEngine;
this.options = {
showDimensions: true,
showGrid: true,
minWidth: 2,
minHeight: 2,
maxWidth: 12,
maxHeight: 10,
touchDelay: 150,
...options
};
this.resizeState = null;
this.resizeHandlers = new Map();
this.gridOverlay = null;
this.touchTimer = null;
// Bound event handlers for cleanup
this.boundMouseMove = this.onMouseMove.bind(this);
this.boundMouseUp = this.onMouseUp.bind(this);
this.boundTouchMove = this.onTouchMove.bind(this);
this.boundTouchEnd = this.onTouchEnd.bind(this);
this.boundKeyDown = this.onKeyDown.bind(this);
// Handle types and their cursor styles
this.handleTypes = {
'nw': 'nwse-resize',
'n': 'ns-resize',
'ne': 'nesw-resize',
'e': 'ew-resize',
'se': 'nwse-resize',
's': 'ns-resize',
'sw': 'nesw-resize',
'w': 'ew-resize'
};
}
/**
* Initialize resize functionality on a widget element
* @param {HTMLElement} element - Widget DOM element
* @param {Object} widget - Widget data object
* @param {Function} onResizeEnd - Callback when resize completes (widget, newW, newH, newX, newY)
* @param {Object} constraints - Size constraints {minW, minH, maxW, maxH}
*/
initWidget(element, widget, onResizeEnd, constraints = {}) {
// Create resize handles
const handles = this.createResizeHandles();
element.appendChild(handles);
// Store constraints
const widgetConstraints = {
minW: constraints.minW || this.options.minWidth,
minH: constraints.minH || this.options.minHeight,
maxW: constraints.maxW || this.options.maxWidth,
maxH: constraints.maxH || this.options.maxHeight
};
// Attach event listeners to each handle
const handleElements = handles.querySelectorAll('.resize-handle');
const handleListeners = [];
handleElements.forEach(handleEl => {
const handleType = handleEl.dataset.handle;
const mouseDownHandler = (e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
this.startResize(e, handleType, element, widget, onResizeEnd, widgetConstraints);
};
const touchStartHandler = (e) => {
this.touchTimer = setTimeout(() => {
e.preventDefault();
e.stopPropagation();
this.startResize(e.touches[0], handleType, element, widget, onResizeEnd, widgetConstraints);
}, this.options.touchDelay);
};
const touchCancelHandler = () => {
if (this.touchTimer) {
clearTimeout(this.touchTimer);
this.touchTimer = null;
}
};
handleEl.addEventListener('mousedown', mouseDownHandler);
handleEl.addEventListener('touchstart', touchStartHandler, { passive: false });
handleEl.addEventListener('touchcancel', touchCancelHandler);
handleEl.addEventListener('touchend', touchCancelHandler);
handleListeners.push({
element: handleEl,
mouseDownHandler,
touchStartHandler,
touchCancelHandler
});
});
// Store handlers for cleanup
this.resizeHandlers.set(element, {
handles,
handleListeners
});
}
/**
* Remove resize functionality from a widget element
* @param {HTMLElement} element - Widget DOM element
*/
destroyWidget(element) {
const handlers = this.resizeHandlers.get(element);
if (!handlers) return;
const { handles, handleListeners } = handlers;
// Remove event listeners
handleListeners.forEach(({ element: handleEl, mouseDownHandler, touchStartHandler, touchCancelHandler }) => {
handleEl.removeEventListener('mousedown', mouseDownHandler);
handleEl.removeEventListener('touchstart', touchStartHandler);
handleEl.removeEventListener('touchcancel', touchCancelHandler);
handleEl.removeEventListener('touchend', touchCancelHandler);
});
// Remove handle container
handles.remove();
this.resizeHandlers.delete(element);
}
/**
* Create resize handle elements
* @returns {HTMLElement} Container with all resize handles
*/
createResizeHandles() {
const container = document.createElement('div');
container.className = 'resize-handles';
container.style.position = 'absolute';
container.style.inset = '0';
container.style.pointerEvents = 'none';
// Create 8 handles (4 corners + 4 edges)
Object.entries(this.handleTypes).forEach(([handleType, cursor]) => {
const handle = document.createElement('div');
handle.className = `resize-handle resize-handle-${handleType}`;
handle.dataset.handle = handleType;
handle.style.position = 'absolute';
handle.style.pointerEvents = 'auto';
handle.style.cursor = cursor;
handle.style.width = '12px';
handle.style.height = '12px';
handle.style.background = 'rgba(78, 204, 163, 0.8)';
handle.style.border = '2px solid white';
handle.style.borderRadius = '3px';
handle.style.zIndex = '100';
// Position handles
if (handleType.includes('n')) handle.style.top = '-6px';
if (handleType.includes('s')) handle.style.bottom = '-6px';
if (handleType.includes('w')) handle.style.left = '-6px';
if (handleType.includes('e')) handle.style.right = '-6px';
// Center edge handles
if (handleType === 'n' || handleType === 's') {
handle.style.left = '50%';
handle.style.transform = 'translateX(-50%)';
}
if (handleType === 'w' || handleType === 'e') {
handle.style.top = '50%';
handle.style.transform = 'translateY(-50%)';
}
container.appendChild(handle);
});
return container;
}
/**
* Start resize operation
* @param {MouseEvent|Touch} e - Pointer event
* @param {string} handleType - Handle type (e.g., 'se', 'nw')
* @param {HTMLElement} element - Element being resized
* @param {Object} widget - Widget data
* @param {Function} onResizeEnd - Callback when resize completes
* @param {Object} constraints - Size constraints
*/
startResize(e, handleType, element, widget, onResizeEnd, constraints) {
// Create dimension overlay
const overlay = this.createDimensionOverlay();
this.resizeState = {
element,
widget: { ...widget },
handle: handleType,
startX: e.clientX,
startY: e.clientY,
startWidth: widget.w,
startHeight: widget.h,
startGridX: widget.x,
startGridY: widget.y,
overlay,
isResizing: true,
onResizeEnd,
constraints
};
// Add event listeners
document.addEventListener('mousemove', this.boundMouseMove);
document.addEventListener('mouseup', this.boundMouseUp);
document.addEventListener('touchmove', this.boundTouchMove, { passive: false });
document.addEventListener('touchend', this.boundTouchEnd);
document.addEventListener('keydown', this.boundKeyDown);
// Show grid overlay
if (this.options.showGrid) {
this.showGridOverlay();
}
// Add resizing class
element.classList.add('resizing');
console.log('[ResizeHandler] Started resizing widget:', widget.id, 'handle:', handleType);
}
/**
* Handle mouse move during resize
* @param {MouseEvent} e - Mouse event
*/
onMouseMove(e) {
if (!this.resizeState?.isResizing) return;
e.preventDefault();
this.updateResizeSize(e.clientX, e.clientY);
}
/**
* Handle touch move during resize
* @param {TouchEvent} e - Touch event
*/
onTouchMove(e) {
if (!this.resizeState?.isResizing) return;
e.preventDefault();
const touch = e.touches[0];
this.updateResizeSize(touch.clientX, touch.clientY);
}
/**
* Update resize dimensions
* @param {number} clientX - Pointer X coordinate
* @param {number} clientY - Pointer Y coordinate
*/
updateResizeSize(clientX, clientY) {
const { widget, handle, startX, startY, startWidth, startHeight, startGridX, startGridY, constraints, element, overlay } = this.resizeState;
// Calculate pixel delta
const deltaX = clientX - startX;
const deltaY = clientY - startY;
// Get column/row size in pixels
this.gridEngine.updateContainerWidth();
const totalGaps = this.gridEngine.gap * (this.gridEngine.columns + 1);
const colWidth = (this.gridEngine.containerWidth - totalGaps) / this.gridEngine.columns;
const rowHeight = this.gridEngine.rowHeight;
// Convert pixel delta to grid units
const deltaGridX = Math.round(deltaX / (colWidth + this.gridEngine.gap));
const deltaGridY = Math.round(deltaY / (rowHeight + this.gridEngine.gap));
// Calculate new dimensions based on handle type
let newW = startWidth;
let newH = startHeight;
let newX = startGridX;
let newY = startGridY;
// Handle width changes
if (handle.includes('e')) {
newW = startWidth + deltaGridX;
} else if (handle.includes('w')) {
newW = startWidth - deltaGridX;
newX = startGridX + deltaGridX;
}
// Handle height changes
if (handle.includes('s')) {
newH = startHeight + deltaGridY;
} else if (handle.includes('n')) {
newH = startHeight - deltaGridY;
newY = startGridY + deltaGridY;
}
// Apply constraints
newW = Math.max(constraints.minW, Math.min(newW, constraints.maxW));
newH = Math.max(constraints.minH, Math.min(newH, constraints.maxH));
// Ensure doesn't exceed grid bounds
newW = Math.min(newW, this.gridEngine.columns - newX);
// Adjust position if resizing from top/left and hit min size
if (handle.includes('w') && newW === constraints.minW) {
newX = startGridX + startWidth - constraints.minW;
}
if (handle.includes('n') && newH === constraints.minH) {
newY = startGridY + startHeight - constraints.minH;
}
// Update widget dimensions
this.resizeState.widget.w = newW;
this.resizeState.widget.h = newH;
this.resizeState.widget.x = newX;
this.resizeState.widget.y = newY;
// Update element size
const pos = this.gridEngine.getPixelPosition(this.resizeState.widget);
element.style.width = pos.width + 'px';
element.style.height = pos.height + 'px';
element.style.left = pos.left + 'px';
element.style.top = pos.top + 'px';
// Update dimension overlay
if (overlay) {
overlay.textContent = `${newW}×${newH}`;
overlay.style.left = (pos.left + pos.width / 2) + 'px';
overlay.style.top = (pos.top + pos.height / 2) + 'px';
}
// Update grid overlay
if (this.gridOverlay) {
this.highlightGridCells(newX, newY, newW, newH);
}
}
/**
* Handle mouse up - end resize
* @param {MouseEvent} e - Mouse event
*/
onMouseUp(e) {
if (!this.resizeState?.isResizing) return;
e.preventDefault();
this.endResize();
}
/**
* Handle touch end - end resize
* @param {TouchEvent} e - Touch event
*/
onTouchEnd(e) {
if (!this.resizeState?.isResizing) return;
e.preventDefault();
this.endResize();
}
/**
* Handle keyboard during resize (Escape to cancel)
* @param {KeyboardEvent} e - Keyboard event
*/
onKeyDown(e) {
if (!this.resizeState?.isResizing) return;
if (e.key === 'Escape') {
e.preventDefault();
this.cancelResize();
}
}
/**
* End resize operation and commit size
*/
endResize() {
if (!this.resizeState) return;
const { element, widget, onResizeEnd } = this.resizeState;
// Remove resizing class
element.classList.remove('resizing');
// Call callback with new dimensions
if (onResizeEnd) {
onResizeEnd(widget, widget.w, widget.h, widget.x, widget.y);
}
this.cleanup();
console.log('[ResizeHandler] Resize completed:', widget.id, `${widget.w}×${widget.h} at (${widget.x}, ${widget.y})`);
}
/**
* Cancel resize operation and restore original size
*/
cancelResize() {
if (!this.resizeState) return;
const { element, startWidth, startHeight, startGridX, startGridY } = this.resizeState;
// Restore original size
const widget = {
x: startGridX,
y: startGridY,
w: startWidth,
h: startHeight
};
const pos = this.gridEngine.getPixelPosition(widget);
element.style.width = pos.width + 'px';
element.style.height = pos.height + 'px';
element.style.left = pos.left + 'px';
element.style.top = pos.top + 'px';
// Remove resizing class
element.classList.remove('resizing');
this.cleanup();
console.log('[ResizeHandler] Resize cancelled');
}
/**
* Cleanup after resize ends
*/
cleanup() {
// Remove dimension overlay
if (this.resizeState?.overlay) {
this.resizeState.overlay.remove();
}
// Remove grid overlay
this.hideGridOverlay();
// Remove event listeners
document.removeEventListener('mousemove', this.boundMouseMove);
document.removeEventListener('mouseup', this.boundMouseUp);
document.removeEventListener('touchmove', this.boundTouchMove);
document.removeEventListener('touchend', this.boundTouchEnd);
document.removeEventListener('keydown', this.boundKeyDown);
// Clear touch timer
if (this.touchTimer) {
clearTimeout(this.touchTimer);
this.touchTimer = null;
}
this.resizeState = null;
}
/**
* Create dimension overlay element
* @returns {HTMLElement} Overlay element
*/
createDimensionOverlay() {
const overlay = document.createElement('div');
overlay.className = 'resize-dimension-overlay';
overlay.style.position = 'absolute';
overlay.style.background = 'rgba(78, 204, 163, 0.9)';
overlay.style.color = 'white';
overlay.style.padding = '8px 12px';
overlay.style.borderRadius = '6px';
overlay.style.fontSize = '14px';
overlay.style.fontWeight = 'bold';
overlay.style.pointerEvents = 'none';
overlay.style.zIndex = '10001';
overlay.style.transform = 'translate(-50%, -50%)';
overlay.style.whiteSpace = 'nowrap';
overlay.style.boxShadow = '0 2px 8px rgba(0,0,0,0.3)';
this.gridEngine.container.appendChild(overlay);
return overlay;
}
/**
* Show grid overlay
*/
showGridOverlay() {
if (this.gridOverlay) return;
this.gridOverlay = document.createElement('div');
this.gridOverlay.className = 'grid-overlay';
this.gridOverlay.style.position = 'absolute';
this.gridOverlay.style.top = '0';
this.gridOverlay.style.left = '0';
this.gridOverlay.style.width = '100%';
this.gridOverlay.style.height = '100%';
this.gridOverlay.style.pointerEvents = 'none';
this.gridOverlay.style.zIndex = '9999';
this.gridEngine.container.appendChild(this.gridOverlay);
}
/**
* Hide grid overlay
*/
hideGridOverlay() {
if (this.gridOverlay) {
this.gridOverlay.remove();
this.gridOverlay = null;
}
}
/**
* Highlight grid cells where widget will be placed
* @param {number} x - Grid X coordinate
* @param {number} y - Grid Y coordinate
* @param {number} w - Widget width in grid units
* @param {number} h - Widget height in grid units
*/
highlightGridCells(x, y, w, h) {
if (!this.gridOverlay) return;
this.gridOverlay.innerHTML = '';
const totalGaps = this.gridEngine.gap * (this.gridEngine.columns + 1);
const colWidth = (this.gridEngine.containerWidth - totalGaps) / this.gridEngine.columns;
for (let row = y; row < y + h; row++) {
for (let col = x; col < x + w; col++) {
const cell = document.createElement('div');
cell.style.position = 'absolute';
cell.style.left = (col * (colWidth + this.gridEngine.gap) + this.gridEngine.gap) + 'px';
cell.style.top = (row * (this.gridEngine.rowHeight + this.gridEngine.gap) + this.gridEngine.gap) + 'px';
cell.style.width = colWidth + 'px';
cell.style.height = this.gridEngine.rowHeight + 'px';
cell.style.backgroundColor = 'rgba(78, 204, 163, 0.3)';
cell.style.border = '2px solid rgba(78, 204, 163, 0.6)';
cell.style.borderRadius = '4px';
cell.style.boxSizing = 'border-box';
this.gridOverlay.appendChild(cell);
}
}
}
/**
* Get current resize state
* @returns {ResizeState|null} Current resize state or null
*/
getResizeState() {
return this.resizeState;
}
/**
* Check if currently resizing
* @returns {boolean} True if resize in progress
*/
isResizing() {
return this.resizeState?.isResizing || false;
}
/**
* Destroy resize handler and cleanup
*/
destroy() {
// Cancel any ongoing resize
if (this.isResizing()) {
this.cancelResize();
}
// Remove all widget handlers
for (const element of this.resizeHandlers.keys()) {
this.destroyWidget(element);
}
this.resizeHandlers.clear();
}
}