/** * Base Control Class for TestDrive-JSUI Controls * * Provides common functionality for positioning, drag, resize, expand/collapse operations. * This is the foundation class that all UI controls inherit from to ensure consistent * behavior across the TestDrive-JSUI component system. * * Key Features: * - Drag and drop positioning with compass-based anchoring * - Resize handles with hover-based visibility * - Expand/collapse state management * - Safe operation wrappers with error handling * - Development mode with strict error checking * - Accessibility support with proper ARIA labels * * Dependencies: * - None (standalone base class) * * Usage: * Controls inherit from this base by using Object.create(Control) and * implementing their specific buildContent() methods. */ // Development mode detection for enhanced error reporting const MARKITECT_STRICT_MODE = ( window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' || window.location.search.includes('strict=true') || window.markitectStrictMode === true ); /** * ControlBase - Foundation class for all TestDrive-JSUI controls * * Provides the base functionality that all controls inherit: * - DOM element management * - Positioning and drag behavior * - Resize handle management * - State persistence * - Error handling with strict mode support */ class ControlBase { constructor() { // Default configuration that controls can override this.config = { icon: '🔧', title: 'Control', className: 'base-control', defaultContent: 'Control content', ariaLabel: 'Base Control', position: 'w', // Compass position: west (middle-left) footer: null // Custom footer text }; // Internal state this.element = null; this.isExpanded = false; this.isHeaderOnly = false; // New state for header-only visibility this.isDragging = false; this.isResizing = false; this.position = { x: 0, y: 0 }; this.size = { width: 300, height: 200 }; this.originalPosition = null; // Store original position for collapse // Event handlers storage this.eventHandlers = new Map(); } /** * Safe operation wrapper with error handling * Provides consistent error handling across all control operations */ safeOperation(operation, fallback = null, context = 'Unknown') { try { return operation(); } catch (error) { console.error(`Control operation failed in ${context}:`, error); if (MARKITECT_STRICT_MODE) { throw error; // Re-throw in strict mode for debugging } return fallback; } } /** * Create and initialize the control element * This method sets up the basic DOM structure that all controls use */ createElement() { return this.safeOperation(() => { if (this.element) { this.destroy(); // Clean up existing element } const control = document.createElement('div'); control.className = `control-panel ${this.config.className}`; control.setAttribute('role', 'dialog'); control.setAttribute('aria-label', this.config.ariaLabel); control.innerHTML = `
`; this.element = control; this.setupStyles(); this.setupEventListeners(); return control; }, null, 'createElement'); } /** * Set up base styles for the control */ setupStyles() { if (!this.element) return; // Position the element this.element.style.position = 'fixed'; this.element.style.zIndex = '1000'; // Store original position for collapse this.storeOriginalPosition(); // Style the icon-only toggle button const toggleBtn = this.element.querySelector('.control-toggle'); if (toggleBtn) { toggleBtn.style.cssText = ` width: 40px; height: 40px; border: none; background: rgba(248, 249, 250, 0.95); border: 1px solid #dee2e6; border-radius: 8px; cursor: pointer; font-size: 16px; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 8px rgba(0,0,0,0.1); transition: all 0.2s ease; `; } } /** * Set up event listeners for control interaction * Handles dragging, resizing, and toggle functionality */ setupEventListeners() { if (!this.element) return; // Icon toggle to expand const toggleBtn = this.element.querySelector('.control-toggle'); if (toggleBtn) { this.addEventListener(toggleBtn, 'click', () => this.expand()); } // Close button to collapse back to icon const closeBtn = this.element.querySelector('.control-close'); if (closeBtn) { this.addEventListener(closeBtn, 'click', () => this.collapse()); } // Header title click to toggle content visibility const title = this.element.querySelector('.control-title'); if (title) { this.addEventListener(title, 'click', () => this.toggleHeaderOnly()); } // Drag functionality on header when expanded const header = this.element.querySelector('.control-header'); if (header) { this.addEventListener(header, 'mousedown', (e) => { if (this.isExpanded && e.target !== title && e.target !== closeBtn) { this.startDrag(e); } }); } } /** * Add event listener with automatic cleanup tracking */ addEventListener(element, event, handler) { const key = `${element.className}_${event}`; // Remove existing handler if it exists if (this.eventHandlers.has(key)) { const [oldElement, oldEvent, oldHandler] = this.eventHandlers.get(key); oldElement.removeEventListener(oldEvent, oldHandler); } // Add new handler element.addEventListener(event, handler); this.eventHandlers.set(key, [element, event, handler]); } /** * Store original position for collapse restoration */ storeOriginalPosition() { if (!this.element) return; const positionStyles = this.getCompassPosition(); this.originalPosition = { top: positionStyles.top, left: positionStyles.left, right: positionStyles.right, bottom: positionStyles.bottom, transform: positionStyles.transform }; // Apply original position Object.assign(this.element.style, positionStyles); } /** * Get compass-based positioning styles */ getCompassPosition() { const positions = { 'n': { top: '20px', left: '50%', transform: 'translateX(-50%)' }, 'ne': { top: '20px', right: '20px' }, 'e': { right: '20px', top: '50%', transform: 'translateY(-50%)' }, 'se': { bottom: '20px', right: '20px' }, 's': { bottom: '20px', left: '50%', transform: 'translateX(-50%)' }, 'sw': { bottom: '20px', left: '20px' }, 'w': { left: '20px', top: '50%', transform: 'translateY(-50%)' }, 'nw': { top: '20px', left: '20px' } }; return positions[this.config.position] || positions['w']; } /** * Expand the control from icon-only state */ expand() { return this.safeOperation(() => { this.isExpanded = true; const panel = this.element?.querySelector('.control-panel-expanded'); const toggleBtn = this.element?.querySelector('.control-toggle'); if (panel && toggleBtn) { panel.style.display = 'block'; toggleBtn.style.display = 'none'; // Style expanded panel panel.style.cssText = ` background: rgba(248, 249, 250, 0.95); border: 1px solid #dee2e6; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.15); backdrop-filter: blur(8px); min-width: 300px; min-height: 200px; `; // Style header const header = this.element.querySelector('.control-header'); if (header) { header.style.cssText = ` display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; background: rgba(0,0,0,0.05); border-bottom: 1px solid #dee2e6; cursor: move; user-select: none; `; } // Style close button const closeBtn = this.element.querySelector('.control-close'); if (closeBtn) { closeBtn.style.cssText = ` background: none; border: none; font-size: 16px; cursor: pointer; padding: 0; width: 20px; height: 20px; display: flex; align-items: center; justify-content: center; `; } // Add resize handle this.addResizeHandle(); this.buildContent(); } return this.isExpanded; }, false, 'expand'); } /** * Collapse back to icon-only state at original position */ collapse() { return this.safeOperation(() => { this.isExpanded = false; this.isHeaderOnly = false; const panel = this.element?.querySelector('.control-panel-expanded'); const toggleBtn = this.element?.querySelector('.control-toggle'); if (panel && toggleBtn) { panel.style.display = 'none'; toggleBtn.style.display = 'block'; // Restore original position if (this.originalPosition) { // Clear any drag positioning this.element.style.left = this.originalPosition.left || ''; this.element.style.right = this.originalPosition.right || ''; this.element.style.top = this.originalPosition.top || ''; this.element.style.bottom = this.originalPosition.bottom || ''; this.element.style.transform = this.originalPosition.transform || ''; } // Remove resize handle this.removeResizeHandle(); } return !this.isExpanded; }, false, 'collapse'); } /** * Toggle header-only visibility (content show/hide) */ toggleHeaderOnly() { return this.safeOperation(() => { if (!this.isExpanded) { // If collapsed, expand first this.expand(); return; } const content = this.element?.querySelector('.control-content'); if (content) { this.isHeaderOnly = !this.isHeaderOnly; content.style.display = this.isHeaderOnly ? 'none' : 'block'; } return this.isHeaderOnly; }, false, 'toggleHeaderOnly'); } /** * Start drag operation */ startDrag(event) { if (!this.isExpanded) return; // Only drag when expanded this.isDragging = true; const rect = this.element.getBoundingClientRect(); // Calculate offset from mouse to element origin this.dragOffset = { x: event.clientX - rect.left, y: event.clientY - rect.top }; // Clear any positioning styles that interfere with dragging this.element.style.right = ''; this.element.style.bottom = ''; this.element.style.transform = ''; // Add global mouse move and up handlers const handleMouseMove = (e) => this.handleDrag(e); const handleMouseUp = () => this.stopDrag(); document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); // Store handlers for cleanup (but don't use the tracked version to avoid conflicts) this._dragHandlers = { move: handleMouseMove, up: handleMouseUp }; event.preventDefault(); } /** * Handle drag movement */ handleDrag(event) { if (!this.isDragging || !this.element) return; // Calculate new position based on mouse position and offset const newX = event.clientX - this.dragOffset.x; const newY = event.clientY - this.dragOffset.y; // Update element position this.element.style.left = `${newX}px`; this.element.style.top = `${newY}px`; this.position.x = newX; this.position.y = newY; event.preventDefault(); } /** * Stop drag operation */ stopDrag() { if (!this.isDragging) return; this.isDragging = false; // Clean up event handlers if (this._dragHandlers) { document.removeEventListener('mousemove', this._dragHandlers.move); document.removeEventListener('mouseup', this._dragHandlers.up); delete this._dragHandlers; } } /** * Add resize handle to expanded control */ addResizeHandle() { // Remove existing resize handle if any this.removeResizeHandle(); const resizeHandle = document.createElement('div'); resizeHandle.className = 'control-resize-handle'; resizeHandle.innerHTML = '↙'; // Bottom-left resize indicator resizeHandle.style.cssText = ` position: absolute; bottom: 0; left: 0; width: 20px; height: 20px; cursor: nw-resize; font-size: 16px; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.1); border-radius: 0 8px 0 0; user-select: none; `; // Add to the expanded panel const panel = this.element?.querySelector('.control-panel-expanded'); if (panel) { panel.appendChild(resizeHandle); // Set up resize handlers this.addEventListener(resizeHandle, 'mousedown', (e) => this.startResize(e)); } } /** * Remove resize handle */ removeResizeHandle() { const handle = this.element?.querySelector('.control-resize-handle'); if (handle && handle.parentNode) { handle.parentNode.removeChild(handle); } } /** * Start resize operation */ startResize(event) { event.stopPropagation(); // Prevent drag from starting if (!this.isExpanded) return; this.isResizing = true; const rect = this.element.getBoundingClientRect(); // Store initial size and mouse position this.resizeStart = { width: rect.width, height: rect.height, mouseX: event.clientX, mouseY: event.clientY }; // Add global mouse move and up handlers const handleMouseMove = (e) => this.handleResize(e); const handleMouseUp = () => this.stopResize(); document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); // Store handlers for cleanup this._resizeHandlers = { move: handleMouseMove, up: handleMouseUp }; event.preventDefault(); } /** * Handle resize movement (bottom-left corner resize) */ handleResize(event) { if (!this.isResizing || !this.element) return; const panel = this.element.querySelector('.control-panel-expanded'); if (!panel) return; // Calculate size change based on mouse movement const deltaX = this.resizeStart.mouseX - event.clientX; // Inverted for left edge const deltaY = event.clientY - this.resizeStart.mouseY; // Calculate new dimensions (minimum size constraints) const newWidth = Math.max(200, this.resizeStart.width + deltaX); const newHeight = Math.max(150, this.resizeStart.height + deltaY); // Apply new size to the panel panel.style.width = `${newWidth}px`; panel.style.height = `${newHeight}px`; // Update stored size this.size.width = newWidth; this.size.height = newHeight; event.preventDefault(); } /** * Stop resize operation */ stopResize() { if (!this.isResizing) return; this.isResizing = false; // Clean up event handlers if (this._resizeHandlers) { document.removeEventListener('mousemove', this._resizeHandlers.move); document.removeEventListener('mouseup', this._resizeHandlers.up); delete this._resizeHandlers; } } /** * Position the control based on compass position (used by show method) */ positionControl() { if (!this.element) return; // Use the compass positioning from setupStyles this.storeOriginalPosition(); } /** * Build the control content (to be overridden by subclasses) */ buildContent() { // Default implementation - subclasses should override this const content = this.element?.querySelector('.control-content'); if (content) { content.innerHTML = this.config.defaultContent; } } /** * Show the control */ show() { return this.safeOperation(() => { if (!this.element) { this.createElement(); } document.body.appendChild(this.element); this.positionControl(); this.buildContent(); return this.element; }, null, 'show'); } /** * Hide the control */ hide() { return this.safeOperation(() => { if (this.element && this.element.parentNode) { this.element.parentNode.removeChild(this.element); } }, null, 'hide'); } /** * Destroy the control and clean up resources */ destroy() { return this.safeOperation(() => { // Clean up event listeners for (const [element, event, handler] of this.eventHandlers.values()) { element.removeEventListener(event, handler); } this.eventHandlers.clear(); // Remove element from DOM if (this.element && this.element.parentNode) { this.element.parentNode.removeChild(this.element); } this.element = null; }, null, 'destroy'); } } // Export for module systems or attach to global for direct usage if (typeof module !== 'undefined' && module.exports) { module.exports = ControlBase; } else { window.ControlBase = ControlBase; }