/** * 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: Math.floor(window.innerHeight / 3) }; 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'; // Calculate default height as 1/3 of window height const defaultHeight = Math.floor(window.innerHeight / 3); // Style expanded panel panel.style.cssText = ` position: relative; display: flex; flex-direction: column; 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; max-height: calc(100vh - 40px); width: auto; height: ${defaultHeight}px; overflow: hidden; `; // Style header const header = this.element.querySelector('.control-header'); if (header) { header.style.cssText = ` display: flex; align-items: center; justify-content: space-between; padding: 4px 12px; background: rgba(0,0,0,0.05); border-bottom: 1px solid #dee2e6; cursor: move; user-select: none; flex-shrink: 0; min-height: 24px; border-radius: 7px 7px 0 0; margin: -1px -1px 0 -1px; `; } // Style content area container const contentArea = this.element.querySelector('.control-content'); if (contentArea) { contentArea.style.cssText = ` flex: 1; overflow: hidden; display: flex; flex-direction: column; min-height: 0; `; } // 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 || ''; } // Reset panel size to defaults panel.style.width = ''; panel.style.height = ''; panel.style.minWidth = '300px'; panel.style.minHeight = '200px'; // Reset internal size tracking this.size.width = 300; this.size.height = Math.floor(window.innerHeight / 3); this.storedWidth = null; // 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'); const panel = this.element?.querySelector('.control-panel-expanded'); if (content && panel) { this.isHeaderOnly = !this.isHeaderOnly; const resizeHandle = this.element?.querySelector('.control-resize-handle'); if (this.isHeaderOnly) { // Store current width before collapsing const currentWidth = panel.offsetWidth; this.storedWidth = currentWidth; // Hide content and shrink panel height only content.style.display = 'none'; panel.style.minHeight = 'auto'; panel.style.height = 'auto'; // Keep the same width and position panel.style.width = `${currentWidth}px`; panel.style.minWidth = `${currentWidth}px`; // Hide resize handle in header-only mode if (resizeHandle) { resizeHandle.style.display = 'none'; } } else { // Show content and restore full panel size content.style.display = 'block'; panel.style.minHeight = '200px'; // Restore stored width or use default const widthToRestore = this.storedWidth || 300; panel.style.minWidth = `${widthToRestore}px`; // Restore height if it was auto if (!panel.style.height || panel.style.height === 'auto') { panel.style.height = '200px'; } if (!panel.style.width || panel.style.width === `${widthToRestore}px`) { panel.style.width = `${widthToRestore}px`; } // Show resize handle when fully expanded if (resizeHandle) { resizeHandle.style.display = 'flex'; } } } 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 }; // Store current computed position before clearing styles const computedStyle = window.getComputedStyle(this.element); const currentLeft = rect.left; const currentTop = rect.top; // Clear any positioning styles that interfere with dragging this.element.style.right = ''; this.element.style.bottom = ''; this.element.style.transform = ''; // Set the element to its current visual position using left/top this.element.style.left = `${currentLeft}px`; this.element.style.top = `${currentTop}px`; // Update internal position tracking this.position.x = currentLeft; this.position.y = currentTop; // 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 = '●'; // Dot resize indicator resizeHandle.style.cssText = ` position: absolute; bottom: 0px; right: 1px; width: 12px; height: 12px; cursor: se-resize; font-size: 10px; line-height: 1; user-select: none; color: #999; background: transparent; z-index: 10; `; // 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)); this.addEventListener(resizeHandle, 'dblclick', (e) => this.autoResizeToContent(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-right 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 (bottom-right corner) const deltaX = event.clientX - this.resizeStart.mouseX; // Right direction const deltaY = event.clientY - this.resizeStart.mouseY; // Down direction // Get minimum size (collapsed header size or default minimum) const headerHeight = this.element.querySelector('.control-header')?.offsetHeight || 40; const minWidth = 200; const minHeight = headerHeight + 20; // Header plus small padding // Calculate new dimensions with minimum constraints const newWidth = Math.max(minWidth, this.resizeStart.width + deltaX); const newHeight = Math.max(minHeight, 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; } } /** * Auto-resize panel to fit content size with viewport repositioning */ autoResizeToContent(event) { return this.safeOperation(() => { event.preventDefault(); event.stopPropagation(); if (!this.isExpanded) return; const panel = this.element?.querySelector('.control-panel-expanded'); const contentBody = this.element?.querySelector('.control-content-body'); if (!panel || !contentBody) return; // Get current panel position const rect = panel.getBoundingClientRect(); const currentLeft = rect.left; const currentTop = rect.top; // Measure content size by temporarily allowing natural sizing const originalOverflow = contentBody.style.overflow; const originalMaxHeight = panel.style.maxHeight; const originalHeight = panel.style.height; const originalWidth = panel.style.width; // Temporarily remove constraints to measure natural size contentBody.style.overflow = 'visible'; panel.style.maxHeight = 'none'; panel.style.height = 'auto'; panel.style.width = 'auto'; // Force reflow and measure panel.offsetHeight; // Force reflow const contentRect = contentBody.getBoundingClientRect(); const headerHeight = this.element.querySelector('.control-header')?.offsetHeight || 24; // Calculate ideal size with padding and margins const idealWidth = Math.max(300, Math.min(window.innerWidth - 40, contentRect.width + 40)); const idealHeight = Math.max(200, Math.min(window.innerHeight - 40, contentRect.height + headerHeight + 40)); // Restore original constraints contentBody.style.overflow = originalOverflow; panel.style.maxHeight = originalMaxHeight; // Calculate new position to keep panel in viewport let newLeft = currentLeft; let newTop = currentTop; // Adjust position if panel would go outside viewport if (currentLeft + idealWidth > window.innerWidth) { newLeft = window.innerWidth - idealWidth - 20; } if (newLeft < 20) { newLeft = 20; } if (currentTop + idealHeight > window.innerHeight) { newTop = window.innerHeight - idealHeight - 20; } if (newTop < 20) { newTop = 20; } // Apply new size and position panel.style.width = `${idealWidth}px`; panel.style.height = `${idealHeight}px`; // Update position if it changed if (newLeft !== currentLeft || newTop !== currentTop) { this.element.style.left = `${newLeft}px`; this.element.style.top = `${newTop}px`; this.position.x = newLeft; this.position.y = newTop; } // Update internal size tracking this.size.width = idealWidth; this.size.height = idealHeight; }, null, 'autoResizeToContent'); } /** * 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) */ /** * Build content with consistent styling - calls subclass generateContent() */ buildContent() { const content = this.element?.querySelector('.control-content'); if (content) { // Get content from subclass const innerContent = this.generateContent ? this.generateContent() : this.config.defaultContent; // Apply consistent container styling content.innerHTML = `
${innerContent}
`; } } /** * Generate content - subclasses should override this method * @returns {string} HTML content for the panel body */ generateContent() { return this.config.defaultContent || `

Panel content goes here...

`; } /** * 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; }