generated from coulomb/repo-seed
Complete integration of refactored testdrive-jsui capability: ## Refactored Architecture - js/ - All JavaScript source (controls, components, core) - static/ - CSS, images, templates - src/testdrive_jsui/ - Python package - tests/ - Python tests ## Plugin Self-Declaration - get_plugin_source_dir() - plugin declares own location - get_asset_paths() - organized asset paths - No hardcoded discovery logic ## Merged Content - Baseline UI scaffold (tutorials, LICENSE, INTRODUCTION.md) - Refactored capability implementation - Comprehensive documentation Ready for standalone use or integration with markitect. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
852 lines
29 KiB
JavaScript
852 lines
29 KiB
JavaScript
/**
|
|
* 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 = `
|
|
<button class="control-toggle" aria-label="${this.config.ariaLabel}">${this.config.icon}</button>
|
|
<div class="control-panel-expanded" style="display: none;">
|
|
<div class="control-header">
|
|
<span class="control-icon">${this.config.icon}</span>
|
|
<span class="control-title">${this.config.title}</span>
|
|
<button class="control-close">✕</button>
|
|
</div>
|
|
<div class="control-content">
|
|
${this.config.defaultContent}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
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 = `
|
|
<div class="control-content-container" style="
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
margin: 0 0 10px 1rem;
|
|
padding: 0.75rem 1rem 1rem 0;
|
|
font-size: 0.8rem;
|
|
box-sizing: border-box;
|
|
min-height: 0;
|
|
border-radius: 0 0 6px 6px;
|
|
overflow: hidden;
|
|
">
|
|
<div class="control-content-body" style="
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
padding: 0;
|
|
margin-bottom: 0;
|
|
min-height: 0;
|
|
">
|
|
${innerContent}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate content - subclasses should override this method
|
|
* @returns {string} HTML content for the panel body
|
|
*/
|
|
generateContent() {
|
|
return this.config.defaultContent || `<p>Panel content goes here...</p>`;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
} |