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 = ` -
-

Table of Contents

-
- ${headings.length > 0 ? - headings.map(heading => - `
- - ${heading.text} - -
` - ).join('') : - '

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

+
-
- ${headings.length} heading${headings.length !== 1 ? 's' : ''} found + `; + } + + const searchHTML = ` +
+ +
+ `; + + const contentsHTML = displayHeadings.map(heading => { + const indentLevel = Math.max(0, heading.level - 1); + const indentPx = indentLevel * 15; + + return ` + + `; + }).join(''); + + return ` +
+ ${searchHTML} +
+
+ Found ${displayHeadings.length} heading${displayHeadings.length !== 1 ? 's' : ''} +
+ ${contentsHTML} +
+
+
`; - this.control.isExpanded = true; - }; - this.control.toggle = () => { - if (this.control.isExpanded) { - this.control.element.querySelector('.control-content').style.display = 'none'; - this.control.isExpanded = false; - } else { - this.control.buildContent(); - this.control.element.querySelector('.control-content').style.display = 'block'; - } - }; + }, '

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 = ` -
-
- ${safeIcon} - ${safeTitle} + control.innerHTML = ` + + -
-
- ${safeContent} +
+ ${this.config.defaultContent}
-
`; - // Set up event listeners with error protection - this.safeOperation(() => this.setupEventListeners(), null, 'setupEventListeners'); - this.safeOperation(() => this.addResizeHandle(), null, 'addResizeHandle'); - this.safeOperation(() => this.addDragFunctionality(), null, 'addDragFunctionality'); - }, () => { - console.error('Failed to build control structure'); - if (this.element) { - this.element.innerHTML = '
Control failed to load
'; - } - }, 'buildControlStructure'); - }, + this.element = control; + this.setupStyles(); + this.setupEventListeners(); + return control; - setupEventListeners: function() { - const header = this.safeQuerySelector('.control-header', this.element); - const closeBtn = this.safeQuerySelector('.control-close', this.element); + }, null, 'createElement'); + } - if (!header || !closeBtn) { - console.warn('Control header or close button not found'); - return; + /** + * 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()); } - // Toggle expand/collapse on header click - header.addEventListener('click', (e) => { - this.safeOperation(() => { - e.stopPropagation(); - this.toggle(); - }, null, 'headerClick'); - }); + // Close button to collapse back to icon + const closeBtn = this.element.querySelector('.control-close'); + if (closeBtn) { + this.addEventListener(closeBtn, 'click', () => this.collapse()); + } - // Close button - closeBtn.addEventListener('click', (e) => { - this.safeOperation(() => { - e.stopPropagation(); - this.collapse(); - }, null, 'closeClick'); - }); + // Header title click to toggle content visibility + const title = this.element.querySelector('.control-title'); + if (title) { + this.addEventListener(title, 'click', () => this.toggleHeaderOnly()); + } - // Show/hide close button and resize handle on hover with bounds checking - this.element.addEventListener('mouseenter', () => { - this.safeOperation(() => { - if (this.isExpanded && closeBtn) { - closeBtn.style.display = 'flex'; - const resizeHandle = this.safeQuerySelector('.resize-handle', this.element); - if (resizeHandle) { - resizeHandle.style.display = 'block'; - } + // 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); } - }, null, 'mouseEnter'); - }); + }); + } + } - this.element.addEventListener('mouseleave', () => { - this.safeOperation(() => { + /** + * 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.display = 'none'; - } - const resizeHandle = this.safeQuerySelector('.resize-handle', this.element); - if (resizeHandle) { - resizeHandle.style.display = 'none'; - } - }, null, 'mouseLeave'); - }); - }, - - addResizeHandle: function() { - const resizeHandle = document.createElement('div'); - resizeHandle.className = 'resize-handle'; - resizeHandle.innerHTML = ''; // Small circle via CSS - resizeHandle.style.cssText = ` - position: absolute; bottom: 2px; right: 2px; - width: 8px; height: 8px; cursor: nw-resize; - display: none; background: #6c757d; border-radius: 50%; - `; - - this.element.appendChild(resizeHandle); - - // Resize functionality - let startX, startY, startWidth, startHeight; - - resizeHandle.addEventListener('mousedown', (e) => { - e.preventDefault(); - e.stopPropagation(); - this.isResizing = true; - - const content = this.element.querySelector('.control-content'); - const rect = content.getBoundingClientRect(); - - startX = e.clientX; - startY = e.clientY; - startWidth = rect.width; - startHeight = rect.height; - - document.addEventListener('mousemove', handleResize); - document.addEventListener('mouseup', stopResize); - }); - - const handleResize = (e) => { - if (!this.isResizing) return; - - const content = this.element.querySelector('.control-content'); - const deltaX = e.clientX - startX; - const deltaY = e.clientY - startY; - - const newWidth = Math.max(200, startWidth + deltaX); - const newHeight = Math.max(100, startHeight + deltaY); - - content.style.width = `${newWidth}px`; - content.style.height = `${newHeight}px`; - }; - - const stopResize = () => { - this.isResizing = false; - document.removeEventListener('mousemove', handleResize); - document.removeEventListener('mouseup', stopResize); - }; - }, - - addDragFunctionality: function() { - const header = this.safeQuerySelector('.control-header', this.element); - if (!header) { - console.warn('Header not found for drag functionality'); - return; - } - - let startX, startY, startLeft, startTop, dragTimeout; - - header.addEventListener('mousedown', (e) => { - this.safeOperation(() => { - if (e.target.closest('.control-close')) return; - - // Clear any existing drag timeout - if (dragTimeout) { - clearTimeout(dragTimeout); + 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; + `; } - this.isDragging = true; - const rect = this.element.getBoundingClientRect(); + // Add resize handle + this.addResizeHandle(); + this.buildContent(); + } - startX = e.clientX; - startY = e.clientY; - startLeft = rect.left; - startTop = rect.top; + return this.isExpanded; + }, false, 'expand'); + } - document.addEventListener('mousemove', handleDrag); - document.addEventListener('mouseup', stopDrag); + /** + * 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'); - // Safety timeout to prevent infinite dragging - dragTimeout = setTimeout(() => { - if (this.isDragging) { - console.warn('Drag operation timed out'); - stopDrag(); - } - }, 30000); // 30 second timeout - }, null, 'dragStart'); - }); + if (panel && toggleBtn) { + panel.style.display = 'none'; + toggleBtn.style.display = 'block'; - const handleDrag = (e) => { - this.safeOperation(() => { - if (!this.isDragging || !this.element) return; - - const deltaX = e.clientX - startX; - const deltaY = e.clientY - startY; - - // Constrain to viewport bounds - const viewportWidth = window.innerWidth || document.documentElement.clientWidth; - const viewportHeight = window.innerHeight || document.documentElement.clientHeight; - - const newLeft = Math.max(0, Math.min(viewportWidth - 100, startLeft + deltaX)); - const newTop = Math.max(0, Math.min(viewportHeight - 50, startTop + deltaY)); - - this.element.style.left = `${newLeft}px`; - this.element.style.top = `${newTop}px`; - this.element.style.right = 'auto'; - this.element.style.bottom = 'auto'; - this.element.style.transform = 'none'; - }, null, 'dragMove'); - }; - - const stopDrag = () => { - this.safeOperation(() => { - this.isDragging = false; - if (dragTimeout) { - clearTimeout(dragTimeout); - dragTimeout = null; + // 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 || ''; } - document.removeEventListener('mousemove', handleDrag); - document.removeEventListener('mouseup', stopDrag); - }, null, 'dragStop'); - }; - }, - expand: function() { - this.safeOperation(() => { - if (this.isExpanded) return; + // Remove resize handle + this.removeResizeHandle(); + } - const content = this.safeQuerySelector('.control-content', this.element); - const closeBtn = this.safeQuerySelector('.control-close', this.element); + return !this.isExpanded; + }, false, 'collapse'); + } - if (!content || !closeBtn) { - console.warn('Control content or close button not found for expansion'); + /** + * Toggle header-only visibility (content show/hide) + */ + toggleHeaderOnly() { + return this.safeOperation(() => { + if (!this.isExpanded) { + // If collapsed, expand first + this.expand(); return; } - content.style.display = 'block'; - closeBtn.style.display = 'flex'; - this.isExpanded = true; - - // Style footer - this.styleFooter(); - - console.log(`${this.config.title || 'Unknown'} control expanded`); - }, null, 'expand'); - }, - - collapse: function() { - this.safeOperation(() => { - if (!this.isExpanded) return; - - const content = this.safeQuerySelector('.control-content', this.element); - const closeBtn = this.safeQuerySelector('.control-close', this.element); - const resizeHandle = this.safeQuerySelector('.resize-handle', this.element); - + const content = this.element?.querySelector('.control-content'); if (content) { - content.style.display = 'none'; - content.style.width = ''; - content.style.height = ''; + this.isHeaderOnly = !this.isHeaderOnly; + content.style.display = this.isHeaderOnly ? 'none' : 'block'; } - if (closeBtn) { - closeBtn.style.display = 'none'; - } - if (resizeHandle) { - resizeHandle.style.display = 'none'; - } - this.isExpanded = false; - console.log(`${this.config.title || 'Unknown'} control collapsed`); - }, null, 'collapse'); - }, - - toggle: function() { - this.safeOperation(() => { - if (this.isExpanded) { - this.collapse(); - } else { - if (this.buildContent) { - this.buildContent(); - } else { - this.expand(); - } - } - }, null, 'toggle'); - }, - - styleFooter: function() { - this.safeOperation(() => { - const footer = this.safeQuerySelector('.control-footer', this.element); - if (!footer) return; - - const footerText = this.getFooter(); - - if (footerText && footerText.trim()) { - // Sanitize footer text - const safeText = footerText.replace(/[<>"'&]/g, ''); - footer.textContent = safeText; - footer.style.cssText = ` - display: block; padding: 0.5rem; font-size: 0.7rem; - color: #6c757d; text-align: center; font-style: italic; - background: #f8f9fa; border-top: 1px solid #e9ecef; - border-radius: 0 0 6px 6px; - `; - } else { - footer.style.display = 'none'; - } - }, null, 'styleFooter'); - }, - - // Virtual method - should be overridden by specific controls - buildContent: function() { - this.safeOperation(() => { - console.log(`${this.config.title || 'Unknown'} control - buildContent should be overridden`); - this.expand(); - }, () => { - console.error('Failed to build content, expanding basic control'); - this.expand(); - }, 'buildContent'); + return this.isHeaderOnly; + }, false, 'toggleHeaderOnly'); } -}; -// Export for use in other modules -window.Control = Control; \ No newline at end of file + /** + * 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; +} \ No newline at end of file diff --git a/markitect/static/js/controls/debug-control.js b/markitect/static/js/controls/debug-control.js index 33abadbf..1135235b 100644 --- a/markitect/static/js/controls/debug-control.js +++ b/markitect/static/js/controls/debug-control.js @@ -3,10 +3,10 @@ * Implements the Robustness Principle with Fail Fast mode support */ -class DebugControl { +class DebugControl extends ControlBase { constructor() { - this.control = Object.create(Control); - this.control.config = { + super(); + this.config = { icon: 'πŸͺ²', title: 'Debug', className: 'debug-control', @@ -15,9 +15,13 @@ class DebugControl { position: 'w' }; - // Bind methods to control - this.control.buildContent = () => { - const content = this.control.element.querySelector('.control-content'); + // Store messages for debug display + this.messages = []; + } + + buildContent() { + const content = this.element?.querySelector('.control-content'); + if (content) { const messages = window.MarkitectDebugSystem ? window.MarkitectDebugSystem.getMessages() : []; @@ -41,22 +45,7 @@ class DebugControl {
`; - this.control.isExpanded = true; - }; - - this.control.toggle = () => { - if (this.control.isExpanded) { - this.control.element.querySelector('.control-content').style.display = 'none'; - this.control.isExpanded = false; - } else { - this.control.buildContent(); - this.control.element.querySelector('.control-content').style.display = 'block'; - } - }; - } - - createControl() { - return this.control.createControl(); + } } } diff --git a/markitect/static/js/controls/edit-control.js b/markitect/static/js/controls/edit-control.js index b87ebc70..7a20a7d2 100644 --- a/markitect/static/js/controls/edit-control.js +++ b/markitect/static/js/controls/edit-control.js @@ -1,70 +1,500 @@ /** - * Edit Control - Document editing tools and actions - * Implements the Robustness Principle with Fail Fast mode support + * EditControl - Document Editing Tools and Actions Control + * + * Provides a comprehensive set of document editing tools including text formatting, + * document actions (print, save, export), navigation helpers, and editing modes. + * Designed to enhance the writing and editing experience within the TestDrive-JSUI + * environment. + * + * Features: + * - Document actions (print, save, export to various formats) + * - Text formatting tools (bold, italic, headers) + * - Navigation helpers (scroll to top/bottom, go to line) + * - Word processing features (find/replace, word count) + * - Accessibility tools (font size, contrast adjustment) + * - Markdown formatting shortcuts + * + * Dependencies: + * - ControlBase (base control functionality) */ -class EditControl { +/** + * EditControl - Comprehensive document editing control + * + * This control provides writers and editors with essential tools for document + * creation and modification. It includes both basic text operations and + * advanced features for content management and formatting. + */ +class EditControl extends ControlBase { constructor() { - this.control = Object.create(Control); - this.control.config = { + super(); + + // Configure for editing functionality + this.config = { icon: '✏️', title: 'Edit', className: 'edit-control', - defaultContent: 'Document editing tools', - ariaLabel: 'Edit Control', - position: 'e' + defaultContent: 'Document editing tools loading...', + ariaLabel: 'Document Edit Control', + position: 'e' // East positioning }; - // Bind methods to control - this.control.buildContent = () => { - const content = this.control.element.querySelector('.control-content'); + // Edit control state + this.editingMode = 'view'; // 'view', 'edit', 'preview' + this.fontSize = 16; + this.lastSaveTime = null; + this.unsavedChanges = false; + this.shortcuts = new Map(); - content.innerHTML = ` + this.initializeShortcuts(); + } + + /** + * Initialize keyboard shortcuts for editing + */ + initializeShortcuts() { + this.shortcuts.set('Ctrl+S', () => this.saveDocument()); + this.shortcuts.set('Ctrl+P', () => this.printDocument()); + this.shortcuts.set('Ctrl+F', () => this.showFindDialog()); + this.shortcuts.set('Ctrl+B', () => this.toggleBold()); + this.shortcuts.set('Ctrl+I', () => this.toggleItalic()); + this.shortcuts.set('Escape', () => this.exitEditMode()); + } + + /** + * Generate the main editing tools HTML + */ + generateEditToolsHTML() { + return this.safeOperation(() => { + return `
-

Edit Tools

+

Edit Tools

-
- - -
-
- Page Info:
- Title: ${document.title}
- Words: ~${(document.body.textContent || '').split(/\\s+/).filter(w => w.length > 0).length}
- Modified: ${document.lastModified} + + + + +
+
Text Tools
+ + + +
+ + + +
+ + +
+ + +
+
Markdown Tools
+ +
+ + + + + + + +
+
+ + +
+
+
Mode: ${this.editingMode}
+
Font: ${this.fontSize}px
+ ${this.lastSaveTime ? `
Saved: ${new Date(this.lastSaveTime).toLocaleTimeString()}
` : ''} + ${this.unsavedChanges ? '
⚠️ Unsaved changes
' : ''} +
`; - this.control.isExpanded = true; - }; - this.control.toggle = () => { - if (this.control.isExpanded) { - this.control.element.querySelector('.control-content').style.display = 'none'; - this.control.isExpanded = false; - } else { - this.control.buildContent(); - this.control.element.querySelector('.control-content').style.display = 'block'; - } - }; + }, '

Error generating edit tools

', 'generateEditToolsHTML'); } - createControl() { - return this.control.createControl(); + /** + * Print the document + */ + printDocument() { + return this.safeOperation(() => { + window.print(); + + // Show feedback + this.showActionFeedback('πŸ–¨οΈ Print dialog opened', '#28a745'); + }, null, 'printDocument'); + } + + /** + * Save document (placeholder - would integrate with actual save system) + */ + saveDocument() { + return this.safeOperation(() => { + // In a real implementation, this would save to a backend + this.lastSaveTime = Date.now(); + this.unsavedChanges = false; + + // Update display + this.buildContent(); + + // Show feedback + this.showActionFeedback('πŸ’Ύ Document saved', '#007bff'); + }, null, 'saveDocument'); + } + + /** + * Export document to various formats + */ + exportDocument() { + return this.safeOperation(() => { + const contentArea = document.querySelector('#markitect-content') || document.body; + const htmlContent = contentArea.innerHTML; + const textContent = contentArea.textContent; + + // Create export menu + const exportMenu = document.createElement('div'); + exportMenu.style.cssText = ` + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: white; + border: 2px solid #007bff; + border-radius: 8px; + padding: 1rem; + z-index: 10000; + box-shadow: 0 4px 20px rgba(0,0,0,0.3); + `; + + exportMenu.innerHTML = ` +

Export Document

+ + + + + `; + + // Add export functions + exportMenu.exportAsHTML = () => { + this.downloadFile(htmlContent, 'document.html', 'text/html'); + document.body.removeChild(exportMenu); + }; + + exportMenu.exportAsText = () => { + this.downloadFile(textContent, 'document.txt', 'text/plain'); + document.body.removeChild(exportMenu); + }; + + exportMenu.exportAsMarkdown = () => { + // Simple HTML to Markdown conversion (basic) + let markdown = htmlContent + .replace(/]*>(.*?)<\/h1>/gi, '# $1\n\n') + .replace(/]*>(.*?)<\/h2>/gi, '## $1\n\n') + .replace(/]*>(.*?)<\/h3>/gi, '### $1\n\n') + .replace(/]*>(.*?)<\/p>/gi, '$1\n\n') + .replace(/]*>(.*?)<\/strong>/gi, '**$1**') + .replace(/]*>(.*?)<\/em>/gi, '*$1*') + .replace(/<[^>]*>/g, ''); // Remove remaining HTML tags + + this.downloadFile(markdown, 'document.md', 'text/markdown'); + document.body.removeChild(exportMenu); + }; + + document.body.appendChild(exportMenu); + + }, null, 'exportDocument'); + } + + /** + * Download a file with given content + */ + downloadFile(content, filename, mimeType) { + const blob = new Blob([content], { type: mimeType }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } + + /** + * Scroll to top of document + */ + scrollToTop() { + window.scrollTo({ top: 0, behavior: 'smooth' }); + this.showActionFeedback('⬆️ Scrolled to top', '#6c757d'); + } + + /** + * Scroll to bottom of document + */ + scrollToBottom() { + window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); + this.showActionFeedback('⬇️ Scrolled to bottom', '#6c757d'); + } + + /** + * Show go to line dialog + */ + showGoToLine() { + const lineNumber = prompt('Go to line number:'); + if (lineNumber && !isNaN(lineNumber)) { + // Simple implementation - scroll to approximate position + const totalHeight = document.body.scrollHeight; + const approximatePosition = (parseInt(lineNumber) / 100) * totalHeight; + window.scrollTo({ top: approximatePosition, behavior: 'smooth' }); + this.showActionFeedback(`🎯 Went to line ${lineNumber}`, '#6c757d'); + } + } + + /** + * Show find and replace dialog + */ + showFindReplace() { + const searchTerm = prompt('Find text:'); + if (searchTerm) { + // Simple highlight implementation + this.highlightText(searchTerm); + this.showActionFeedback(`πŸ” Highlighted "${searchTerm}"`, '#ffc107', '#000'); + } + } + + /** + * Highlight text in the document + */ + highlightText(searchTerm) { + return this.safeOperation(() => { + // Remove previous highlights + document.querySelectorAll('.edit-highlight').forEach(el => { + el.outerHTML = el.innerHTML; + }); + + // Add new highlights + const contentArea = document.querySelector('#markitect-content') || document.body; + const walker = document.createTreeWalker( + contentArea, + NodeFilter.SHOW_TEXT, + null, + false + ); + + const textNodes = []; + let node; + while (node = walker.nextNode()) { + textNodes.push(node); + } + + textNodes.forEach(textNode => { + const parent = textNode.parentNode; + const text = textNode.textContent; + if (text.toLowerCase().includes(searchTerm.toLowerCase())) { + const regex = new RegExp(`(${searchTerm})`, 'gi'); + const highlightedHTML = text.replace(regex, '$1'); + + const wrapper = document.createElement('div'); + wrapper.innerHTML = highlightedHTML; + while (wrapper.firstChild) { + parent.insertBefore(wrapper.firstChild, textNode); + } + parent.removeChild(textNode); + } + }); + }, null, 'highlightText'); + } + + /** + * Increase font size + */ + increaseFontSize() { + this.fontSize = Math.min(this.fontSize + 2, 24); + this.applyFontSize(); + this.buildContent(); + } + + /** + * Decrease font size + */ + decreaseFontSize() { + this.fontSize = Math.max(this.fontSize - 2, 12); + this.applyFontSize(); + this.buildContent(); + } + + /** + * Apply font size to document + */ + applyFontSize() { + const contentArea = document.querySelector('#markitect-content') || document.body; + contentArea.style.fontSize = `${this.fontSize}px`; + } + + /** + * Copy page link to clipboard + */ + copyLink() { + return this.safeOperation(() => { + const url = window.location.href; + if (navigator.clipboard) { + navigator.clipboard.writeText(url).then(() => { + this.showActionFeedback('πŸ“‹ Link copied to clipboard', '#fd7e14'); + }); + } else { + // Fallback for older browsers + prompt('Copy this link:', url); + this.showActionFeedback('πŸ“‹ Link displayed for copying', '#fd7e14'); + } + }, null, 'copyLink'); + } + + /** + * Insert markdown formatting + */ + insertMarkdown(prefix, suffix, placeholder) { + // This would integrate with an actual text editor + // For now, just show what would be inserted + const text = `${prefix}${placeholder}${suffix}`; + if (navigator.clipboard) { + navigator.clipboard.writeText(text); + this.showActionFeedback(`πŸ“‹ Copied: ${text}`, '#495057'); + } else { + prompt('Markdown to copy:', text); + } + } + + /** + * Show action feedback message + */ + showActionFeedback(message, backgroundColor, color = 'white') { + const feedback = document.createElement('div'); + feedback.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + background: ${backgroundColor}; + color: ${color}; + padding: 0.5rem 1rem; + border-radius: 4px; + z-index: 9999; + font-size: 0.8rem; + box-shadow: 0 2px 10px rgba(0,0,0,0.2); + `; + feedback.textContent = message; + document.body.appendChild(feedback); + + setTimeout(() => { + if (feedback.parentNode) { + document.body.removeChild(feedback); + } + }, 3000); + } + + /** + * Build the control content + * Override of base class method to provide edit-specific functionality + */ + buildContent() { + return this.safeOperation(() => { + const content = this.element?.querySelector('.control-content'); + if (content) { + content.innerHTML = this.generateEditToolsHTML(); + + // Store reference to this control for onclick handlers + this.element.editControl = this; + } + }, null, 'buildContent'); + } + + /** + * Exit edit mode + */ + exitEditMode() { + this.editingMode = 'view'; + this.buildContent(); } } -window.EditControl = EditControl; \ 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 = EditControl; +} else { + window.EditControl = EditControl; +} \ No newline at end of file diff --git a/markitect/static/js/controls/status-control.js b/markitect/static/js/controls/status-control.js index 82232bc5..f3ca19d2 100644 --- a/markitect/static/js/controls/status-control.js +++ b/markitect/static/js/controls/status-control.js @@ -1,616 +1,368 @@ /** - * Status Control - Document statistics and change tracking + * StatusControl - Document Statistics and Change Tracking Control + * + * Provides real-time document statistics including word count, character count, + * reading time estimation, and change tracking. Monitors document modifications + * and provides insights into document structure and content metrics. + * + * Features: + * - Real-time word and character counting + * - Reading time estimation based on content + * - Document structure analysis (headings, paragraphs, lists) + * - Change tracking with before/after comparisons + * - Content complexity metrics + * - Export functionality for statistics + * + * Dependencies: + * - ControlBase (base control functionality) */ -class StatusControl { + +/** + * StatusControl - Document statistics and monitoring control + * + * This control continuously monitors the document for changes and provides + * detailed statistics about content, structure, and reading metrics. + * Useful for writers, editors, and content creators. + */ +class StatusControl extends ControlBase { constructor() { - this.control = Object.create(Control); + super(); // Configure for status functionality - this.control.config = { + this.config = { icon: 'πŸ“Š', title: 'Status', className: 'status-control', - defaultContent: 'Document statistics and changes', - ariaLabel: 'Status Control', - position: 'e', // East positioning - footer: `Updated ${new Date().toLocaleTimeString()}` + defaultContent: 'Loading document statistics...', + ariaLabel: 'Document Status Control', + position: 'e' // East positioning }; - // Initialize change tracking - this.control.changeTracking = { - headings: new Set(), - sections: new Set(), - images: new Set(), - tables: new Set(), - lastScanTime: null, - initialCounts: { - headings: 0, - sections: 0, - images: 0, - tables: 0, - lines: 0, - words: 0, - characters: 0 - } + // Status tracking state + this.stats = { + characters: 0, + charactersNoSpaces: 0, + words: 0, + sentences: 0, + paragraphs: 0, + headings: 0, + lists: 0, + images: 0, + links: 0, + readingTimeMinutes: 0 }; - this.bindMethods(); + this.previousStats = { ...this.stats }; + this.lastUpdateTime = null; + this.updateInterval = null; + this.wordsPerMinute = 200; // Average reading speed } - bindMethods() { - // Bind utility functions - this.control.safeTextExtraction = this.safeTextExtraction.bind(this); - this.control.sanitizeText = this.sanitizeText.bind(this); - this.control.validateElement = this.validateElement.bind(this); - this.control.safeStatsOperation = this.safeStatsOperation.bind(this); + /** + * Extract and count document content statistics + */ + analyzeDocument() { + return this.safeOperation(() => { + const contentArea = document.querySelector('#markitect-content') || document.body; + const textContent = contentArea.textContent || ''; - // Bind existing methods - this.control.calculateStats = this.calculateStats.bind(this); - this.control.isContentSection = this.isContentSection.bind(this); - this.control.isContentTable = this.isContentTable.bind(this); - this.control.updateChangeTracking = this.updateChangeTracking.bind(this); - this.control.buildContent = this.buildContent.bind(this); - this.control.refreshStats = this.refreshStats.bind(this); - this.control.resetChangeTracking = this.resetChangeTracking.bind(this); - this.control.setupAutoRefresh = this.setupAutoRefresh.bind(this); + // Basic text statistics + this.stats.characters = textContent.length; + this.stats.charactersNoSpaces = textContent.replace(/\s/g, '').length; - // Override collapse to clean up intervals - const originalCollapse = this.control.collapse; - this.control.collapse = () => { - if (this.control.autoRefreshInterval) { - clearInterval(this.control.autoRefreshInterval); - this.control.autoRefreshInterval = null; - } - originalCollapse.call(this.control); - }; + // Word counting (more accurate) + const words = textContent.trim().split(/\s+/).filter(word => word.length > 0); + this.stats.words = words.length; + + // Sentence counting (approximate) + const sentences = textContent.split(/[.!?]+/).filter(s => s.trim().length > 0); + this.stats.sentences = sentences.length; + + // Structural elements + this.stats.paragraphs = contentArea.querySelectorAll('p').length; + this.stats.headings = contentArea.querySelectorAll('h1, h2, h3, h4, h5, h6').length; + this.stats.lists = contentArea.querySelectorAll('ul, ol').length; + this.stats.images = contentArea.querySelectorAll('img').length; + this.stats.links = contentArea.querySelectorAll('a').length; + + // Reading time calculation + this.stats.readingTimeMinutes = Math.ceil(this.stats.words / this.wordsPerMinute); + + this.lastUpdateTime = Date.now(); + return this.stats; + + }, this.stats, 'analyzeDocument'); } - // Utility functions for safe operations - safeTextExtraction(element) { - if (!this.validateElement(element)) { - return ''; - } - - try { - const text = element.textContent || element.innerText || ''; - return this.sanitizeText(text.trim()); - } catch (error) { - console.warn('Text extraction failed:', error); - return ''; - } + /** + * Calculate changes since last analysis + */ + calculateChanges() { + return this.safeOperation(() => { + const changes = {}; + for (const [key, currentValue] of Object.entries(this.stats)) { + const previousValue = this.previousStats[key] || 0; + const difference = currentValue - previousValue; + changes[key] = { + current: currentValue, + previous: previousValue, + change: difference, + hasChanged: difference !== 0 + }; + } + return changes; + }, {}, 'calculateChanges'); } - sanitizeText(text) { - if (typeof text !== 'string') { - return ''; - } + /** + * Format statistics for display + */ + formatStatistics() { + return this.safeOperation(() => { + const changes = this.calculateChanges(); - // Remove potentially harmful characters and limit length - const maxLength = 100000; // 100KB text limit - const sanitized = text - .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, '') // Remove control chars - .slice(0, maxLength); // Limit length - - return sanitized; - } - - validateElement(element) { - return element && - element.nodeType === Node.ELEMENT_NODE && - element.isConnected && - !element.closest('.control-panel'); // Avoid control elements - } - - safeStatsOperation(operation, fallback = 0, context = 'stats') { - try { - const result = operation(); - // Validate numeric results - return typeof result === 'number' && isFinite(result) ? result : fallback; - } catch (error) { - console.warn(`Stats operation failed in ${context}:`, error); - if (window.MarkitectDebugSystem) { - window.MarkitectDebugSystem.addMessage( - `Stats operation failed: ${error.message}`, - 'WARNING', - 'StatusControl', - { context, eventType: 'ERROR' } - ); - } - return fallback; - } - } - - calculateStats() { - const stats = { - headings: { total: 0, changed: 0 }, - sections: { total: 0, changed: 0 }, - images: { total: 0, changed: 0 }, - tables: { total: 0, changed: 0 }, - document: { lines: 0, words: 0, characters: 0 }, - sections_detail: { lines: 0, words: 0, characters: 0 }, - tables_detail: { lines: 0, words: 0, characters: 0 } - }; - - return this.safeStatsOperation(() => { - // Count headings (h1-h6, excluding control titles) - const headings = this.control.safeQuerySelectorAll('h1, h2, h3, h4, h5, h6'); - const maxElements = 10000; // Limit processing to prevent DoS - - headings.slice(0, maxElements).forEach(heading => { - if (!this.validateElement(heading)) return; - - const text = this.safeTextExtraction(heading).toLowerCase(); - // Skip control headings with enhanced filtering - const controlKeywords = ['contents', 'debug', 'status', 'control', 'menu', 'toolbar']; - const isControlHeading = controlKeywords.some(keyword => text.includes(keyword)); - - if (text.length > 0 && !isControlHeading) { - stats.headings.total++; - const fullText = this.safeTextExtraction(heading); - if (this.control.changeTracking.headings.has(fullText)) { - stats.headings.changed++; - } - } - }); - - // Count sections (content blocks excluding headings and table cells) - const sections = this.control.safeQuerySelectorAll('p, blockquote, pre, li, div'); - sections.slice(0, maxElements).forEach(section => { - if (this.isContentSection(section)) { - stats.sections.total++; - const sectionText = this.safeTextExtraction(section); - if (sectionText.length > 0) { - const lines = this.safeStatsOperation(() => sectionText.split('\n').length, 0, 'countLines'); - const words = this.safeStatsOperation(() => - sectionText.split(/\s+/).filter(w => w.length > 0).length, 0, 'countWords'); - const characters = Math.min(sectionText.length, 1000000); // Cap at 1MB - - stats.sections_detail.lines += lines; - stats.sections_detail.words += words; - stats.sections_detail.characters += characters; - - if (this.control.changeTracking.sections.has(sectionText)) { - stats.sections.changed++; - } - } - } - }); - - // Count tables as separate entities - const tables = this.control.safeQuerySelectorAll('table'); - tables.slice(0, maxElements).forEach(table => { - if (this.isContentTable(table)) { - stats.tables.total++; - const tableText = this.safeTextExtraction(table); - if (tableText.length > 0) { - const lines = this.safeStatsOperation(() => tableText.split('\n').length, 0, 'countTableLines'); - const words = this.safeStatsOperation(() => - tableText.split(/\s+/).filter(w => w.length > 0).length, 0, 'countTableWords'); - const characters = Math.min(tableText.length, 1000000); // Cap at 1MB - - stats.tables_detail.lines += lines; - stats.tables_detail.words += words; - stats.tables_detail.characters += characters; - - // Generate safer table identifier - const tableId = this.sanitizeText(table.id || - table.outerHTML.substring(0, 100).replace(/[<>"'&]/g, '')); - if (this.control.changeTracking.tables.has(tableId)) { - stats.tables.changed++; - } - } - } - }); - - // Count images with validation - const images = this.control.safeQuerySelectorAll('img'); - images.slice(0, maxElements).forEach(img => { - if (this.validateElement(img)) { - stats.images.total++; - // Safely extract and validate image source - const imgSrc = this.sanitizeText(img.src || img.getAttribute('src') || ''); - if (imgSrc && this.control.changeTracking.images.has(imgSrc)) { - stats.images.changed++; - } - } - }); - - // Calculate total document stats with protection - const bodyText = this.safeTextExtraction(document.body); - if (bodyText) { - const cleanText = bodyText.replace(/\s+/g, ' '); - stats.document.lines = this.safeStatsOperation(() => - bodyText.split('\n').length, 0, 'countDocLines'); - stats.document.words = this.safeStatsOperation(() => - cleanText.split(/\s+/).filter(w => w.length > 0).length, 0, 'countDocWords'); - stats.document.characters = Math.min(cleanText.length, 10000000); // Cap at 10MB - } - - return stats; - }, stats, 'calculateStats'); - } - - isContentSection(element) { - return this.safeStatsOperation(() => { - if (!this.validateElement(element)) { - return false; - } - - // Enhanced control detection with timeout protection - let current = element; - let depth = 0; - const maxDepth = 50; // Prevent infinite loops - - while (current && current !== document.body && depth < maxDepth) { - if (current.classList && ( - current.classList.contains('control-panel') || - current.classList.contains('control-content') || - current.classList.contains('control-header') || - current.className.includes('control') || - current.id?.includes('control') - )) { - return false; - } - current = current.parentElement; - depth++; - } - - // Skip if element is inside a table (tables are counted separately) - if (element.closest && element.closest('table')) { - return false; - } - - // Skip if element has no meaningful text content - const text = this.safeTextExtraction(element); - return text.length > 0 && text.length < 50000; // Reasonable size limit - }, false, 'isContentSection'); - } - - isContentTable(table) { - return this.safeStatsOperation(() => { - if (!this.validateElement(table) || table.tagName !== 'TABLE') { - return false; - } - - // Enhanced control detection with depth limiting - let current = table; - let depth = 0; - const maxDepth = 50; - - while (current && current !== document.body && depth < maxDepth) { - if (current.classList && ( - current.classList.contains('control-panel') || - current.classList.contains('control-content') || - current.classList.contains('control-header') || - current.className.includes('control') || - current.id?.includes('control') - )) { - return false; - } - current = current.parentElement; - depth++; - } - - // Check if table has meaningful content with limits - const text = this.safeTextExtraction(table); - return text.length > 0 && text.length < 100000; // Reasonable table size limit - }, false, 'isContentTable'); - } - - updateChangeTracking() { - const now = Date.now(); - - // Headings - const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6'); - headings.forEach(heading => { - const text = heading.textContent.trim(); - if (text && !text.toLowerCase().includes('control')) { - const changed = heading.dataset.lastModified && - (now - parseInt(heading.dataset.lastModified)) < 300000; // 5 minutes - if (changed) { - this.control.changeTracking.headings.add(text); - } - } - }); - - // Sections - const sections = document.querySelectorAll('p, blockquote, pre, li, div'); - sections.forEach(section => { - if (this.isContentSection(section)) { - const text = section.textContent.trim(); - if (text.length > 0) { - const changed = section.dataset.lastModified && - (now - parseInt(section.dataset.lastModified)) < 300000; // 5 minutes - if (changed) { - this.control.changeTracking.sections.add(text); - } - } - } - }); - - // Tables - const tables = document.querySelectorAll('table'); - tables.forEach(table => { - if (this.isContentTable(table)) { - const tableId = table.id || table.outerHTML.substring(0, 100); - const changed = table.dataset.lastModified && - (now - parseInt(table.dataset.lastModified)) < 300000; // 5 minutes - if (changed) { - this.control.changeTracking.tables.add(tableId); - } - } - }); - - // Images - const images = document.querySelectorAll('img'); - images.forEach(img => { - const src = img.src || img.getAttribute('src') || ''; - const changed = img.dataset.lastModified && - (now - parseInt(img.dataset.lastModified)) < 300000; // 5 minutes - if (changed && src) { - this.control.changeTracking.images.add(src); - } - }); - - this.control.changeTracking.lastScanTime = now; - } - - buildContent() { - this.control.safeOperation(() => { - console.log("πŸ“Š Building status control content..."); - - const content = this.control.safeQuerySelector('.control-content', this.control.element); - if (!content) { - console.error("πŸ“Š Status control content element not found"); - return; - } - - // Update tracking and calculate stats with timeout protection - const timeout = setTimeout(() => { - console.warn('Status content build operation timed out'); - }, 10000); // 10 second timeout - - this.updateChangeTracking(); - const stats = this.calculateStats(); - - clearTimeout(timeout); - - // Sanitize numeric values to prevent injection - const safeStats = { - document: { - lines: Math.max(0, Math.floor(stats.document.lines || 0)), - words: Math.max(0, Math.floor(stats.document.words || 0)), - characters: Math.max(0, Math.floor(stats.document.characters || 0)) - }, - headings: { - total: Math.max(0, Math.floor(stats.headings.total || 0)), - changed: Math.max(0, Math.floor(stats.headings.changed || 0)) - }, - sections: { - total: Math.max(0, Math.floor(stats.sections.total || 0)), - changed: Math.max(0, Math.floor(stats.sections.changed || 0)) - }, - sections_detail: { - lines: Math.max(0, Math.floor(stats.sections_detail.lines || 0)), - words: Math.max(0, Math.floor(stats.sections_detail.words || 0)), - characters: Math.max(0, Math.floor(stats.sections_detail.characters || 0)) - }, - tables: { - total: Math.max(0, Math.floor(stats.tables.total || 0)), - changed: Math.max(0, Math.floor(stats.tables.changed || 0)) - }, - tables_detail: { - lines: Math.max(0, Math.floor(stats.tables_detail.lines || 0)), - words: Math.max(0, Math.floor(stats.tables_detail.words || 0)), - characters: Math.max(0, Math.floor(stats.tables_detail.characters || 0)) - }, - images: { - total: Math.max(0, Math.floor(stats.images.total || 0)), - changed: Math.max(0, Math.floor(stats.images.changed || 0)) - } + const formatChange = (changeData) => { + if (!changeData.hasChanged) return ''; + const sign = changeData.change > 0 ? '+' : ''; + const color = changeData.change > 0 ? '#28a745' : '#dc3545'; + return ` (${sign}${changeData.change})`; }; - // Use safe stats for display with proper escaping - content.innerHTML = ` -
- -
-
πŸ“„ Document
-
- Lines: ${safeStats.document.lines.toLocaleString()} | Words: ${safeStats.document.words.toLocaleString()} | Chars: ${safeStats.document.characters.toLocaleString()} + const formatNumber = (num) => num.toLocaleString(); + + return ` +
+

Document Statistics

+ +
+
+ Words:
+ ${formatNumber(this.stats.words)} + ${formatChange(changes.words)} +
+ +
+ Characters:
+ ${formatNumber(this.stats.characters)} + ${formatChange(changes.characters)} +
+ +
+ Reading Time:
+ ${this.stats.readingTimeMinutes} min + ${formatChange(changes.readingTimeMinutes)} +
+ +
+ Sentences:
+ ${formatNumber(this.stats.sentences)} + ${formatChange(changes.sentences)}
- -
-
- πŸ“‹ Headings: ${safeStats.headings.total} - ${safeStats.headings.changed > 0 ? ` (+${safeStats.headings.changed})` : ''} +
+
Document Structure
+ +
+ Paragraphs: + ${this.stats.paragraphs}${formatChange(changes.paragraphs)} +
+ +
+ Headings: + ${this.stats.headings}${formatChange(changes.headings)} +
+ +
+ Lists: + ${this.stats.lists}${formatChange(changes.lists)} +
+ +
+ Images: + ${this.stats.images}${formatChange(changes.images)} +
+ +
+ Links: + ${this.stats.links}${formatChange(changes.links)}
- -
-
- πŸ“„ Sections: ${safeStats.sections.total} - ${safeStats.sections.changed > 0 ? ` (+${safeStats.sections.changed})` : ''} -
-
- Lines: ${safeStats.sections_detail.lines.toLocaleString()} | Words: ${safeStats.sections_detail.words.toLocaleString()} | Chars: ${safeStats.sections_detail.characters.toLocaleString()} -
-
- - -
-
- πŸ—‚οΈ Tables: ${safeStats.tables.total} - ${safeStats.tables.changed > 0 ? ` (+${safeStats.tables.changed})` : ''} -
-
- Lines: ${safeStats.tables_detail.lines.toLocaleString()} | Words: ${safeStats.tables_detail.words.toLocaleString()} | Chars: ${safeStats.tables_detail.characters.toLocaleString()} -
-
- - -
-
- πŸ–ΌοΈ Images: ${safeStats.images.total} - ${safeStats.images.changed > 0 ? ` (+${safeStats.images.changed})` : ''} -
-
- - -
- -
+ ${this.lastUpdateTime ? ` +
+ Updated: ${new Date(this.lastUpdateTime).toLocaleTimeString()} +
+ ` : ''}
`; - // Add safer event listeners instead of inline onclick - const refreshBtn = content.querySelector('#status-refresh-btn'); - const resetBtn = content.querySelector('#status-reset-btn'); - - if (refreshBtn) { - refreshBtn.addEventListener('click', () => { - this.control.safeOperation(() => { - if (window.statusControl && window.statusControl.refreshStats) { - window.statusControl.refreshStats(); - } - }, null, 'refreshButton'); - }); - } - - if (resetBtn) { - resetBtn.addEventListener('click', () => { - this.control.safeOperation(() => { - if (window.statusControl && window.statusControl.resetChangeTracking) { - window.statusControl.resetChangeTracking(); - } - }, null, 'resetButton'); - }); - } - - console.log("πŸ“Š Status control content built successfully"); - - // Set up auto-refresh - this.setupAutoRefresh(); - - // Show panel and expand - this.control.expand(); - - }, () => { - console.error("πŸ“Š Error in buildContent: Failed to build status control content"); - const content = this.control.safeQuerySelector('.control-content', this.control.element); - if (content) { - content.innerHTML = '
Status loading failed
'; - } - }, 'buildContent'); + }, '

Error displaying statistics

', 'formatStatistics'); } + /** + * Refresh statistics and update display + */ refreshStats() { - if (this.control.isExpanded) { - this.updateChangeTracking(); - // Update footer timestamp - this.control.config.footer = `Updated ${new Date().toLocaleTimeString()}`; - this.control.styleFooter(); + return this.safeOperation(() => { + // Save current stats as previous + this.previousStats = { ...this.stats }; - const content = this.control.element.querySelector('.control-content'); - if (content) { - const stats = this.calculateStats(); - // Update the display without rebuilding entire content - this.buildContent(); + // Analyze document + this.analyzeDocument(); + + // Update display + this.buildContent(); + + // Show success feedback + const refreshBtn = this.element?.querySelector('button'); + if (refreshBtn) { + const originalText = refreshBtn.innerHTML; + refreshBtn.innerHTML = 'βœ… Updated'; + + setTimeout(() => { + refreshBtn.innerHTML = originalText; + }, 1000); } - } + + }, null, 'refreshStats'); } - resetChangeTracking() { - if (confirm('Reset all document changes? This will revert all sections to their original state.')) { - console.log('πŸ“Š Resetting document changes...'); - - // Reset using available infrastructure - if (window.sectionManager && window.domRenderer) { - // Use the proper document management infrastructure - try { - // Hide any open editors - window.domRenderer.hideCurrentEditor(); - - // Reset all sections to original state - const allSections = Array.from(window.sectionManager.sections.values()); - allSections.forEach(section => { - section.resetToOriginal(); - }); - - // Re-render all sections - window.domRenderer.renderAllSections(allSections); - - console.log('πŸ“Š Document reset successful'); - - // Add to debug system - if (window.MarkitectDebugSystem) { - window.MarkitectDebugSystem.addMessage( - `Document reset completed - ${allSections.length} sections restored`, - 'SUCCESS', - 'StatusControl', - { eventType: 'SYSTEM' } - ); - } - - } catch (error) { - console.error('πŸ“Š Document reset failed:', error); - - if (window.MarkitectDebugSystem) { - window.MarkitectDebugSystem.addMessage( - `Document reset failed: ${error.message}`, - 'ERROR', - 'StatusControl', - { eventType: 'SYSTEM' } - ); - } + /** + * Export statistics to various formats + */ + exportStats() { + return this.safeOperation(() => { + const exportData = { + timestamp: new Date().toISOString(), + document: { + title: document.title || 'Untitled Document', + url: window.location.href + }, + statistics: this.stats, + metadata: { + wordsPerMinute: this.wordsPerMinute, + analysisDate: new Date(this.lastUpdateTime).toISOString() } - } else { - // Fallback to page reload if infrastructure not available - console.log('πŸ“Š Document management infrastructure not available, using page reload'); - window.location.reload(); + }; + + // Create downloadable JSON + const dataStr = JSON.stringify(exportData, null, 2); + const dataBlob = new Blob([dataStr], { type: 'application/json' }); + const url = URL.createObjectURL(dataBlob); + + // Create temporary download link + const link = document.createElement('a'); + link.href = url; + link.download = `document-stats-${new Date().toISOString().split('T')[0]}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + + // Clean up + URL.revokeObjectURL(url); + + // Show feedback + const exportBtn = this.element?.querySelector('button:last-child'); + if (exportBtn) { + const originalText = exportBtn.innerHTML; + exportBtn.innerHTML = 'βœ… Exported'; + exportBtn.style.background = '#28a745'; + + setTimeout(() => { + exportBtn.innerHTML = originalText; + exportBtn.style.background = '#28a745'; + }, 2000); } - // Clear our own change tracking - this.control.changeTracking.headings.clear(); - this.control.changeTracking.sections.clear(); - this.control.changeTracking.images.clear(); - this.control.changeTracking.tables.clear(); - this.control.changeTracking.lastScanTime = Date.now(); - - // Refresh our display - this.refreshStats(); - } + }, null, 'exportStats'); } - setupAutoRefresh() { - if (this.control.autoRefreshInterval) { - clearInterval(this.control.autoRefreshInterval); - } + /** + * Get reading difficulty score (Flesch Reading Ease approximation) + */ + calculateReadabilityScore() { + return this.safeOperation(() => { + if (this.stats.sentences === 0 || this.stats.words === 0) { + return { score: 0, level: 'Unknown' }; + } - this.control.autoRefreshInterval = setInterval(() => { - if (this.control.isExpanded) { + const avgWordsPerSentence = this.stats.words / this.stats.sentences; + const avgSyllablesPerWord = 1.5; // Simplified approximation + + // Flesch Reading Ease formula (simplified) + const score = 206.835 - (1.015 * avgWordsPerSentence) - (84.6 * avgSyllablesPerWord); + + let level; + if (score >= 90) level = 'Very Easy'; + else if (score >= 80) level = 'Easy'; + else if (score >= 70) level = 'Fairly Easy'; + else if (score >= 60) level = 'Standard'; + else if (score >= 50) level = 'Fairly Difficult'; + else if (score >= 30) level = 'Difficult'; + else level = 'Very Difficult'; + + return { score: Math.round(score), level }; + }, { score: 0, level: 'Unknown' }, 'calculateReadabilityScore'); + } + + /** + * Build the control content + * Override of base class method to provide status-specific functionality + */ + buildContent() { + return this.safeOperation(() => { + // Analyze document first + this.analyzeDocument(); + + // Generate and set content + const content = this.element?.querySelector('.control-content'); + if (content) { + content.innerHTML = this.formatStatistics(); + + // Store reference to this control for onclick handlers + this.element.statusControl = this; + } + + // Set up auto-refresh for dynamic content + if (this.updateInterval) { + clearInterval(this.updateInterval); + } + + this.updateInterval = setInterval(() => { this.refreshStats(); - } - }, 30000); // 30 seconds + }, 10000); // Update every 10 seconds + + }, null, 'buildContent'); } - createControl() { - return this.control.createControl(); + /** + * Clean up resources when control is destroyed + */ + destroy() { + if (this.updateInterval) { + clearInterval(this.updateInterval); + this.updateInterval = null; + } + super.destroy(); } } -// Export for global access -window.StatusControl = StatusControl; \ 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 = StatusControl; +} else { + window.StatusControl = StatusControl; +} \ No newline at end of file