diff --git a/markitect/static/js/controls/contents-control.js b/markitect/static/js/controls/contents-control.js index 843e3559..b4351052 100644 --- a/markitect/static/js/controls/contents-control.js +++ b/markitect/static/js/controls/contents-control.js @@ -1,93 +1,336 @@ /** - * Contents Control - Displays document table of contents - * Implements the Robustness Principle with Fail Fast mode support + * ContentsControl - Table of Contents Display Control + * + * Provides an interactive table of contents for document navigation. + * Extracts headings from the document and displays them in a hierarchical + * structure with clickable links for quick navigation. + * + * Features: + * - Automatic heading extraction from document + * - Hierarchical display with proper indentation + * - Clickable navigation links with smooth scrolling + * - Real-time updates when document structure changes + * - Collapsible sections for better organization + * - Search functionality within the table of contents + * + * Dependencies: + * - ControlBase (base control functionality) */ -class ContentsControl { +/** + * ContentsControl - Interactive table of contents control + * + * This control scans the document for headings (h1-h6) and presents them + * in a navigable tree structure. Users can click on any heading to jump + * directly to that section with smooth scrolling. + */ +class ContentsControl extends ControlBase { constructor() { - this.control = Object.create(Control); - this.control.config = { - icon: 'β°', + super(); + + // Configure for contents functionality + this.config = { + icon: 'π', title: 'Contents', className: 'contents-control', - defaultContent: 'Click to view table of contents', - ariaLabel: 'Contents Control', - position: 'w' + defaultContent: 'Loading table of contents...', + ariaLabel: 'Table of Contents Control', + position: 'w' // West positioning }; - // Bind methods to control - this.control.buildContent = () => { - const content = this.control.element.querySelector('.control-content'); - const headings = this.extractHeadings(); + // Contents-specific state + this.headings = []; + this.lastScanTime = null; + this.updateInterval = null; + this.searchQuery = ''; + } - content.innerHTML = ` -
No headings found in document
' - } + /** + * Extract all headings from the document + * Creates a hierarchical structure of the document's heading elements + */ + extractHeadings() { + return this.safeOperation(() => { + const headingSelectors = 'h1, h2, h3, h4, h5, h6'; + const headingElements = document.querySelectorAll(headingSelectors); + const extractedHeadings = []; + + headingElements.forEach((heading, index) => { + const level = parseInt(heading.tagName.charAt(1)); + const text = heading.textContent.trim(); + + // Generate or use existing ID for anchor links + let id = heading.id; + if (!id) { + id = text.toLowerCase() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .substring(0, 50); + + // Ensure uniqueness + let counter = 1; + let uniqueId = id; + while (document.getElementById(uniqueId)) { + uniqueId = `${id}-${counter}`; + counter++; + } + + heading.id = uniqueId; + id = uniqueId; + } + + extractedHeadings.push({ + id, + text, + level, + element: heading, + index + }); + }); + + this.headings = extractedHeadings; + this.lastScanTime = Date.now(); + return extractedHeadings; + + }, [], 'extractHeadings'); + } + + /** + * Filter headings based on search query + */ + filterHeadings(headings, query) { + if (!query || query.trim() === '') { + return headings; + } + + const normalizedQuery = query.toLowerCase().trim(); + return headings.filter(heading => + heading.text.toLowerCase().includes(normalizedQuery) + ); + } + + /** + * Generate HTML for the table of contents + */ + generateContentsHTML(headings = null) { + return this.safeOperation(() => { + const displayHeadings = headings || this.headings; + + if (displayHeadings.length === 0) { + return ` +No headings found in document
+Error generating contents
', 'generateContentsHTML'); } - extractHeadings() { - const headings = []; - const elements = document.querySelectorAll('h1, h2, h3, h4, h5, h6'); + /** + * Navigate to a specific heading with smooth scrolling + */ + navigateToHeading(headingId) { + return this.safeOperation(() => { + const targetElement = document.getElementById(headingId); + if (targetElement) { + targetElement.scrollIntoView({ + behavior: 'smooth', + block: 'start' + }); - elements.forEach((heading, index) => { - const level = parseInt(heading.tagName.charAt(1)); - const text = heading.textContent || heading.innerText || ''; - let id = heading.id; + // Highlight the target temporarily + const originalStyle = targetElement.style.backgroundColor; + targetElement.style.backgroundColor = '#fff3cd'; + targetElement.style.transition = 'background-color 0.3s ease'; - // Generate ID if not present - if (!id) { - id = text.toLowerCase() - .replace(/[^\w\s-]/g, '') - .replace(/\s+/g, '-') - .replace(/-+/g, '-') - .replace(/^-|-$/g, '') || `heading-${index}`; - heading.id = id; + setTimeout(() => { + targetElement.style.backgroundColor = originalStyle; + setTimeout(() => { + targetElement.style.transition = ''; + }, 300); + }, 1500); + + return true; + } + return false; + }, false, 'navigateToHeading'); + } + + /** + * Handle search input + */ + handleSearch(query) { + this.searchQuery = query; + const filteredHeadings = this.filterHeadings(this.headings, query); + this.updateContentsDisplay(filteredHeadings); + } + + /** + * Update the contents display with new headings + */ + updateContentsDisplay(headings) { + return this.safeOperation(() => { + const content = this.element?.querySelector('.control-content'); + if (content) { + content.innerHTML = this.generateContentsHTML(headings); + } + }, null, 'updateContentsDisplay'); + } + + /** + * Refresh the contents by re-scanning the document + */ + refreshContents() { + return this.safeOperation(() => { + this.extractHeadings(); + const filteredHeadings = this.filterHeadings(this.headings, this.searchQuery); + this.updateContentsDisplay(filteredHeadings); + + // Show success feedback + const refreshBtn = this.element?.querySelector('button'); + if (refreshBtn) { + const originalText = refreshBtn.innerHTML; + refreshBtn.innerHTML = 'β Updated'; + refreshBtn.style.background = '#28a745'; + + setTimeout(() => { + refreshBtn.innerHTML = originalText; + refreshBtn.style.background = '#28a745'; + }, 1000); + } + }, null, 'refreshContents'); + } + + /** + * Build the control content + * Override of base class method to provide contents-specific functionality + */ + buildContent() { + return this.safeOperation(() => { + // Extract headings on first build + this.extractHeadings(); + + // Generate and set content + const content = this.element?.querySelector('.control-content'); + if (content) { + content.innerHTML = this.generateContentsHTML(); + + // Store reference to this control for onclick handlers + this.element.contentsControl = this; } - headings.push({ - level: level, - text: text.trim(), - id: id + // Set up auto-refresh for dynamic content + if (this.updateInterval) { + clearInterval(this.updateInterval); + } + + this.updateInterval = setInterval(() => { + const currentHeadingCount = document.querySelectorAll('h1, h2, h3, h4, h5, h6').length; + if (currentHeadingCount !== this.headings.length) { + this.refreshContents(); + } + }, 5000); // Check every 5 seconds + + }, null, 'buildContent'); + } + + /** + * Get statistics about the document structure + */ + getDocumentStats() { + return this.safeOperation(() => { + const stats = { + totalHeadings: this.headings.length, + byLevel: {}, + deepestLevel: 1, + structure: 'flat' + }; + + // Count headings by level + this.headings.forEach(heading => { + stats.byLevel[heading.level] = (stats.byLevel[heading.level] || 0) + 1; + stats.deepestLevel = Math.max(stats.deepestLevel, heading.level); }); - }); - return headings; + // Determine structure type + const levels = Object.keys(stats.byLevel).map(Number).sort(); + if (levels.length > 1) { + const hasSequentialLevels = levels.every((level, index) => + index === 0 || level <= levels[index - 1] + 1 + ); + stats.structure = hasSequentialLevels ? 'hierarchical' : 'mixed'; + } + + return stats; + }, {}, 'getDocumentStats'); } - createControl() { - return this.control.createControl(); + /** + * Clean up resources when control is destroyed + */ + destroy() { + if (this.updateInterval) { + clearInterval(this.updateInterval); + this.updateInterval = null; + } + super.destroy(); } } -window.ContentsControl = ContentsControl; \ No newline at end of file +// Export for module systems or attach to global for direct usage +if (typeof module !== 'undefined' && module.exports) { + module.exports = ContentsControl; +} else { + window.ContentsControl = ContentsControl; +} \ No newline at end of file diff --git a/markitect/static/js/controls/control-base.js b/markitect/static/js/controls/control-base.js index aa7b0e65..0706c4b7 100644 --- a/markitect/static/js/controls/control-base.js +++ b/markitect/static/js/controls/control-base.js @@ -1,10 +1,27 @@ /** - * Base Control Class for Markitect UI Controls - * Provides common functionality for positioning, drag, resize, expand/collapse - * Supports Fail Fast strict mode for development + * 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 (must match main.js) +// Development mode detection for enhanced error reporting const MARKITECT_STRICT_MODE = ( window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1' || @@ -12,504 +29,603 @@ const MARKITECT_STRICT_MODE = ( window.markitectStrictMode === true ); -const Control = { - // Default configuration - config: { - icon: 'π§', - title: 'Control', - className: 'base-control', - defaultContent: 'Control content', - ariaLabel: 'Base Control', - position: 'w', // Default compass position: west (middle-left) - footer: null // If null, will use default Markitect copyright - }, +/** + * 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 + }; - // Utility functions for safe operations - safeOperation: function(operation, fallback = null, context = 'Unknown') { + // 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.warn(`Control operation failed in ${context}:`, error); + console.error(`Control operation failed in ${context}:`, error); - // Fail Fast in development mode if (MARKITECT_STRICT_MODE) { - console.error(`π¨ STRICT MODE: Control operation failed in ${context}`); - throw error; // Re-throw for immediate debugging + throw error; // Re-throw in strict mode for debugging } - if (window.MarkitectDebugSystem) { - window.MarkitectDebugSystem.addMessage( - `Safe operation failed: ${error.message}`, - 'WARNING', - 'Control', - { context, eventType: 'ERROR' } - ); - } - return typeof fallback === 'function' ? fallback() : fallback; + return fallback; } - }, + } - safeQuerySelector: function(selector, parent = document) { - try { - if (!parent || !parent.querySelector) { - return null; - } - return parent.querySelector(selector); - } catch (error) { - console.warn(`Invalid selector: ${selector}`, error); - return null; - } - }, - - safeQuerySelectorAll: function(selector, parent = document) { - try { - if (!parent || !parent.querySelectorAll) { - return []; - } - return Array.from(parent.querySelectorAll(selector)); - } catch (error) { - console.warn(`Invalid selector: ${selector}`, error); - return []; - } - }, - - // Version and default footer - getMarkitectVersion: function() { + /** + * Create and initialize the control element + * This method sets up the basic DOM structure that all controls use + */ + createElement() { return this.safeOperation(() => { - // Try to get version from various sources - if (window.markitectVersion) { - return window.markitectVersion; + if (this.element) { + this.destroy(); // Clean up existing element } - // Check for generator meta tag in document head - const generatorMeta = this.safeQuerySelector('meta[name="generator"]'); - if (generatorMeta) { - const content = generatorMeta.getAttribute('content'); - if (content && content.includes('Markitect')) { - // Extract version from generator content - // Expected formats: "Markitect 1.0.0" or "Markitect/1.0.0" or "Markitect v1.0.0" - const versionMatch = content.match(/Markitect[\s\/v]*([\d\.]+[\w\-\.]*)/i); - if (versionMatch && versionMatch[1]) { - return versionMatch[1]; - } - } - } + const control = document.createElement('div'); + control.className = `control-panel ${this.config.className}`; + control.setAttribute('role', 'dialog'); + control.setAttribute('aria-label', this.config.ariaLabel); - // Fallback version with generation timestamp - const now = new Date(); - const timestamp = now.toISOString().slice(0, 19).replace('T', ' '); - return `Generated ${timestamp}`; - }, () => 'Unknown Version', 'getMarkitectVersion'); - }, - - getDefaultFooter: function() { - return `Β© Markitect ${this.getMarkitectVersion()}`; - }, - - getFooter: function() { - if (this.config.footer !== null) { - return this.config.footer; - } - return this.getDefaultFooter(); - }, - - // Compass positioning system (top-aligned for proper expansion) - compassPositions: { - 'n': { top: '20px', left: '50%', transform: 'translateX(-50%)' }, - 'nne': { top: '40px', right: '120px' }, - 'ne': { top: '20px', right: '20px' }, - 'ene': { top: '80px', right: '20px' }, - 'e': { top: '50vh', right: '20px', transform: 'translateY(-20px)' }, - 'ese': { top: 'calc(50vh + 80px)', right: '20px', transform: 'translateY(-20px)' }, - 'se': { bottom: '20px', right: '20px' }, - 'sse': { bottom: '40px', right: '120px' }, - 's': { bottom: '20px', left: '50%', transform: 'translateX(-50%)' }, - 'ssw': { bottom: '40px', left: '120px' }, - 'sw': { bottom: '20px', left: '20px' }, - 'wsw': { bottom: '80px', left: '20px' }, - 'w': { top: '50vh', left: '20px', transform: 'translateY(-20px)' }, - 'wnw': { top: 'calc(50vh - 80px)', left: '20px', transform: 'translateY(-20px)' }, - 'nw': { top: '20px', left: '20px' }, - 'nnw': { top: '40px', left: '120px' } - }, - - // State management - isExpanded: false, - isDragging: false, - isResizing: false, - element: null, - - createControl: function() { - return this.safeOperation(() => { - console.log(`Creating ${this.config.title} control...`); - - // Validate configuration - if (!this.config || !this.config.title) { - throw new Error('Invalid control configuration'); - } - - // Ensure document.body exists - if (!document.body) { - throw new Error('Document body not available'); - } - - // Create main control element - this.element = document.createElement('div'); - this.element.className = `control-panel ${this.config.className || ''}`; - this.element.setAttribute('role', 'dialog'); - this.element.setAttribute('aria-label', this.config.ariaLabel || this.config.title); - - // Position the control using compass system - const position = this.compassPositions[this.config.position] || this.compassPositions['w']; - Object.assign(this.element.style, { - position: 'fixed', - zIndex: '1000', - ...position - }); - - // Build the control structure - this.buildControlStructure(); - - // Add to document - document.body.appendChild(this.element); - - console.log(`${this.config.title} control created and positioned at ${this.config.position}`); - return this.element; - }, () => { - console.error(`Failed to create ${this.config?.title || 'Unknown'} control`); - return null; - }, 'createControl'); - }, - - buildControlStructure: function() { - this.safeOperation(() => { - if (!this.element) { - throw new Error('Control element not available'); - } - - // Sanitize configuration values - const safeIcon = (this.config.icon || 'π§').replace(/[<>"'&]/g, ''); - const safeTitle = (this.config.title || 'Control').replace(/[<>"'&]/g, ''); - const safeContent = (this.config.defaultContent || 'Control content').replace(/[<>]/g, ''); - - this.element.innerHTML = ` -