Save current state before major JavaScript architecture refactoring. Current state: - Monolithic 5,188-line editor.js with all functionality - Working floating menu system and section editing - Debug panel implementation (with server-side generation issues) - All TDD-recovered features integrated Issues to address in refactoring: - Debug messages generated during HTML rendering instead of client-side - Monolithic architecture violates separation of concerns - Tight coupling prevents independent component testing - JavaScript changes affecting Python md-render code Next: Implement modular JavaScript architecture with: - Component separation (core/, components/, utils/, tests/) - Pure client-side debug system - Independent testing capability - Proper architectural boundaries 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
5189 lines
182 KiB
JavaScript
5189 lines
182 KiB
JavaScript
// Clean Editor Architecture
|
||
/**
|
||
* Test-Driven Section Editor Implementation
|
||
*
|
||
* A clean, object-oriented approach to handling section editing
|
||
* that can be tested independently of the DOM.
|
||
*/
|
||
|
||
// Debug system - choose one of: 'off', 'console', 'alerts'
|
||
const DEBUG_MODE = 'console';
|
||
|
||
// Advanced State Management
|
||
const EditState = Object.freeze({
|
||
ORIGINAL: 'original',
|
||
EDITING: 'editing',
|
||
MODIFIED: 'modified',
|
||
SAVED: 'saved'
|
||
});
|
||
|
||
/**
|
||
* Reusable FloatingMenu Component
|
||
* Provides a consistent floating menu system for both text and image editing
|
||
*/
|
||
class FloatingMenu {
|
||
constructor(sectionId, type, renderer) {
|
||
this.sectionId = sectionId;
|
||
this.type = type; // 'text' or 'image'
|
||
this.renderer = renderer;
|
||
this.element = null;
|
||
this.dragHandle = null;
|
||
this.contentArea = null;
|
||
this.controlsArea = null;
|
||
this.isDragging = false;
|
||
this.isVisible = false;
|
||
}
|
||
|
||
/**
|
||
* Create and show the floating menu
|
||
* @param {HTMLElement} contentElement - Content to display in the menu
|
||
* @param {HTMLElement} controlsElement - Controls to display in the menu
|
||
* @returns {HTMLElement} The floating menu element
|
||
*/
|
||
show(contentElement, controlsElement) {
|
||
if (this.isVisible) this.hide();
|
||
|
||
const targetElement = this.renderer.findSectionElement(this.sectionId);
|
||
if (!targetElement) return null;
|
||
|
||
// Calculate positioning
|
||
const positioning = this._calculatePositioning(targetElement);
|
||
|
||
// Create menu container
|
||
this.element = this._createMenuContainer(positioning);
|
||
|
||
// Create drag handle
|
||
this.dragHandle = this._createDragHandle();
|
||
|
||
// Create content and controls areas
|
||
this.contentArea = this._createContentArea();
|
||
this.controlsArea = this._createControlsArea();
|
||
|
||
// Create wrapper for content that will take most of the space
|
||
const contentWrapper = document.createElement('div');
|
||
contentWrapper.style.cssText = `
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
`;
|
||
contentWrapper.appendChild(contentElement);
|
||
|
||
// Add content and controls to content area (side by side)
|
||
this.contentArea.appendChild(contentWrapper);
|
||
this.controlsArea.appendChild(controlsElement);
|
||
this.contentArea.appendChild(this.controlsArea);
|
||
|
||
// Assemble menu
|
||
this.element.appendChild(this.dragHandle);
|
||
this.element.appendChild(this.contentArea);
|
||
|
||
// Apply highlighting to target element
|
||
this._highlightTargetElement(targetElement);
|
||
|
||
// Make draggable
|
||
this._makeDraggable();
|
||
|
||
// Add to DOM
|
||
document.body.appendChild(this.element);
|
||
this.isVisible = true;
|
||
|
||
return this.element;
|
||
}
|
||
|
||
/**
|
||
* Hide and remove the floating menu
|
||
*/
|
||
hide() {
|
||
if (!this.isVisible) return;
|
||
|
||
// Stop editing state in the section manager
|
||
const section = this.renderer.sectionManager.sections.get(this.sectionId);
|
||
if (section && section.isEditing()) {
|
||
section.stopEditing();
|
||
}
|
||
|
||
// Remove highlighting from target element
|
||
const targetElement = this.renderer.findSectionElement(this.sectionId);
|
||
if (targetElement) {
|
||
this._removeHighlighting(targetElement);
|
||
}
|
||
|
||
// Remove section from editing set
|
||
this.renderer.editingSections.delete(this.sectionId);
|
||
|
||
// Remove from DOM
|
||
if (this.element && this.element.parentElement) {
|
||
this.element.remove();
|
||
}
|
||
|
||
this.element = null;
|
||
this.dragHandle = null;
|
||
this.contentArea = null;
|
||
this.controlsArea = null;
|
||
this.isVisible = false;
|
||
}
|
||
|
||
/**
|
||
* Calculate optimal positioning for the floating menu
|
||
* @param {HTMLElement} targetElement - Element being edited
|
||
* @returns {Object} Positioning information
|
||
*/
|
||
_calculatePositioning(targetElement) {
|
||
const elementRect = targetElement.getBoundingClientRect();
|
||
const scrollX = window.pageXOffset || document.documentElement.scrollLeft;
|
||
const scrollY = window.pageYOffset || document.documentElement.scrollTop;
|
||
const viewportWidth = window.innerWidth;
|
||
|
||
// Calculate content width and positioning
|
||
const elementWidth = elementRect.width;
|
||
const controlsWidth = 180; // Width needed for controls on the right
|
||
const minMenuWidth = 400; // Increased minimum to accommodate side-by-side layout
|
||
const maxMenuWidth = Math.min(800, viewportWidth - 40);
|
||
|
||
// Always use integrated layout with controls on the right side of content
|
||
let menuWidth, menuLeft;
|
||
|
||
if (elementWidth + controlsWidth >= minMenuWidth && elementWidth + controlsWidth <= maxMenuWidth) {
|
||
// Use element width + controls width
|
||
menuWidth = elementWidth + controlsWidth;
|
||
} else if (elementWidth + controlsWidth > maxMenuWidth) {
|
||
// Use maximum width
|
||
menuWidth = maxMenuWidth;
|
||
} else {
|
||
// Use minimum width
|
||
menuWidth = minMenuWidth;
|
||
}
|
||
|
||
menuLeft = elementRect.left + scrollX;
|
||
|
||
return {
|
||
width: menuWidth,
|
||
left: menuLeft,
|
||
top: elementRect.top + scrollY,
|
||
height: Math.max(200, elementRect.height),
|
||
controlsWidth: controlsWidth
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Create the main menu container
|
||
* @param {Object} positioning - Positioning information
|
||
* @returns {HTMLElement} Menu container element
|
||
*/
|
||
_createMenuContainer(positioning) {
|
||
const container = document.createElement('div');
|
||
container.className = 'ui-edit-floating-menu';
|
||
container.dataset.sectionId = this.sectionId;
|
||
container.dataset.editType = this.type;
|
||
container.style.cssText = `
|
||
position: fixed;
|
||
top: ${positioning.top}px;
|
||
left: ${positioning.left}px;
|
||
width: ${positioning.width}px;
|
||
min-height: ${positioning.height}px;
|
||
max-height: 600px;
|
||
z-index: 10000;
|
||
background: rgba(255, 255, 255, 0.98);
|
||
border: 2px solid #007bff;
|
||
border-radius: 12px;
|
||
box-shadow: 0 8px 32px rgba(0, 123, 255, 0.25);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
backdrop-filter: blur(10px);
|
||
`;
|
||
|
||
// Store positioning info for controls layout
|
||
container._positioning = positioning;
|
||
|
||
return container;
|
||
}
|
||
|
||
/**
|
||
* Create the drag handle with X button
|
||
* @returns {HTMLElement} Drag handle element
|
||
*/
|
||
_createDragHandle() {
|
||
const handle = document.createElement('div');
|
||
handle.className = 'ui-edit-drag-handle';
|
||
handle.style.cssText = `
|
||
padding: 10px 15px;
|
||
background: #007bff;
|
||
color: white;
|
||
font-size: 12px;
|
||
font-weight: bold;
|
||
cursor: move;
|
||
user-select: none;
|
||
border-radius: 10px 10px 0 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
position: relative;
|
||
`;
|
||
|
||
// Left side: icon and title
|
||
const leftContent = document.createElement('div');
|
||
leftContent.style.cssText = `
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
`;
|
||
|
||
const icon = this.type === 'image' ? '🖼️' : '📝';
|
||
leftContent.innerHTML = `${icon} <span>Drag to Move • Editing ${this.type === 'image' ? 'Image' : 'Text'}</span>`;
|
||
|
||
// Right side: X button
|
||
const closeButton = document.createElement('button');
|
||
closeButton.className = 'ui-edit-close-button';
|
||
closeButton.innerHTML = '✕';
|
||
closeButton.style.cssText = `
|
||
background: none;
|
||
border: none;
|
||
color: white;
|
||
font-size: 16px;
|
||
font-weight: bold;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
width: 20px;
|
||
height: 20px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 3px;
|
||
transition: background-color 0.2s ease;
|
||
`;
|
||
|
||
// Hover effect for close button
|
||
closeButton.addEventListener('mouseenter', () => {
|
||
closeButton.style.backgroundColor = 'rgba(255, 255, 255, 0.2)';
|
||
});
|
||
closeButton.addEventListener('mouseleave', () => {
|
||
closeButton.style.backgroundColor = 'transparent';
|
||
});
|
||
|
||
// Close functionality
|
||
closeButton.addEventListener('click', (e) => {
|
||
e.stopPropagation(); // Prevent dragging when clicking close
|
||
this.hide();
|
||
});
|
||
|
||
handle.appendChild(leftContent);
|
||
handle.appendChild(closeButton);
|
||
|
||
return handle;
|
||
}
|
||
|
||
/**
|
||
* Create the content area with horizontal layout
|
||
* @returns {HTMLElement} Content area element
|
||
*/
|
||
_createContentArea() {
|
||
const area = document.createElement('div');
|
||
area.className = 'ui-edit-content-area';
|
||
area.style.cssText = `
|
||
padding: 15px;
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: row;
|
||
gap: 15px;
|
||
overflow: hidden;
|
||
`;
|
||
return area;
|
||
}
|
||
|
||
/**
|
||
* Create the controls area for side-by-side layout
|
||
* @returns {HTMLElement} Controls area element
|
||
*/
|
||
_createControlsArea() {
|
||
const area = document.createElement('div');
|
||
area.className = 'ui-edit-controls-area';
|
||
area.style.cssText = `
|
||
width: 160px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
justify-content: flex-start;
|
||
align-items: stretch;
|
||
flex-shrink: 0;
|
||
`;
|
||
return area;
|
||
}
|
||
|
||
/**
|
||
* Apply highlighting to the target element
|
||
* @param {HTMLElement} targetElement - Element to highlight
|
||
*/
|
||
_highlightTargetElement(targetElement) {
|
||
targetElement.style.outline = '3px solid #007bff';
|
||
targetElement.style.outlineOffset = '2px';
|
||
targetElement.style.backgroundColor = 'rgba(0, 123, 255, 0.08)';
|
||
targetElement.style.transition = 'all 0.2s ease';
|
||
}
|
||
|
||
/**
|
||
* Remove highlighting from the target element
|
||
* @param {HTMLElement} targetElement - Element to remove highlighting from
|
||
*/
|
||
_removeHighlighting(targetElement) {
|
||
targetElement.style.outline = '';
|
||
targetElement.style.outlineOffset = '';
|
||
targetElement.style.backgroundColor = '';
|
||
targetElement.style.transition = '';
|
||
}
|
||
|
||
/**
|
||
* Make the floating menu draggable
|
||
*/
|
||
_makeDraggable() {
|
||
let startX, startY, initialX, initialY;
|
||
|
||
this.dragHandle.addEventListener('mousedown', (e) => {
|
||
// Don't start dragging if clicking on the close button
|
||
if (e.target.closest('.ui-edit-close-button')) {
|
||
return;
|
||
}
|
||
|
||
this.isDragging = true;
|
||
startX = e.clientX;
|
||
startY = e.clientY;
|
||
|
||
const rect = this.element.getBoundingClientRect();
|
||
initialX = rect.left;
|
||
initialY = rect.top;
|
||
|
||
this.element.style.cursor = 'grabbing';
|
||
this.dragHandle.style.cursor = 'grabbing';
|
||
|
||
e.preventDefault();
|
||
});
|
||
|
||
document.addEventListener('mousemove', (e) => {
|
||
if (!this.isDragging) return;
|
||
|
||
const deltaX = e.clientX - startX;
|
||
const deltaY = e.clientY - startY;
|
||
|
||
const newX = initialX + deltaX;
|
||
const newY = initialY + deltaY;
|
||
|
||
// Keep within viewport bounds
|
||
const maxX = window.innerWidth - this.element.offsetWidth;
|
||
const maxY = window.innerHeight - this.element.offsetHeight;
|
||
|
||
this.element.style.left = Math.max(0, Math.min(newX, maxX)) + 'px';
|
||
this.element.style.top = Math.max(0, Math.min(newY, maxY)) + 'px';
|
||
});
|
||
|
||
document.addEventListener('mouseup', () => {
|
||
if (this.isDragging) {
|
||
this.isDragging = false;
|
||
this.element.style.cursor = 'move';
|
||
this.dragHandle.style.cursor = 'move';
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
// Global debug message storage - reset on page load
|
||
let debugMessages = [];
|
||
let debugPanelActive = false;
|
||
|
||
// Clear any messages that might have been captured during rendering
|
||
if (typeof window !== 'undefined') {
|
||
debugMessages = [];
|
||
}
|
||
|
||
function debug(message, category = 'INFO') {
|
||
const timestamp = new Date().toLocaleTimeString();
|
||
const prefix = `DEBUG ${category}:`;
|
||
const fullMessage = `[${timestamp}] ${prefix} ${message}`;
|
||
|
||
// Always store messages for the debug panel
|
||
debugMessages.push({
|
||
timestamp,
|
||
category,
|
||
message,
|
||
fullMessage
|
||
});
|
||
|
||
// Keep only last 100 messages to prevent memory issues
|
||
if (debugMessages.length > 100) {
|
||
debugMessages = debugMessages.slice(-100);
|
||
}
|
||
|
||
// Update debug panel if active
|
||
if (debugPanelActive) {
|
||
console.log('Debug panel is active, updating...'); // Debug log
|
||
if (window.updateDebugPanel) {
|
||
console.log('Calling window.updateDebugPanel'); // Debug log
|
||
window.updateDebugPanel();
|
||
} else {
|
||
console.log('window.updateDebugPanel not available'); // Debug log
|
||
}
|
||
} else {
|
||
console.log('Debug panel not active, debugPanelActive =', debugPanelActive); // Debug log
|
||
}
|
||
|
||
switch (DEBUG_MODE) {
|
||
case 'off':
|
||
// No debugging output to console
|
||
break;
|
||
case 'console':
|
||
console.log(prefix, message);
|
||
break;
|
||
case 'alerts':
|
||
alert(`${prefix} ${message}`);
|
||
console.log(prefix, message); // Also log to console for reference
|
||
break;
|
||
default:
|
||
console.warn('Invalid DEBUG_MODE. Use: off, console, or alerts');
|
||
}
|
||
}
|
||
|
||
// Global functions for debug panel
|
||
window.updateDebugPanel = function() {
|
||
// Find the DOMRenderer instance and call its updateDebugPanel method
|
||
if (window.currentDOMRenderer && typeof window.currentDOMRenderer.updateDebugPanel === 'function') {
|
||
window.currentDOMRenderer.updateDebugPanel();
|
||
}
|
||
};
|
||
|
||
window.clearDebugMessages = function() {
|
||
console.log('clearDebugMessages called, current messages:', debugMessages.length);
|
||
debugMessages = [];
|
||
console.log('Messages cleared, new length:', debugMessages.length);
|
||
if (debugPanelActive && window.currentDOMRenderer && typeof window.currentDOMRenderer.updateDebugPanel === 'function') {
|
||
console.log('Updating debug panel after clear');
|
||
window.currentDOMRenderer.updateDebugPanel();
|
||
} else {
|
||
console.log('Cannot update debug panel after clear:', {
|
||
debugPanelActive,
|
||
hasRenderer: !!window.currentDOMRenderer,
|
||
hasUpdateMethod: !!(window.currentDOMRenderer && window.currentDOMRenderer.updateDebugPanel)
|
||
});
|
||
}
|
||
};
|
||
|
||
// Enums for clear state management (already defined above)
|
||
|
||
const SectionType = Object.freeze({
|
||
HEADING: 'heading',
|
||
PARAGRAPH: 'paragraph',
|
||
LIST: 'list',
|
||
CODE: 'code',
|
||
QUOTE: 'quote',
|
||
IMAGE: 'image',
|
||
TABLE: 'table',
|
||
HR: 'hr',
|
||
OTHER: 'other'
|
||
});
|
||
|
||
/**
|
||
* Section class - Represents a single editable section
|
||
*/
|
||
class Section {
|
||
constructor(id, markdown, type) {
|
||
this.id = id;
|
||
this.originalMarkdown = markdown;
|
||
this.currentMarkdown = markdown;
|
||
this.editingMarkdown = markdown;
|
||
this.pendingMarkdown = null;
|
||
this.type = type;
|
||
this.state = EditState.ORIGINAL;
|
||
this.domElement = null;
|
||
this.lastSaved = null;
|
||
this.created = new Date();
|
||
}
|
||
|
||
/**
|
||
* Generate sophisticated section ID with hash-based algorithm
|
||
* @param {string} markdown - Section content
|
||
* @param {number} position - Position in document
|
||
* @param {string} strategy - ID generation strategy ('hash', 'timestamp', 'sequential', 'hierarchical')
|
||
* @param {string} parentId - Parent section ID for hierarchical strategy
|
||
* @returns {string} Generated section ID
|
||
*/
|
||
static generateId(markdown, position, strategy = 'hash', parentId = null) {
|
||
return this.generateIdWithStrategy(markdown, position, strategy, parentId);
|
||
}
|
||
|
||
/**
|
||
* Generate ID with specific strategy
|
||
* @param {string} markdown - Section content
|
||
* @param {number} position - Position in document
|
||
* @param {string} strategy - Generation strategy
|
||
* @param {string} parentId - Parent ID for hierarchical
|
||
* @returns {string} Generated ID
|
||
*/
|
||
static generateIdWithStrategy(markdown, position, strategy = 'hash', parentId = null) {
|
||
const sanitizedContent = this.sanitizeContentForId(markdown);
|
||
const normalizedContent = this.normalizeContentForHashing(sanitizedContent);
|
||
const sectionType = this.detectType(markdown);
|
||
|
||
switch (strategy) {
|
||
case 'timestamp':
|
||
return this.generateTimestampId(normalizedContent, position, sectionType);
|
||
case 'sequential':
|
||
return this.generateSequentialId(normalizedContent, position, sectionType);
|
||
case 'hierarchical':
|
||
return this.generateHierarchicalId(normalizedContent, position, parentId);
|
||
case 'hash':
|
||
default:
|
||
return this.generateAdvancedId(normalizedContent, position, sectionType);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate advanced hash-based ID with section type
|
||
* @param {string} content - Normalized content
|
||
* @param {number} position - Position
|
||
* @param {string} sectionType - Detected section type
|
||
* @returns {string} Advanced ID
|
||
*/
|
||
static generateAdvancedId(content, position, sectionType) {
|
||
const contentHash = this.generateCryptoHash(content);
|
||
const safeType = sectionType || 'paragraph';
|
||
const typePrefix = safeType.substring(0, 3); // First 3 chars of type
|
||
const positionHex = position.toString(16).padStart(2, '0');
|
||
|
||
return `section-${typePrefix}-${contentHash}-${positionHex}`;
|
||
}
|
||
|
||
/**
|
||
* Generate cryptographic hash for content fingerprinting
|
||
* @param {string} content - Content to hash
|
||
* @returns {string} Hex hash string
|
||
*/
|
||
static generateCryptoHash(content) {
|
||
// Simple but effective hash function for browser compatibility
|
||
let hash = 0;
|
||
if (content.length === 0) return '00000000';
|
||
|
||
for (let i = 0; i < content.length; i++) {
|
||
const char = content.charCodeAt(i);
|
||
hash = ((hash << 5) - hash) + char;
|
||
hash = hash & hash; // Convert to 32-bit integer
|
||
}
|
||
|
||
// Convert to positive hex string
|
||
const hexHash = Math.abs(hash).toString(16).padStart(8, '0');
|
||
return hexHash.substring(0, 8);
|
||
}
|
||
|
||
/**
|
||
* Normalize content for consistent hashing
|
||
* @param {string} content - Raw content
|
||
* @returns {string} Normalized content
|
||
*/
|
||
static normalizeContentForHashing(content) {
|
||
if (!content || typeof content !== 'string') {
|
||
return '';
|
||
}
|
||
|
||
return content
|
||
.trim() // Remove leading/trailing whitespace
|
||
.replace(/\s+/g, ' ') // Normalize whitespace
|
||
.replace(/\r\n/g, '\n') // Normalize line endings
|
||
.toLowerCase(); // Case insensitive
|
||
}
|
||
|
||
/**
|
||
* Sanitize content for safe ID generation
|
||
* @param {string} content - Raw content
|
||
* @returns {string} Sanitized content
|
||
*/
|
||
static sanitizeContentForId(content) {
|
||
if (!content || typeof content !== 'string') {
|
||
return '';
|
||
}
|
||
|
||
// Remove potentially dangerous characters and HTML
|
||
return content
|
||
.replace(/<[^>]*>/g, '') // Remove HTML tags
|
||
.replace(/javascript:/gi, '') // Remove javascript: protocol
|
||
.replace(/[^\w\s\-_.#]/g, '') // Keep only safe characters
|
||
.trim();
|
||
}
|
||
|
||
/**
|
||
* Generate timestamp-based ID for temporal uniqueness
|
||
* @param {string} content - Content
|
||
* @param {number} position - Position
|
||
* @param {string} sectionType - Section type
|
||
* @returns {string} Timestamp-based ID
|
||
*/
|
||
static generateTimestampId(content, position = 0, sectionType = 'paragraph') {
|
||
const timestamp = Date.now().toString(36); // Base-36 timestamp
|
||
const contentSnippet = this.generateCryptoHash(content || '').substring(0, 4);
|
||
const safeType = sectionType || 'paragraph';
|
||
const typePrefix = safeType.substring(0, 3);
|
||
|
||
return `section-${typePrefix}-${contentSnippet}-${timestamp}`;
|
||
}
|
||
|
||
/**
|
||
* Generate sequential ID
|
||
* @param {string} content - Content
|
||
* @param {number} position - Position
|
||
* @param {string} sectionType - Section type
|
||
* @returns {string} Sequential ID
|
||
*/
|
||
static generateSequentialId(content, position, sectionType = 'paragraph') {
|
||
const safeType = sectionType || 'paragraph';
|
||
const typePrefix = safeType.substring(0, 3);
|
||
const seqNumber = (position || 0).toString().padStart(3, '0');
|
||
const contentHash = this.generateCryptoHash(content || '').substring(0, 4);
|
||
|
||
return `section-${typePrefix}-seq${seqNumber}-${contentHash}`;
|
||
}
|
||
|
||
/**
|
||
* Generate hierarchical ID for nested sections
|
||
* @param {string} content - Content
|
||
* @param {number} position - Position
|
||
* @param {string} parentId - Parent section ID
|
||
* @returns {string} Hierarchical ID
|
||
*/
|
||
static generateHierarchicalId(content, position, parentId = null) {
|
||
const contentHash = this.generateCryptoHash(content || '').substring(0, 6);
|
||
|
||
if (parentId) {
|
||
const childIndex = (position || 0).toString().padStart(2, '0');
|
||
return `${parentId}-child-${childIndex}-${contentHash}`;
|
||
} else {
|
||
return `section-root-${position || 0}-${contentHash}`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Detect ID collision
|
||
* @param {string} id - Proposed ID
|
||
* @param {Set} existingIds - Set of existing IDs
|
||
* @returns {boolean} True if collision detected
|
||
*/
|
||
static detectIdCollision(id, existingIds) {
|
||
return existingIds && existingIds.has(id);
|
||
}
|
||
|
||
/**
|
||
* Resolve ID collision by generating alternative
|
||
* @param {string} id - Colliding ID
|
||
* @param {Set} existingIds - Set of existing IDs
|
||
* @returns {string} Resolved unique ID
|
||
*/
|
||
static resolveIdCollision(id, existingIds) {
|
||
let counter = 1;
|
||
let resolvedId = `${id}-${counter}`;
|
||
|
||
while (existingIds.has(resolvedId)) {
|
||
counter++;
|
||
resolvedId = `${id}-${counter}`;
|
||
}
|
||
|
||
return resolvedId;
|
||
}
|
||
|
||
/**
|
||
* Analyze section ID and extract metadata
|
||
* @param {string} id - Section ID to analyze
|
||
* @returns {Object} ID metadata
|
||
*/
|
||
static analyzeId(id) {
|
||
if (!id || !id.startsWith('section-')) {
|
||
return { id, valid: false };
|
||
}
|
||
|
||
const parts = id.split('-');
|
||
const analysis = {
|
||
id,
|
||
valid: true,
|
||
prefix: parts[0], // 'section'
|
||
format: 'unknown'
|
||
};
|
||
|
||
if (parts.length >= 4) {
|
||
// Advanced format: section-{type}-{hash}-{position}
|
||
analysis.format = 'advanced';
|
||
analysis.type = parts[1];
|
||
analysis.hash = parts[2];
|
||
analysis.position = parseInt(parts[3], 16) || 0;
|
||
} else if (parts.length === 3 && parts[1] === 'root') {
|
||
// Hierarchical root format
|
||
analysis.format = 'hierarchical-root';
|
||
analysis.level = 'root';
|
||
analysis.position = parseInt(parts[2]) || 0;
|
||
} else if (id.includes('child')) {
|
||
// Hierarchical child format
|
||
analysis.format = 'hierarchical-child';
|
||
analysis.level = 'child';
|
||
} else if (id.includes('seq')) {
|
||
// Sequential format
|
||
analysis.format = 'sequential';
|
||
} else {
|
||
// Basic or legacy format
|
||
analysis.format = 'basic';
|
||
analysis.hash = parts.slice(1).join('-');
|
||
}
|
||
|
||
return analysis;
|
||
}
|
||
|
||
static detectType(markdown) {
|
||
if (!markdown || typeof markdown !== 'string') {
|
||
return SectionType.PARAGRAPH;
|
||
}
|
||
|
||
// Don't trim the entire markdown - preserve leading indentation
|
||
const content = markdown.replace(/^\n+|\n+$/g, ''); // Only remove leading/trailing newlines
|
||
if (!content) {
|
||
return SectionType.PARAGRAPH;
|
||
}
|
||
|
||
const rawLines = content.split('\n');
|
||
const lines = rawLines.map(line => line.trim()).filter(line => line.length > 0);
|
||
if (lines.length === 0) {
|
||
return SectionType.PARAGRAPH;
|
||
}
|
||
|
||
// For other detection methods, use trimmed content
|
||
const trimmed = content.trim();
|
||
|
||
// Detection order matters - most specific first
|
||
|
||
// 1. Heading detection (must start with # and have space)
|
||
if (this.isHeading(trimmed)) {
|
||
return SectionType.HEADING;
|
||
}
|
||
|
||
// 2. Code block detection
|
||
if (this.isCodeBlock(trimmed, lines, rawLines)) {
|
||
return SectionType.CODE;
|
||
}
|
||
|
||
// 3. Table detection
|
||
if (this.isTable(trimmed, lines)) {
|
||
return SectionType.TABLE;
|
||
}
|
||
|
||
// 4. Horizontal rule detection
|
||
if (this.isHorizontalRule(trimmed, lines)) {
|
||
return SectionType.HR;
|
||
}
|
||
|
||
// 5. List detection
|
||
if (this.isList(trimmed, lines)) {
|
||
return SectionType.LIST;
|
||
}
|
||
|
||
// 6. Quote detection
|
||
if (this.isQuote(trimmed, lines)) {
|
||
return SectionType.QUOTE;
|
||
}
|
||
|
||
// 7. Image detection
|
||
if (this.isImage(trimmed)) {
|
||
return SectionType.IMAGE;
|
||
}
|
||
|
||
// 8. Default to paragraph
|
||
return SectionType.PARAGRAPH;
|
||
}
|
||
|
||
/**
|
||
* Advanced type detection with confidence scores
|
||
* @param {string} markdown - Markdown content
|
||
* @returns {Object} Detection result with type, confidence, and alternatives
|
||
*/
|
||
static detectTypeWithConfidence(markdown) {
|
||
const scores = {
|
||
[SectionType.HEADING]: 0,
|
||
[SectionType.CODE]: 0,
|
||
[SectionType.TABLE]: 0,
|
||
[SectionType.HR]: 0,
|
||
[SectionType.LIST]: 0,
|
||
[SectionType.QUOTE]: 0,
|
||
[SectionType.IMAGE]: 0,
|
||
[SectionType.PARAGRAPH]: 0
|
||
};
|
||
|
||
if (!markdown || typeof markdown !== 'string') {
|
||
scores[SectionType.PARAGRAPH] = 1.0;
|
||
return this.formatDetectionResult(scores);
|
||
}
|
||
|
||
const content = markdown.replace(/^\n+|\n+$/g, '');
|
||
const trimmed = content.trim();
|
||
const rawLines = content.split('\n');
|
||
const lines = rawLines.map(line => line.trim()).filter(line => line.length > 0);
|
||
|
||
// Calculate confidence scores for each type
|
||
scores[SectionType.HEADING] = this.calculateHeadingScore(trimmed);
|
||
scores[SectionType.CODE] = this.calculateCodeScore(trimmed, lines, rawLines);
|
||
scores[SectionType.TABLE] = this.calculateTableScore(trimmed, lines);
|
||
scores[SectionType.HR] = this.calculateHRScore(trimmed, lines);
|
||
scores[SectionType.LIST] = this.calculateListScore(trimmed, lines);
|
||
scores[SectionType.QUOTE] = this.calculateQuoteScore(trimmed, lines);
|
||
scores[SectionType.IMAGE] = this.calculateImageScore(trimmed);
|
||
|
||
// Base paragraph score
|
||
scores[SectionType.PARAGRAPH] = 0.1;
|
||
|
||
return this.formatDetectionResult(scores);
|
||
}
|
||
|
||
/**
|
||
* Format detection result with primary type and alternatives
|
||
* @param {Object} scores - Confidence scores for each type
|
||
* @returns {Object} Formatted result
|
||
*/
|
||
static formatDetectionResult(scores) {
|
||
const sortedTypes = Object.entries(scores)
|
||
.sort(([,a], [,b]) => b - a)
|
||
.map(([type, score]) => ({ type, confidence: score }));
|
||
|
||
const primaryType = sortedTypes[0];
|
||
const alternatives = sortedTypes.slice(1, 4).filter(alt => alt.confidence > 0.1);
|
||
|
||
return {
|
||
type: primaryType.type,
|
||
confidence: primaryType.confidence,
|
||
alternatives: alternatives
|
||
};
|
||
}
|
||
|
||
// Specific detection methods
|
||
|
||
static isHeading(trimmed) {
|
||
const headingPattern = /^#{1,6}\s+.+/;
|
||
return headingPattern.test(trimmed);
|
||
}
|
||
|
||
static calculateHeadingScore(trimmed) {
|
||
if (/^#{1,6}\s+.+/.test(trimmed)) {
|
||
const hashCount = (trimmed.match(/^#+/) || [''])[0].length;
|
||
if (hashCount <= 6) {
|
||
return 0.95 - (hashCount - 1) * 0.05; // Higher score for lower level headings
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
static isCodeBlock(trimmed, lines, rawLines) {
|
||
// Fenced code blocks - check if starts with fence OR contains fence blocks
|
||
if (trimmed.startsWith('```') || trimmed.startsWith('~~~')) {
|
||
return true;
|
||
}
|
||
|
||
// Check for code blocks anywhere in the content for mixed content
|
||
if (trimmed.includes('```') || trimmed.includes('~~~')) {
|
||
const codeBlockPattern = /```[\s\S]*?```|~~~[\s\S]*?~~~/;
|
||
if (codeBlockPattern.test(trimmed)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
// Indented code blocks (4+ spaces or 1+ tabs at start of every line)
|
||
if (rawLines && rawLines.length > 0) {
|
||
const nonEmptyRawLines = rawLines.filter(line => line.trim().length > 0);
|
||
const indentedLines = nonEmptyRawLines.filter(line =>
|
||
line.startsWith(' ') || line.startsWith('\t')
|
||
);
|
||
return indentedLines.length === nonEmptyRawLines.length && nonEmptyRawLines.length > 0;
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
static calculateCodeScore(trimmed, lines, rawLines) {
|
||
let score = 0;
|
||
|
||
// Fenced code blocks at start
|
||
if (trimmed.startsWith('```') || trimmed.startsWith('~~~')) {
|
||
score = 0.95;
|
||
}
|
||
// Code blocks anywhere in content (mixed content)
|
||
else if (trimmed.includes('```') || trimmed.includes('~~~')) {
|
||
const codeBlockPattern = /```[\s\S]*?```|~~~[\s\S]*?~~~/;
|
||
if (codeBlockPattern.test(trimmed)) {
|
||
// Lower score for mixed content, but still significant
|
||
score = 0.7;
|
||
}
|
||
}
|
||
// Indented code blocks
|
||
else if (rawLines && rawLines.length > 0) {
|
||
const nonEmptyRawLines = rawLines.filter(line => line.trim().length > 0);
|
||
const indentedLines = nonEmptyRawLines.filter(line =>
|
||
line.startsWith(' ') || line.startsWith('\t')
|
||
);
|
||
if (indentedLines.length === nonEmptyRawLines.length && nonEmptyRawLines.length > 0) {
|
||
score = 0.8;
|
||
} else if (indentedLines.length > 0) {
|
||
score = 0.3;
|
||
}
|
||
}
|
||
|
||
// Boost score for code-like content
|
||
if (/[{}();]/.test(trimmed)) {
|
||
score += 0.1;
|
||
}
|
||
|
||
return Math.min(score, 1.0);
|
||
}
|
||
|
||
static isTable(trimmed, lines) {
|
||
if (lines.length < 2) return false;
|
||
|
||
// Look for table structure with pipes
|
||
const hasTableSeparator = lines.some(line =>
|
||
/^\s*\|?.*?\|.*?\|?\s*$/.test(line) && line.includes('|')
|
||
);
|
||
|
||
const hasSeparatorRow = lines.some(line =>
|
||
/^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/.test(line)
|
||
);
|
||
|
||
return hasTableSeparator && (hasSeparatorRow || this.looksLikeSimpleTable(lines));
|
||
}
|
||
|
||
static looksLikeSimpleTable(lines) {
|
||
if (lines.length < 2) return false;
|
||
|
||
const pipeLines = lines.filter(line => line.includes('|'));
|
||
if (pipeLines.length < 2) return false;
|
||
|
||
// Check if lines have similar number of pipes
|
||
const pipeCounts = pipeLines.map(line => (line.match(/\|/g) || []).length);
|
||
const avgPipes = pipeCounts.reduce((a, b) => a + b, 0) / pipeCounts.length;
|
||
|
||
return pipeCounts.every(count => Math.abs(count - avgPipes) <= 1);
|
||
}
|
||
|
||
static calculateTableScore(trimmed, lines) {
|
||
if (lines.length < 2) return 0;
|
||
|
||
let score = 0;
|
||
const pipeLines = lines.filter(line => line.includes('|'));
|
||
|
||
if (pipeLines.length >= 2) {
|
||
score = 0.3 + (pipeLines.length / lines.length) * 0.4;
|
||
|
||
// Boost for separator row
|
||
if (lines.some(line => /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/.test(line))) {
|
||
score += 0.3;
|
||
}
|
||
}
|
||
|
||
return Math.min(score, 1.0);
|
||
}
|
||
|
||
static isHorizontalRule(trimmed, lines) {
|
||
if (lines.length !== 1) return false;
|
||
|
||
const line = lines[0];
|
||
|
||
// Three or more hyphens, asterisks, or underscores
|
||
const hrPatterns = [
|
||
/^-{3,}$/,
|
||
/^\*{3,}$/,
|
||
/^_{3,}$/,
|
||
/^- -( -)*$/,
|
||
/^\* \*( \*)*$/,
|
||
/^_ _( _)*$/
|
||
];
|
||
|
||
return hrPatterns.some(pattern => pattern.test(line));
|
||
}
|
||
|
||
static calculateHRScore(trimmed, lines) {
|
||
if (lines.length === 1) {
|
||
const line = lines[0];
|
||
const hrPatterns = [
|
||
/^-{3,}$/, /^\*{3,}$/, /^_{3,}$/,
|
||
/^- -( -)*$/, /^\* \*( \*)*$/, /^_ _( _)*$/
|
||
];
|
||
|
||
if (hrPatterns.some(pattern => pattern.test(line))) {
|
||
return 0.9;
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
static isList(trimmed, lines) {
|
||
if (lines.length === 0) return false;
|
||
|
||
const listItemPatterns = [
|
||
/^[-*+]\s+/, // Bullet lists
|
||
/^\d+[\.)]\s+/, // Numbered lists
|
||
/^[-*+]\s*\[([ x])\]\s+/ // Task lists
|
||
];
|
||
|
||
const listLines = lines.filter(line =>
|
||
listItemPatterns.some(pattern => pattern.test(line))
|
||
);
|
||
|
||
return listLines.length > 0 && listLines.length >= lines.length * 0.5;
|
||
}
|
||
|
||
static calculateListScore(trimmed, lines) {
|
||
if (lines.length === 0) return 0;
|
||
|
||
const listItemPatterns = [
|
||
/^[-*+]\s+/,
|
||
/^\d+[\.)]\s+/,
|
||
/^[-*+]\s*\[([ x])\]\s+/
|
||
];
|
||
|
||
const listLines = lines.filter(line =>
|
||
listItemPatterns.some(pattern => pattern.test(line))
|
||
);
|
||
|
||
if (listLines.length === 0) return 0;
|
||
|
||
const ratio = listLines.length / lines.length;
|
||
return ratio * 0.9;
|
||
}
|
||
|
||
static isQuote(trimmed, lines) {
|
||
if (lines.length === 0) return false;
|
||
|
||
const quoteLines = lines.filter(line => line.startsWith('>'));
|
||
return quoteLines.length > 0 && quoteLines.length >= lines.length * 0.5;
|
||
}
|
||
|
||
static calculateQuoteScore(trimmed, lines) {
|
||
if (lines.length === 0) return 0;
|
||
|
||
const quoteLines = lines.filter(line => line.startsWith('>'));
|
||
if (quoteLines.length === 0) return 0;
|
||
|
||
const ratio = quoteLines.length / lines.length;
|
||
return ratio * 0.85;
|
||
}
|
||
|
||
static isImage(trimmed) {
|
||
// Look for image syntax anywhere in the content
|
||
const imagePattern = /!\[.*?\]\([^)]+\)/;
|
||
return imagePattern.test(trimmed);
|
||
}
|
||
|
||
static calculateImageScore(trimmed) {
|
||
const imagePattern = /!\[.*?\]\([^)]+\)/g;
|
||
const matches = trimmed.match(imagePattern);
|
||
|
||
if (matches) {
|
||
// Higher score for standalone images
|
||
const isStandalone = trimmed.replace(imagePattern, '').trim().length < 20;
|
||
return isStandalone ? 0.9 : 0.6;
|
||
}
|
||
|
||
return 0;
|
||
}
|
||
|
||
startEdit() {
|
||
if (this.state === EditState.EDITING) {
|
||
throw new Error(`Section ${this.id} is already being edited`);
|
||
}
|
||
this.editingMarkdown = this.pendingMarkdown || this.currentMarkdown;
|
||
this.state = EditState.EDITING;
|
||
return this.editingMarkdown;
|
||
}
|
||
|
||
updateContent(markdown) {
|
||
if (this.state !== EditState.EDITING) {
|
||
throw new Error(`Section ${this.id} is not in editing state`);
|
||
}
|
||
this.editingMarkdown = markdown;
|
||
}
|
||
|
||
acceptChanges() {
|
||
if (this.state !== EditState.EDITING) {
|
||
throw new Error(`Section ${this.id} is not in editing state`);
|
||
}
|
||
this.currentMarkdown = this.editingMarkdown;
|
||
this.editingMarkdown = null;
|
||
this.pendingMarkdown = null;
|
||
this.state = EditState.SAVED;
|
||
this.lastSaved = new Date();
|
||
return this.currentMarkdown;
|
||
}
|
||
|
||
cancelChanges() {
|
||
if (this.state !== EditState.EDITING) {
|
||
throw new Error(`Section ${this.id} is not in editing state`);
|
||
}
|
||
this.editingMarkdown = null;
|
||
if (this.pendingMarkdown !== null) {
|
||
this.state = EditState.MODIFIED;
|
||
return this.pendingMarkdown;
|
||
} else if (this.lastSaved !== null) {
|
||
this.state = EditState.SAVED;
|
||
return this.currentMarkdown;
|
||
} else {
|
||
this.state = this.hasChanges() ? EditState.MODIFIED : EditState.ORIGINAL;
|
||
return this.currentMarkdown;
|
||
}
|
||
}
|
||
|
||
stopEditing() {
|
||
if (this.state !== EditState.EDITING) {
|
||
return this.state;
|
||
}
|
||
|
||
// If we have editing changes that differ from current content, preserve them as pending
|
||
if (this.editingMarkdown && this.editingMarkdown !== this.currentMarkdown) {
|
||
this.pendingMarkdown = this.editingMarkdown;
|
||
this.state = EditState.MODIFIED; // Has pending changes
|
||
} else {
|
||
// No changes made during this edit session
|
||
this.pendingMarkdown = null;
|
||
if (this.lastSaved !== null) {
|
||
this.state = EditState.SAVED;
|
||
} else {
|
||
this.state = this.hasChanges() ? EditState.MODIFIED : EditState.ORIGINAL;
|
||
}
|
||
}
|
||
|
||
this.editingMarkdown = null;
|
||
return this.state;
|
||
}
|
||
|
||
resetToOriginal() {
|
||
this.currentMarkdown = this.originalMarkdown;
|
||
this.editingMarkdown = this.originalMarkdown;
|
||
this.pendingMarkdown = null;
|
||
this.state = EditState.ORIGINAL;
|
||
return this.originalMarkdown;
|
||
}
|
||
|
||
isEditing() {
|
||
return this.state === EditState.EDITING;
|
||
}
|
||
|
||
hasChanges() {
|
||
return this.currentMarkdown !== this.originalMarkdown;
|
||
}
|
||
|
||
getStatus() {
|
||
return {
|
||
id: this.id,
|
||
state: this.state,
|
||
hasChanges: this.hasChanges(),
|
||
isEditing: this.isEditing(),
|
||
contentLength: this.currentMarkdown.length,
|
||
lastSaved: this.lastSaved,
|
||
sectionType: this.sectionType,
|
||
// Legacy compatibility
|
||
type: this.type,
|
||
originalLength: this.originalMarkdown.length,
|
||
currentLength: this.currentMarkdown.length
|
||
};
|
||
}
|
||
|
||
isImage() {
|
||
return this.type === SectionType.IMAGE;
|
||
}
|
||
|
||
isProtectedHeading() {
|
||
// In insert mode, headings 1-3 are protected
|
||
if (this.type === SectionType.HEADING) {
|
||
const headingMatch = this.originalMarkdown.match(/^(#{1,3})\s/);
|
||
if (headingMatch) {
|
||
this.headingLevel = headingMatch[1].length;
|
||
return this.headingLevel <= 3;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
getHeadingContent() {
|
||
if (this.type === SectionType.HEADING) {
|
||
const lines = this.currentMarkdown.split('\n');
|
||
return lines.slice(1).join('\n').trim();
|
||
}
|
||
return this.currentMarkdown;
|
||
}
|
||
|
||
/**
|
||
* Re-detect and update the section type based on current content
|
||
* @param {string} content - Optional content to use for detection (defaults to currentMarkdown)
|
||
* @returns {string} The detected section type
|
||
*/
|
||
redetectType(content = null) {
|
||
const markdown = content || this.currentMarkdown;
|
||
const oldType = this.type;
|
||
this.type = Section.detectType(markdown);
|
||
|
||
// Emit type change event if type changed
|
||
if (oldType !== this.type) {
|
||
debug(`Section ${this.id} type changed from ${oldType} to ${this.type}`, 'TYPE_DETECTION');
|
||
}
|
||
|
||
return this.type;
|
||
}
|
||
|
||
/**
|
||
* Get detailed type detection information
|
||
* @param {string} content - Optional content to analyze
|
||
* @returns {Object} Detection result with confidence and alternatives
|
||
*/
|
||
getTypeAnalysis(content = null) {
|
||
const markdown = content || this.currentMarkdown;
|
||
return Section.detectTypeWithConfidence(markdown);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* SectionManager class - Manages the collection of sections
|
||
*/
|
||
class SectionManager {
|
||
constructor() {
|
||
this.sections = new Map();
|
||
this.listeners = new Map();
|
||
this.statusInterval = null;
|
||
this.lastStatusUpdate = new Date().toISOString();
|
||
}
|
||
|
||
on(event, callback) {
|
||
if (!this.listeners.has(event)) {
|
||
this.listeners.set(event, []);
|
||
}
|
||
this.listeners.get(event).push(callback);
|
||
}
|
||
|
||
emit(event, data) {
|
||
if (this.listeners.has(event)) {
|
||
this.listeners.get(event).forEach(callback => callback(data));
|
||
}
|
||
}
|
||
|
||
createSectionsFromMarkdown(markdownContent) {
|
||
const lines = markdownContent.split('\n');
|
||
const sections = [];
|
||
let currentSection = '';
|
||
let position = 0;
|
||
|
||
for (let i = 0; i < lines.length; i++) {
|
||
const line = lines[i];
|
||
const isHeading = /^#{1,6}\s/.test(line);
|
||
const isNewParagraph = line.trim() && i > 0 && !lines[i-1].trim();
|
||
const isNewSection = isHeading || isNewParagraph;
|
||
|
||
if (isNewSection && currentSection.trim()) {
|
||
const sectionId = Section.generateId(currentSection, position);
|
||
const sectionType = Section.detectType(currentSection);
|
||
const section = new Section(sectionId, currentSection.trim(), sectionType);
|
||
sections.push(section);
|
||
this.sections.set(sectionId, section);
|
||
position++;
|
||
currentSection = line;
|
||
} else {
|
||
if (currentSection) currentSection += '\n';
|
||
currentSection += line;
|
||
}
|
||
}
|
||
|
||
if (currentSection.trim()) {
|
||
const sectionId = Section.generateId(currentSection, position);
|
||
const sectionType = Section.detectType(currentSection);
|
||
const section = new Section(sectionId, currentSection.trim(), sectionType);
|
||
sections.push(section);
|
||
this.sections.set(sectionId, section);
|
||
}
|
||
|
||
this.emit('sections-created', { sections, count: sections.length });
|
||
return sections;
|
||
}
|
||
|
||
startEditing(sectionId) {
|
||
debug('MANAGER: startEditing called for: ' + sectionId, 'MANAGER');
|
||
|
||
const section = this.sections.get(sectionId);
|
||
if (!section) {
|
||
throw new Error(`Section ${sectionId} not found`);
|
||
}
|
||
|
||
if (section.isEditing()) {
|
||
debug('MANAGER: Section already in editing state: ' + sectionId, 'MANAGER');
|
||
return section.editingMarkdown;
|
||
}
|
||
|
||
debug('MANAGER: Starting edit for section: ' + sectionId, 'MANAGER');
|
||
const content = section.startEdit();
|
||
|
||
debug('MANAGER: About to emit edit-started event for: ' + sectionId, 'MANAGER');
|
||
this.emit('edit-started', { sectionId, content, section: section.getStatus() });
|
||
debug('MANAGER: Emitted edit-started event for: ' + sectionId, 'MANAGER');
|
||
|
||
return content;
|
||
}
|
||
|
||
updateContent(sectionId, markdown) {
|
||
const section = this.sections.get(sectionId);
|
||
if (!section) {
|
||
throw new Error(`Section ${sectionId} not found`);
|
||
}
|
||
|
||
// Store old type for comparison
|
||
const oldType = section.type;
|
||
|
||
// Update content
|
||
section.updateContent(markdown);
|
||
|
||
// Automatically redetect type if content changed significantly
|
||
const newType = section.redetectType(markdown);
|
||
|
||
// Emit events
|
||
const eventData = {
|
||
sectionId,
|
||
markdown,
|
||
section: section.getStatus(),
|
||
typeChanged: oldType !== newType,
|
||
oldType,
|
||
newType
|
||
};
|
||
|
||
this.emit('content-updated', eventData);
|
||
|
||
if (oldType !== newType) {
|
||
this.emit('section-type-changed', {
|
||
sectionId,
|
||
oldType,
|
||
newType,
|
||
section: section.getStatus()
|
||
});
|
||
}
|
||
}
|
||
|
||
acceptChanges(sectionId) {
|
||
const section = this.sections.get(sectionId);
|
||
if (!section) {
|
||
throw new Error(`Section ${sectionId} not found`);
|
||
}
|
||
|
||
const content = section.acceptChanges();
|
||
this.emit('changes-accepted', { sectionId, content, section: section.getStatus() });
|
||
return content;
|
||
}
|
||
|
||
cancelChanges(sectionId) {
|
||
const section = this.sections.get(sectionId);
|
||
if (!section) {
|
||
throw new Error(`Section ${sectionId} not found`);
|
||
}
|
||
|
||
const content = section.cancelChanges();
|
||
this.emit('changes-cancelled', { sectionId, content, section: section.getStatus() });
|
||
return content;
|
||
}
|
||
|
||
resetSection(sectionId) {
|
||
const section = this.sections.get(sectionId);
|
||
if (!section) {
|
||
throw new Error(`Section ${sectionId} not found`);
|
||
}
|
||
|
||
const content = section.resetToOriginal();
|
||
this.emit('section-reset', { sectionId, content, section: section.getStatus() });
|
||
return content;
|
||
}
|
||
|
||
getDocumentMarkdown() {
|
||
const sortedSections = Array.from(this.sections.values())
|
||
.sort((a, b) => a.created - b.created);
|
||
|
||
return sortedSections.map(section => section.currentMarkdown).join('\n\n');
|
||
}
|
||
|
||
getAllSections() {
|
||
return Array.from(this.sections.values());
|
||
}
|
||
|
||
getSectionStatus() {
|
||
return Array.from(this.sections.values()).map(section => section.getStatus());
|
||
}
|
||
|
||
getDocumentStatus() {
|
||
const sections = Array.from(this.sections.values());
|
||
const editingSections = sections.filter(section => section.isEditing).length;
|
||
|
||
return {
|
||
totalSections: sections.length,
|
||
editingSections: editingSections
|
||
};
|
||
}
|
||
|
||
escapeRegex(str) {
|
||
return str.replace(/[.*+?^${}()|\[\]\\]/g, '\\\\$&');
|
||
}
|
||
|
||
/**
|
||
* Check if new content contains new headings that would require section splitting
|
||
* @param {string} newContent - The new content to check
|
||
* @param {string} originalContent - The original content to compare against
|
||
* @returns {boolean} True if section splitting is needed
|
||
*/
|
||
checkForSectionSplits(newContent, originalContent) {
|
||
const originalHeadings = this.extractHeadings(originalContent);
|
||
const newHeadings = this.extractHeadings(newContent);
|
||
|
||
// If new content has more headings than original, we need to split
|
||
return newHeadings.length > originalHeadings.length;
|
||
}
|
||
|
||
/**
|
||
* Extract heading lines from markdown content
|
||
* @param {string} content - Markdown content
|
||
* @returns {Array} Array of heading lines
|
||
*/
|
||
extractHeadings(content) {
|
||
if (!content) return [];
|
||
const lines = content.split('\n');
|
||
return lines.filter(line => /^#{1,6}\s/.test(line.trim()));
|
||
}
|
||
|
||
/**
|
||
* Handle splitting a section when new headings are detected
|
||
* @param {string} sectionId - ID of the section to split
|
||
* @param {string} newContent - New content with multiple headings
|
||
*/
|
||
handleSectionSplit(sectionId, newContent) {
|
||
const section = this.sections.get(sectionId);
|
||
if (!section) {
|
||
throw new Error(`Section ${sectionId} not found`);
|
||
}
|
||
|
||
// Remove the original section
|
||
this.sections.delete(sectionId);
|
||
|
||
// Create new sections from the content
|
||
const newSections = this.createSectionsFromContent(newContent);
|
||
|
||
// Emit section-split event
|
||
this.emit('section-split', {
|
||
originalSectionId: sectionId,
|
||
newSections: newSections,
|
||
count: newSections.length
|
||
});
|
||
|
||
return newSections;
|
||
}
|
||
|
||
/**
|
||
* Create sections from content (alias for createSectionsFromMarkdown)
|
||
* @param {string} content - Markdown content
|
||
* @returns {Array} Array of created sections
|
||
*/
|
||
createSectionsFromContent(content) {
|
||
return this.createSectionsFromMarkdown(content);
|
||
}
|
||
|
||
/**
|
||
* Get global status information about all sections
|
||
* @returns {Object} Status object with global information
|
||
*/
|
||
getGlobalStatus() {
|
||
const sections = this.getAllSections();
|
||
const editingSections = sections.filter(s => s.isEditing()).map(s => s.id);
|
||
const modifiedSections = sections.filter(s => s.hasChanges());
|
||
const hasModifications = modifiedSections.length > 0 || editingSections.length > 0;
|
||
|
||
let state = 'ready';
|
||
if (editingSections.length > 0) {
|
||
state = 'editing';
|
||
} else if (hasModifications) {
|
||
state = 'modified';
|
||
}
|
||
|
||
return {
|
||
totalSections: sections.length,
|
||
editingSections: editingSections,
|
||
modifiedSections: modifiedSections.length,
|
||
hasModifications: hasModifications,
|
||
state: state,
|
||
lastUpdate: this.lastStatusUpdate
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Update global status and emit status-updated event
|
||
*/
|
||
updateGlobalStatus() {
|
||
this.lastStatusUpdate = new Date().toISOString();
|
||
const status = this.getGlobalStatus();
|
||
this.emit('status-updated', status);
|
||
return status;
|
||
}
|
||
|
||
/**
|
||
* Start periodic status tracking
|
||
* @param {number} intervalMs - Update interval in milliseconds (default: 2000)
|
||
*/
|
||
startStatusTracking(intervalMs = 2000) {
|
||
if (this.statusInterval) {
|
||
clearInterval(this.statusInterval);
|
||
}
|
||
|
||
this.statusInterval = setInterval(() => {
|
||
this.updateGlobalStatus();
|
||
}, intervalMs);
|
||
|
||
// Emit initial status
|
||
this.updateGlobalStatus();
|
||
}
|
||
|
||
/**
|
||
* Stop periodic status tracking
|
||
*/
|
||
stopStatusTracking() {
|
||
if (this.statusInterval) {
|
||
clearInterval(this.statusInterval);
|
||
this.statusInterval = null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Bulk Operations for Concurrent Editing Sessions
|
||
*/
|
||
|
||
/**
|
||
* Accept changes for all currently editing sessions
|
||
* @returns {Array} Array of results from accept operations
|
||
*/
|
||
acceptAllEditingSessions() {
|
||
const editingSections = this.getAllSections().filter(section => section.isEditing());
|
||
const results = [];
|
||
|
||
editingSections.forEach(section => {
|
||
try {
|
||
const content = this.acceptChanges(section.id);
|
||
results.push({ sectionId: section.id, success: true, content });
|
||
} catch (error) {
|
||
results.push({ sectionId: section.id, success: false, error: error.message });
|
||
}
|
||
});
|
||
|
||
this.emit('bulk-accept-completed', { results, count: results.length });
|
||
return results;
|
||
}
|
||
|
||
/**
|
||
* Cancel changes for all currently editing sessions
|
||
* @returns {Array} Array of results from cancel operations
|
||
*/
|
||
cancelAllEditingSessions() {
|
||
const editingSections = this.getAllSections().filter(section => section.isEditing());
|
||
const results = [];
|
||
|
||
editingSections.forEach(section => {
|
||
try {
|
||
const content = this.cancelChanges(section.id);
|
||
results.push({ sectionId: section.id, success: true, content });
|
||
} catch (error) {
|
||
results.push({ sectionId: section.id, success: false, error: error.message });
|
||
}
|
||
});
|
||
|
||
this.emit('bulk-cancel-completed', { results, count: results.length });
|
||
return results;
|
||
}
|
||
|
||
/**
|
||
* Stop editing for all currently editing sessions (preserve changes as pending)
|
||
* @returns {Array} Array of results from stop operations
|
||
*/
|
||
stopAllEditingSessions() {
|
||
const editingSections = this.getAllSections().filter(section => section.isEditing());
|
||
const results = [];
|
||
|
||
editingSections.forEach(section => {
|
||
try {
|
||
const finalState = section.stopEditing();
|
||
results.push({ sectionId: section.id, success: true, finalState });
|
||
} catch (error) {
|
||
results.push({ sectionId: section.id, success: false, error: error.message });
|
||
}
|
||
});
|
||
|
||
this.emit('bulk-stop-completed', { results, count: results.length });
|
||
return results;
|
||
}
|
||
|
||
/**
|
||
* Get detailed status of all concurrent editing sessions
|
||
* @returns {Object} Detailed concurrent session information
|
||
*/
|
||
getConcurrentEditingStatus() {
|
||
const sections = this.getAllSections();
|
||
const editingSections = sections.filter(s => s.isEditing());
|
||
const modifiedSections = sections.filter(s => s.hasChanges());
|
||
const pendingSections = sections.filter(s => s.pendingMarkdown !== null);
|
||
|
||
return {
|
||
totalSections: sections.length,
|
||
concurrentSessions: {
|
||
editing: editingSections.map(s => ({
|
||
id: s.id,
|
||
type: s.type,
|
||
hasUnsavedChanges: s.editingMarkdown !== s.currentMarkdown,
|
||
editingLength: s.editingMarkdown ? s.editingMarkdown.length : 0,
|
||
originalLength: s.originalMarkdown.length
|
||
})),
|
||
editingCount: editingSections.length,
|
||
pendingCount: pendingSections.length,
|
||
modifiedCount: modifiedSections.length
|
||
},
|
||
systemState: {
|
||
allowsConcurrentEditing: true,
|
||
maxConcurrentSessions: null, // No limit
|
||
activeSessionCount: editingSections.length
|
||
}
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Handle conflicts when multiple sections are edited simultaneously
|
||
* @param {string} sectionId - Primary section ID
|
||
* @param {Array} conflictingSectionIds - IDs of sections with potential conflicts
|
||
* @returns {Object} Conflict resolution result
|
||
*/
|
||
resolveEditingConflicts(sectionId, conflictingSectionIds = []) {
|
||
const primarySection = this.sections.get(sectionId);
|
||
if (!primarySection) {
|
||
throw new Error(`Primary section ${sectionId} not found`);
|
||
}
|
||
|
||
const conflicts = [];
|
||
const resolutions = [];
|
||
|
||
conflictingSectionIds.forEach(conflictId => {
|
||
const conflictSection = this.sections.get(conflictId);
|
||
if (conflictSection && conflictSection.isEditing()) {
|
||
// Check for content overlap or dependency conflicts
|
||
const hasConflict = this.detectContentConflict(primarySection, conflictSection);
|
||
|
||
if (hasConflict) {
|
||
conflicts.push({
|
||
sectionId: conflictId,
|
||
conflictType: 'content-overlap',
|
||
description: 'Sections may have overlapping content changes'
|
||
});
|
||
|
||
// Auto-resolve by preserving both as separate sections
|
||
resolutions.push({
|
||
sectionId: conflictId,
|
||
resolution: 'preserve-separate',
|
||
action: 'maintain-current-state'
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
this.emit('conflicts-resolved', { sectionId, conflicts, resolutions });
|
||
return { conflicts, resolutions, resolved: true };
|
||
}
|
||
|
||
/**
|
||
* Detect potential content conflicts between two sections
|
||
* @param {Section} section1 - First section
|
||
* @param {Section} section2 - Second section
|
||
* @returns {boolean} True if conflict detected
|
||
*/
|
||
detectContentConflict(section1, section2) {
|
||
// Simple conflict detection - check for similar content or headings
|
||
if (section1.type === 'heading' && section2.type === 'heading') {
|
||
const heading1 = section1.editingMarkdown || section1.currentMarkdown;
|
||
const heading2 = section2.editingMarkdown || section2.currentMarkdown;
|
||
|
||
// Check if headings are similar (potential duplicate)
|
||
const similarity = this.calculateContentSimilarity(heading1, heading2);
|
||
return similarity > 0.8; // 80% similarity threshold
|
||
}
|
||
|
||
return false; // No conflicts detected for non-heading sections
|
||
}
|
||
|
||
/**
|
||
* Calculate content similarity between two markdown strings
|
||
* @param {string} content1 - First content
|
||
* @param {string} content2 - Second content
|
||
* @returns {number} Similarity score (0-1)
|
||
*/
|
||
calculateContentSimilarity(content1, content2) {
|
||
if (!content1 || !content2) return 0;
|
||
|
||
const clean1 = content1.toLowerCase().replace(/[^a-z0-9\s]/g, '');
|
||
const clean2 = content2.toLowerCase().replace(/[^a-z0-9\s]/g, '');
|
||
|
||
const words1 = clean1.split(/\s+/).filter(w => w.length > 0);
|
||
const words2 = clean2.split(/\s+/).filter(w => w.length > 0);
|
||
|
||
if (words1.length === 0 && words2.length === 0) return 1;
|
||
if (words1.length === 0 || words2.length === 0) return 0;
|
||
|
||
const commonWords = words1.filter(word => words2.includes(word));
|
||
const totalWords = Math.max(words1.length, words2.length);
|
||
|
||
return commonWords.length / totalWords;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* DOM Renderer - Handles DOM interactions with Enhanced Event System
|
||
*/
|
||
class DOMRenderer {
|
||
constructor(sectionManager, container) {
|
||
this.sectionManager = sectionManager;
|
||
this.container = container;
|
||
this.editingSections = new Set();
|
||
this.currentFloatingMenu = null;
|
||
this.eventListenersAttached = false;
|
||
|
||
// Enhanced Event System - Track 6 event types
|
||
this.eventHistory = [];
|
||
this.eventStats = {
|
||
'section-click': 0,
|
||
'section-hover-enter': 0,
|
||
'section-hover-leave': 0,
|
||
'keyboard-shortcut': 0,
|
||
'section-drag-start': 0,
|
||
'section-drag-over': 0,
|
||
'section-drop': 0,
|
||
'section-focus-in': 0,
|
||
'section-focus-out': 0,
|
||
'section-context-menu': 0
|
||
};
|
||
|
||
// Bind event handlers
|
||
this.handleSectionClick = this.handleSectionClick.bind(this);
|
||
this.handleSectionHover = this.handleSectionHover.bind(this);
|
||
this.handleAccept = this.handleAccept.bind(this);
|
||
this.handleCancel = this.handleCancel.bind(this);
|
||
this.handleReset = this.handleReset.bind(this);
|
||
this.handleKeydown = this.handleKeydown.bind(this);
|
||
this.handleDragStart = this.handleDragStart.bind(this);
|
||
this.handleDragOver = this.handleDragOver.bind(this);
|
||
this.handleDrop = this.handleDrop.bind(this);
|
||
this.handleFocus = this.handleFocus.bind(this);
|
||
this.handleContextMenu = this.handleContextMenu.bind(this);
|
||
|
||
this.setupEventListeners();
|
||
}
|
||
|
||
setupEventListeners() {
|
||
this.sectionManager.on('sections-created', (data) => {
|
||
this.renderAllSections(data.sections);
|
||
});
|
||
this.sectionManager.on('edit-started', (data) => {
|
||
debug('EVENT: edit-started event received for: ' + data.sectionId, 'EVENT');
|
||
this.showEditor(data.sectionId, data.content);
|
||
});
|
||
this.sectionManager.on('edit-stopped', (data) => {
|
||
this.hideEditor(data.sectionId);
|
||
});
|
||
this.sectionManager.on('changes-accepted', (data) => {
|
||
this.hideEditor(data.sectionId);
|
||
this.updateSectionContent(data.sectionId, data.content);
|
||
});
|
||
this.sectionManager.on('changes-cancelled', (data) => {
|
||
this.hideEditor(data.sectionId);
|
||
this.updateSectionContent(data.sectionId, data.content);
|
||
});
|
||
this.sectionManager.on('section-reset', (data) => {
|
||
this.hideEditor(data.sectionId);
|
||
this.updateSectionContent(data.sectionId, data.content);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Track and log DOM events for analytics and debugging
|
||
* @param {string} eventType - Type of event
|
||
* @param {Object} eventData - Event data
|
||
*/
|
||
trackEvent(eventType, eventData) {
|
||
const timestamp = new Date().toISOString();
|
||
const eventRecord = {
|
||
type: eventType,
|
||
timestamp,
|
||
data: eventData
|
||
};
|
||
|
||
// Add to history (keep last 1000 events)
|
||
this.eventHistory.push(eventRecord);
|
||
if (this.eventHistory.length > 1000) {
|
||
this.eventHistory.shift();
|
||
}
|
||
|
||
// Update stats
|
||
if (this.eventStats.hasOwnProperty(eventType)) {
|
||
this.eventStats[eventType]++;
|
||
}
|
||
|
||
// Emit to section manager for broader handling
|
||
this.sectionManager.emit(eventType, eventData);
|
||
}
|
||
|
||
/**
|
||
* Get event statistics for debugging
|
||
* @returns {Object} Event statistics
|
||
*/
|
||
getEventStats() {
|
||
return {
|
||
stats: { ...this.eventStats },
|
||
recentEvents: this.eventHistory.slice(-10),
|
||
totalEvents: this.eventHistory.length
|
||
};
|
||
}
|
||
|
||
renderAllSections(sections) {
|
||
debug('21: renderAllSections called with ' + sections.length + ' sections', 'RENDER');
|
||
|
||
this.container.innerHTML = '';
|
||
debug('22: Container cleared', 'RENDER');
|
||
|
||
sections.forEach((section, index) => {
|
||
debug('23: Creating element for section ' + (index + 1) + ': ' + section.id, 'RENDER');
|
||
const element = this.createSectionElement(section);
|
||
section.domElement = element;
|
||
this.container.appendChild(element);
|
||
});
|
||
|
||
debug('24: All section elements added to container', 'RENDER');
|
||
|
||
// Enhanced DOM Event System - Setup all 6 event types with delegation (only once)
|
||
if (!this.eventListenersAttached) {
|
||
this.container.addEventListener('click', this.handleSectionClick);
|
||
this.container.addEventListener('mouseenter', this.handleSectionHover, true);
|
||
this.container.addEventListener('mouseleave', this.handleSectionHover, true);
|
||
this.container.addEventListener('keydown', this.handleKeydown);
|
||
this.container.addEventListener('dragstart', this.handleDragStart);
|
||
this.container.addEventListener('dragover', this.handleDragOver);
|
||
this.container.addEventListener('drop', this.handleDrop);
|
||
this.container.addEventListener('focusin', this.handleFocus);
|
||
this.container.addEventListener('focusout', this.handleFocus);
|
||
this.container.addEventListener('contextmenu', this.handleContextMenu);
|
||
|
||
this.eventListenersAttached = true;
|
||
debug('25: Enhanced event listeners attached for the first time', 'RENDER');
|
||
} else {
|
||
debug('25: Event listeners already attached, skipping', 'RENDER');
|
||
}
|
||
|
||
debug('25: Container content length: ' + this.container.innerHTML.length, 'RENDER');
|
||
}
|
||
|
||
createSectionElement(section) {
|
||
const element = document.createElement('div');
|
||
element.setAttribute('data-section-id', section.id);
|
||
debug('SECTION: Creating element for section ' + section.id + ' with content: ' + section.currentMarkdown.substring(0, 50) + '...', 'SECTION');
|
||
|
||
if (typeof marked !== 'undefined') {
|
||
const html = marked.parse(section.currentMarkdown);
|
||
const htmlWithTargetBlank = html.replace(/<a href="([^"]*)"([^>]*)>/g, '<a href="$1" target="_blank"$2>');
|
||
element.innerHTML = htmlWithTargetBlank;
|
||
} else {
|
||
element.innerHTML = `<p>${section.currentMarkdown}</p>`;
|
||
}
|
||
|
||
this.setupSectionElement(element);
|
||
debug('SECTION: Section element setup complete for ' + section.id, 'SECTION');
|
||
return element;
|
||
}
|
||
|
||
handleSectionClick(event) {
|
||
debug('handleSectionClick: Click detected on target: ' + event.target.tagName + ' ' + (event.target.className || ''), 'CLICK');
|
||
|
||
// Don't handle clicks on form elements, buttons, or links
|
||
if (event.target.closest('textarea, button, input, a')) {
|
||
debug('handleSectionClick: Ignoring click on form element', 'CLICK');
|
||
return;
|
||
}
|
||
|
||
const sectionElement = event.target.closest('.ui-edit-section');
|
||
debug('handleSectionClick: Found section element: ' + (sectionElement ? sectionElement.getAttribute('data-section-id') : 'null'), 'CLICK');
|
||
if (!sectionElement) return;
|
||
|
||
const sectionId = sectionElement.getAttribute('data-section-id');
|
||
debug('handleSectionClick: Section ID: ' + sectionId, 'CLICK');
|
||
if (!sectionId) return;
|
||
|
||
// Track the click event
|
||
this.trackEvent('section-click', {
|
||
sectionId,
|
||
target: event.target.tagName.toLowerCase(),
|
||
event,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
// Check if this section is already being edited
|
||
const section = this.sectionManager.sections.get(sectionId);
|
||
debug('handleSectionClick: Found section object: ' + (section ? 'YES' : 'NO'), 'CLICK');
|
||
|
||
if (section && section.isEditing()) {
|
||
debug('handleSectionClick: Section already being edited: ' + sectionId, 'CLICK');
|
||
return;
|
||
}
|
||
|
||
debug('handleSectionClick: About to start editing for section: ' + sectionId, 'CLICK');
|
||
|
||
try {
|
||
debug('handleSectionClick: Calling sectionManager.startEditing', 'CLICK');
|
||
this.sectionManager.startEditing(sectionId);
|
||
debug('handleSectionClick: Successfully called startEditing', 'CLICK');
|
||
} catch (error) {
|
||
debug('handleSectionClick: ERROR in startEditing: ' + error.message, 'ERROR');
|
||
console.error('Failed to start editing:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handle hover events (mouseenter/mouseleave)
|
||
* @param {Event} event - Mouse event
|
||
*/
|
||
handleSectionHover(event) {
|
||
const sectionElement = event.target.closest('.ui-edit-section');
|
||
if (!sectionElement) return;
|
||
|
||
const sectionId = sectionElement.getAttribute('data-section-id');
|
||
if (!sectionId) return;
|
||
|
||
const eventType = event.type === 'mouseenter' ? 'section-hover-enter' : 'section-hover-leave';
|
||
|
||
// Track the hover event
|
||
this.trackEvent(eventType, {
|
||
sectionId,
|
||
event,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Handle drag and drop events for section reordering
|
||
* @param {Event} event - Drag event
|
||
*/
|
||
handleDragStart(event) {
|
||
const sectionElement = event.target.closest('.ui-edit-section');
|
||
if (!sectionElement) return;
|
||
|
||
const sectionId = sectionElement.getAttribute('data-section-id');
|
||
if (!sectionId) return;
|
||
|
||
// Store the section ID being dragged
|
||
event.dataTransfer.setData('text/plain', sectionId);
|
||
event.dataTransfer.effectAllowed = 'move';
|
||
|
||
// Track the drag start event
|
||
this.trackEvent('section-drag-start', {
|
||
sectionId,
|
||
event,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
|
||
handleDragOver(event) {
|
||
const sectionElement = event.target.closest('.ui-edit-section');
|
||
if (!sectionElement) return;
|
||
|
||
const sectionId = sectionElement.getAttribute('data-section-id');
|
||
if (!sectionId) return;
|
||
|
||
event.preventDefault(); // Allow drop
|
||
event.dataTransfer.dropEffect = 'move';
|
||
|
||
// Track the drag over event
|
||
this.trackEvent('section-drag-over', {
|
||
sectionId,
|
||
event,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
|
||
handleDrop(event) {
|
||
const sectionElement = event.target.closest('.ui-edit-section');
|
||
if (!sectionElement) return;
|
||
|
||
const targetSectionId = sectionElement.getAttribute('data-section-id');
|
||
const draggedSectionId = event.dataTransfer.getData('text/plain');
|
||
|
||
if (!targetSectionId || !draggedSectionId || targetSectionId === draggedSectionId) return;
|
||
|
||
event.preventDefault();
|
||
|
||
// Track the drop event
|
||
this.trackEvent('section-drop', {
|
||
draggedSectionId,
|
||
targetSectionId,
|
||
event,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
// Emit section reorder event
|
||
this.sectionManager.emit('section-reorder', {
|
||
draggedSectionId,
|
||
targetSectionId,
|
||
action: 'reorder'
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Handle focus events for accessibility
|
||
* @param {Event} event - Focus event
|
||
*/
|
||
handleFocus(event) {
|
||
const sectionElement = event.target.closest('.ui-edit-section');
|
||
if (!sectionElement) return;
|
||
|
||
const sectionId = sectionElement.getAttribute('data-section-id');
|
||
if (!sectionId) return;
|
||
|
||
const eventType = event.type === 'focusin' ? 'section-focus-in' : 'section-focus-out';
|
||
|
||
// Track the focus event
|
||
this.trackEvent(eventType, {
|
||
sectionId,
|
||
target: event.target.tagName.toLowerCase(),
|
||
event,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Handle context menu events for right-click operations
|
||
* @param {Event} event - Context menu event
|
||
*/
|
||
handleContextMenu(event) {
|
||
const sectionElement = event.target.closest('.ui-edit-section');
|
||
if (!sectionElement) return;
|
||
|
||
const sectionId = sectionElement.getAttribute('data-section-id');
|
||
if (!sectionId) return;
|
||
|
||
// Track the context menu event
|
||
this.trackEvent('section-context-menu', {
|
||
sectionId,
|
||
x: event.clientX,
|
||
y: event.clientY,
|
||
event,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
// Prevent default context menu for sections
|
||
event.preventDefault();
|
||
|
||
// Show custom context menu
|
||
this.showSectionContextMenu(sectionId, event.clientX, event.clientY);
|
||
}
|
||
|
||
/**
|
||
* Show custom context menu for section operations
|
||
* @param {string} sectionId - Section ID
|
||
* @param {number} x - X coordinate
|
||
* @param {number} y - Y coordinate
|
||
*/
|
||
showSectionContextMenu(sectionId, x, y) {
|
||
// Remove existing context menu
|
||
const existingMenu = document.querySelector('.ui-edit-context-menu');
|
||
if (existingMenu) {
|
||
existingMenu.remove();
|
||
}
|
||
|
||
const menu = document.createElement('div');
|
||
menu.className = 'ui-edit-context-menu';
|
||
menu.style.cssText = `
|
||
position: fixed;
|
||
left: ${x}px;
|
||
top: ${y}px;
|
||
background: white;
|
||
border: 1px solid #ccc;
|
||
border-radius: 6px;
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||
z-index: 10000;
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||
font-size: 14px;
|
||
min-width: 150px;
|
||
`;
|
||
|
||
const section = this.sectionManager.sections.get(sectionId);
|
||
const isEditing = section && section.isEditing();
|
||
|
||
const menuItems = [
|
||
{ text: 'Edit Section', action: () => this.sectionManager.startEditing(sectionId), disabled: isEditing },
|
||
{ text: 'Copy Section', action: () => this.copySectionToClipboard(sectionId), disabled: false },
|
||
{ text: 'Delete Section', action: () => this.deleteSection(sectionId), disabled: isEditing },
|
||
{ text: '—', action: null, disabled: false }, // Separator
|
||
{ text: 'Move Up', action: () => this.moveSectionUp(sectionId), disabled: false },
|
||
{ text: 'Move Down', action: () => this.moveSectionDown(sectionId), disabled: false }
|
||
];
|
||
|
||
menuItems.forEach(item => {
|
||
if (item.text === '—') {
|
||
const separator = document.createElement('div');
|
||
separator.style.cssText = 'height: 1px; background: #eee; margin: 4px 0;';
|
||
menu.appendChild(separator);
|
||
} else {
|
||
const menuItem = document.createElement('div');
|
||
menuItem.textContent = item.text;
|
||
menuItem.style.cssText = `
|
||
padding: 8px 12px;
|
||
cursor: ${item.disabled ? 'not-allowed' : 'pointer'};
|
||
color: ${item.disabled ? '#999' : '#333'};
|
||
background: ${item.disabled ? 'transparent' : 'white'};
|
||
`;
|
||
|
||
if (!item.disabled) {
|
||
menuItem.addEventListener('mouseenter', () => {
|
||
menuItem.style.background = '#f0f0f0';
|
||
});
|
||
menuItem.addEventListener('mouseleave', () => {
|
||
menuItem.style.background = 'white';
|
||
});
|
||
menuItem.addEventListener('click', () => {
|
||
item.action();
|
||
menu.remove();
|
||
});
|
||
}
|
||
|
||
menu.appendChild(menuItem);
|
||
}
|
||
});
|
||
|
||
document.body.appendChild(menu);
|
||
|
||
// Remove menu when clicking elsewhere
|
||
const removeMenu = (e) => {
|
||
if (!menu.contains(e.target)) {
|
||
menu.remove();
|
||
document.removeEventListener('click', removeMenu);
|
||
}
|
||
};
|
||
setTimeout(() => document.addEventListener('click', removeMenu), 100);
|
||
}
|
||
|
||
/**
|
||
* Context menu actions
|
||
*/
|
||
copySectionToClipboard(sectionId) {
|
||
const section = this.sectionManager.sections.get(sectionId);
|
||
if (section) {
|
||
navigator.clipboard.writeText(section.currentMarkdown).then(() => {
|
||
console.log('Section copied to clipboard');
|
||
});
|
||
}
|
||
}
|
||
|
||
deleteSection(sectionId) {
|
||
if (confirm('Are you sure you want to delete this section?')) {
|
||
this.sectionManager.sections.delete(sectionId);
|
||
const element = this.findSectionElement(sectionId);
|
||
if (element) {
|
||
element.remove();
|
||
}
|
||
}
|
||
}
|
||
|
||
moveSectionUp(sectionId) {
|
||
const element = this.findSectionElement(sectionId);
|
||
if (element && element.previousElementSibling) {
|
||
element.parentNode.insertBefore(element, element.previousElementSibling);
|
||
}
|
||
}
|
||
|
||
moveSectionDown(sectionId) {
|
||
const element = this.findSectionElement(sectionId);
|
||
if (element && element.nextElementSibling) {
|
||
element.parentNode.insertBefore(element.nextElementSibling, element);
|
||
}
|
||
}
|
||
|
||
showEditor(sectionId, content) {
|
||
debug('showEditor: called for section: ' + sectionId, 'EDITOR');
|
||
|
||
const element = this.findSectionElement(sectionId);
|
||
debug('showEditor: Found element: ' + (element ? 'YES' : 'NO'), 'EDITOR');
|
||
if (!element) return;
|
||
|
||
debug('showEditor: About to hide current editor', 'EDITOR');
|
||
this.hideCurrentEditor();
|
||
debug('showEditor: Hidden current editor', 'EDITOR');
|
||
|
||
const section = this.sectionManager.sections.get(sectionId);
|
||
const isImageSection = section && section.isImage();
|
||
|
||
if (isImageSection) {
|
||
this.showImageEditor(sectionId, section);
|
||
return;
|
||
}
|
||
|
||
// Create content area for text editing (optimized for side-by-side layout)
|
||
const editorContent = document.createElement('div');
|
||
editorContent.className = 'ui-edit-editor-content';
|
||
editorContent.style.cssText = `
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
flex: 1;
|
||
min-width: 0;
|
||
`;
|
||
|
||
const textarea = document.createElement('textarea');
|
||
textarea.className = 'ui-edit-textarea ui-edit-textarea-main';
|
||
textarea.value = content;
|
||
textarea.style.cssText = `
|
||
flex: 1;
|
||
min-height: 200px;
|
||
width: 100%;
|
||
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
padding: 12px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 8px;
|
||
resize: none;
|
||
outline: none;
|
||
background: white;
|
||
color: #333;
|
||
box-sizing: border-box;
|
||
`;
|
||
|
||
editorContent.appendChild(textarea);
|
||
|
||
// Setup auto-resize functionality
|
||
this.setupAutoResize(textarea);
|
||
|
||
// Create controls (optimized for side panel layout)
|
||
const controls = document.createElement('div');
|
||
controls.className = 'ui-edit-controls';
|
||
controls.style.cssText = `
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
width: 100%;
|
||
`;
|
||
|
||
const acceptBtn = this.createButton('Accept', 'ui-edit-button-accept', this.handleAccept);
|
||
const cancelBtn = this.createButton('Cancel', 'ui-edit-button-cancel', this.handleCancel);
|
||
const resetBtn = this.createButton('Reset', 'ui-edit-button-reset', this.handleReset);
|
||
|
||
// Style buttons for side panel layout
|
||
[acceptBtn, cancelBtn, resetBtn].forEach(btn => {
|
||
btn.style.cssText += `
|
||
padding: 8px 12px;
|
||
font-size: 12px;
|
||
border-radius: 6px;
|
||
border: none;
|
||
color: white;
|
||
cursor: pointer;
|
||
font-weight: 600;
|
||
transition: all 0.2s ease;
|
||
width: 100%;
|
||
text-align: center;
|
||
`;
|
||
});
|
||
|
||
acceptBtn.style.background = '#28a745';
|
||
cancelBtn.style.background = '#dc3545';
|
||
resetBtn.style.background = '#fd7e14';
|
||
|
||
controls.appendChild(acceptBtn);
|
||
controls.appendChild(cancelBtn);
|
||
controls.appendChild(resetBtn);
|
||
|
||
// Create unified floating menu using new component
|
||
this.currentFloatingMenu = new FloatingMenu(sectionId, 'text', this);
|
||
const floatingMenu = this.currentFloatingMenu.show(editorContent, controls);
|
||
if (!floatingMenu) return;
|
||
|
||
textarea.focus();
|
||
this.editingSections.add(sectionId);
|
||
|
||
textarea.addEventListener('input', () => {
|
||
this.sectionManager.updateContent(sectionId, textarea.value);
|
||
});
|
||
|
||
// Add keyboard shortcuts
|
||
textarea.addEventListener('keydown', this.handleKeydown);
|
||
}
|
||
|
||
/**
|
||
* Create a unified floating edit menu
|
||
* @param {string} sectionId - The section being edited
|
||
* @param {string} type - 'text' or 'image'
|
||
* @param {HTMLElement} contentElement - The content area to add to the menu
|
||
* @param {HTMLElement} controlsElement - The controls/buttons to add to the menu
|
||
* @returns {HTMLElement} The floating menu element
|
||
*/
|
||
|
||
/**
|
||
* Show image editor with manipulation controls
|
||
* @param {string} sectionId - The section ID
|
||
* @param {Section} section - The section object
|
||
*/
|
||
showImageEditor(sectionId, section) {
|
||
const element = this.findSectionElement(sectionId);
|
||
if (!element) return;
|
||
|
||
this.hideCurrentEditor();
|
||
|
||
// Track staging state for this editor
|
||
const stagingState = {
|
||
originalMarkdown: section.currentMarkdown,
|
||
currentAltText: '',
|
||
currentImageSrc: '',
|
||
stagedImageSrc: null,
|
||
stagedAltText: null,
|
||
hasChanges: false
|
||
};
|
||
|
||
// Create image editor content area (optimized for side-by-side layout)
|
||
const editorContent = document.createElement('div');
|
||
editorContent.className = 'ui-edit-image-content';
|
||
editorContent.style.cssText = `
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 15px;
|
||
flex: 1;
|
||
min-width: 0;
|
||
`;
|
||
|
||
// Parse markdown to extract image info
|
||
const imageMatch = section.currentMarkdown.match(/!\[(.*?)\]\((.*?)\)/);
|
||
if (imageMatch) {
|
||
const [, altText, imageSrc] = imageMatch;
|
||
stagingState.currentAltText = altText;
|
||
stagingState.currentImageSrc = imageSrc;
|
||
}
|
||
|
||
// Image preview with drop zone for floating menu
|
||
const imagePreview = document.createElement('div');
|
||
imagePreview.className = 'ui-edit-image-preview';
|
||
imagePreview.style.cssText = `
|
||
width: 100%;
|
||
height: 180px;
|
||
text-align: center;
|
||
background: white;
|
||
padding: 12px;
|
||
border-radius: 8px;
|
||
border: 2px dashed #007bff;
|
||
transition: all 0.3s ease;
|
||
cursor: pointer;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
position: relative;
|
||
box-sizing: border-box;
|
||
overflow: hidden;
|
||
`;
|
||
|
||
// Function to update image preview
|
||
const updateImagePreview = (imageSrc, altText) => {
|
||
imagePreview.innerHTML = '';
|
||
|
||
if (imageSrc) {
|
||
const img = document.createElement('img');
|
||
img.src = imageSrc;
|
||
img.alt = altText || '';
|
||
img.style.cssText = `
|
||
max-width: 100%;
|
||
max-height: 250px;
|
||
border-radius: 4px;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||
`;
|
||
imagePreview.appendChild(img);
|
||
|
||
// Add overlay for drop zone
|
||
const overlay = document.createElement('div');
|
||
overlay.className = 'drop-overlay';
|
||
overlay.style.cssText = `
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 123, 255, 0.1);
|
||
border-radius: 6px;
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #007bff;
|
||
font-weight: bold;
|
||
font-size: 18px;
|
||
`;
|
||
overlay.textContent = '📁 Drop new image here';
|
||
imagePreview.appendChild(overlay);
|
||
} else {
|
||
// Show drop zone placeholder
|
||
const placeholder = document.createElement('div');
|
||
placeholder.style.cssText = `
|
||
text-align: center;
|
||
color: #6c757d;
|
||
font-size: 16px;
|
||
`;
|
||
placeholder.innerHTML = `
|
||
<div style="font-size: 48px; margin-bottom: 12px;">📁</div>
|
||
<div style="margin-bottom: 8px;"><strong>Drop image here or click to select</strong></div>
|
||
<div style="font-size: 14px;">Supports JPG, PNG, GIF, WebP</div>
|
||
`;
|
||
imagePreview.appendChild(placeholder);
|
||
}
|
||
};
|
||
|
||
// Initialize preview
|
||
updateImagePreview(stagingState.currentImageSrc, stagingState.currentAltText);
|
||
|
||
// File input for image selection
|
||
const fileInput = document.createElement('input');
|
||
fileInput.type = 'file';
|
||
fileInput.accept = 'image/*';
|
||
fileInput.style.display = 'none';
|
||
|
||
// Function to handle image file selection
|
||
const handleImageFile = (file) => {
|
||
if (file && file.type.startsWith('image/')) {
|
||
const reader = new FileReader();
|
||
reader.onload = (event) => {
|
||
stagingState.stagedImageSrc = event.target.result;
|
||
stagingState.hasChanges = true;
|
||
updateImagePreview(stagingState.stagedImageSrc, altTextInput.value);
|
||
updateChangeIndicator();
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
};
|
||
|
||
// Drag and drop functionality
|
||
imagePreview.addEventListener('dragover', (e) => {
|
||
e.preventDefault();
|
||
imagePreview.style.borderColor = '#28a745';
|
||
imagePreview.style.backgroundColor = '#f8fff8';
|
||
const overlay = imagePreview.querySelector('.drop-overlay');
|
||
if (overlay) overlay.style.display = 'flex';
|
||
});
|
||
|
||
imagePreview.addEventListener('dragleave', (e) => {
|
||
e.preventDefault();
|
||
imagePreview.style.borderColor = '#007bff';
|
||
imagePreview.style.backgroundColor = 'white';
|
||
const overlay = imagePreview.querySelector('.drop-overlay');
|
||
if (overlay) overlay.style.display = 'none';
|
||
});
|
||
|
||
imagePreview.addEventListener('drop', (e) => {
|
||
e.preventDefault();
|
||
imagePreview.style.borderColor = '#007bff';
|
||
imagePreview.style.backgroundColor = 'white';
|
||
const overlay = imagePreview.querySelector('.drop-overlay');
|
||
if (overlay) overlay.style.display = 'none';
|
||
|
||
const files = e.dataTransfer.files;
|
||
if (files.length > 0) {
|
||
handleImageFile(files[0]);
|
||
}
|
||
});
|
||
|
||
// Click to select file
|
||
imagePreview.addEventListener('click', () => {
|
||
fileInput.click();
|
||
});
|
||
|
||
fileInput.addEventListener('change', (e) => {
|
||
if (e.target.files.length > 0) {
|
||
handleImageFile(e.target.files[0]);
|
||
}
|
||
});
|
||
|
||
// Alt text editor for floating menu
|
||
const altTextContainer = document.createElement('div');
|
||
altTextContainer.className = 'ui-edit-alt-text-container';
|
||
altTextContainer.style.cssText = `
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
`;
|
||
|
||
const altTextLabel = document.createElement('label');
|
||
altTextLabel.textContent = 'Alt Text Description:';
|
||
altTextLabel.style.cssText = `
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: #333;
|
||
margin: 0;
|
||
`;
|
||
|
||
const altTextInput = document.createElement('input');
|
||
altTextInput.type = 'text';
|
||
altTextInput.value = stagingState.currentAltText;
|
||
altTextInput.style.cssText = `
|
||
width: 100%;
|
||
padding: 10px 12px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 6px;
|
||
font-size: 14px;
|
||
box-sizing: border-box;
|
||
outline: none;
|
||
transition: border-color 0.2s ease;
|
||
`;
|
||
|
||
altTextInput.addEventListener('focus', () => {
|
||
altTextInput.style.borderColor = '#007bff';
|
||
});
|
||
|
||
altTextInput.addEventListener('blur', () => {
|
||
altTextInput.style.borderColor = '#ddd';
|
||
});
|
||
|
||
// Track alt text changes
|
||
altTextInput.addEventListener('input', () => {
|
||
stagingState.stagedAltText = altTextInput.value;
|
||
stagingState.hasChanges = altTextInput.value !== stagingState.currentAltText || stagingState.stagedImageSrc !== null;
|
||
updateChangeIndicator();
|
||
});
|
||
|
||
// Add keyboard shortcuts to alt text input
|
||
altTextInput.addEventListener('keydown', this.handleKeydown);
|
||
|
||
altTextContainer.appendChild(altTextLabel);
|
||
altTextContainer.appendChild(altTextInput);
|
||
|
||
// Change indicator for floating menu
|
||
const changeIndicator = document.createElement('div');
|
||
changeIndicator.className = 'change-indicator';
|
||
changeIndicator.style.cssText = `
|
||
padding: 8px 12px;
|
||
background: #fff3cd;
|
||
border: 1px solid #ffeaa7;
|
||
border-radius: 6px;
|
||
color: #856404;
|
||
font-size: 12px;
|
||
text-align: center;
|
||
display: none;
|
||
font-weight: 500;
|
||
`;
|
||
changeIndicator.textContent = '⚠️ You have unsaved changes';
|
||
|
||
const updateChangeIndicator = () => {
|
||
if (stagingState.hasChanges) {
|
||
changeIndicator.style.display = 'block';
|
||
} else {
|
||
changeIndicator.style.display = 'none';
|
||
}
|
||
};
|
||
|
||
// Responsive controls container with alt text
|
||
const editorControls = document.createElement('div');
|
||
editorControls.className = 'ui-edit-controls';
|
||
editorControls.style.cssText = `
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
min-width: 180px;
|
||
padding: 12px;
|
||
justify-content: space-between;
|
||
align-items: stretch;
|
||
`;
|
||
|
||
const acceptBtn = this.createButton('✓ Accept', 'ui-edit-accept', (e) => {
|
||
// Apply staged changes only when accept is clicked
|
||
if (stagingState.hasChanges) {
|
||
let newMarkdown = stagingState.originalMarkdown;
|
||
|
||
// Apply image source change if staged
|
||
if (stagingState.stagedImageSrc !== null) {
|
||
const currentImageMatch = newMarkdown.match(/!\[(.*?)\]\((.*?)\)/);
|
||
if (currentImageMatch) {
|
||
newMarkdown = newMarkdown.replace(
|
||
/!\[(.*?)\]\((.*?)\)/,
|
||
`![${currentImageMatch[1]}](${stagingState.stagedImageSrc})`
|
||
);
|
||
}
|
||
}
|
||
|
||
// Apply alt text change if staged
|
||
if (stagingState.stagedAltText !== null) {
|
||
newMarkdown = newMarkdown.replace(
|
||
/!\[(.*?)\]/,
|
||
`![${stagingState.stagedAltText}]`
|
||
);
|
||
}
|
||
|
||
// Update section with final changes
|
||
this.sectionManager.updateContent(sectionId, newMarkdown);
|
||
this.updateSectionContent(sectionId, newMarkdown);
|
||
}
|
||
|
||
// Accept changes and hide editor
|
||
this.sectionManager.acceptChanges(sectionId);
|
||
this.hideEditor(sectionId);
|
||
});
|
||
|
||
const cancelBtn = this.createButton('✗ Cancel', 'ui-edit-cancel', (e) => {
|
||
// Discard all staged changes and hide editor
|
||
this.sectionManager.cancelChanges(sectionId);
|
||
this.hideEditor(sectionId);
|
||
});
|
||
|
||
const resetBtn = this.createButton('↺ Reset', 'ui-edit-reset', (e) => {
|
||
// Reset section to original content (like reset all does)
|
||
this.sectionManager.resetSection(sectionId);
|
||
|
||
// Get the reset section to update staging state with original content
|
||
const resetSection = this.sectionManager.sections.get(sectionId);
|
||
if (resetSection) {
|
||
// Update DOM immediately to show reset content
|
||
this.updateSectionContent(sectionId, resetSection.currentMarkdown);
|
||
|
||
// Parse original image info from reset content
|
||
const originalImageMatch = resetSection.currentMarkdown.match(/!\[(.*?)\]\((.*?)\)/);
|
||
if (originalImageMatch) {
|
||
const [, originalAltText, originalImageSrc] = originalImageMatch;
|
||
|
||
// Update staging state to reflect original content
|
||
stagingState.originalMarkdown = resetSection.currentMarkdown;
|
||
stagingState.currentAltText = originalAltText;
|
||
stagingState.currentImageSrc = originalImageSrc;
|
||
}
|
||
}
|
||
|
||
// Clear any staged changes
|
||
stagingState.stagedImageSrc = null;
|
||
stagingState.stagedAltText = null;
|
||
stagingState.hasChanges = false;
|
||
|
||
// Reset alt text input to original
|
||
altTextInput.value = stagingState.currentAltText;
|
||
|
||
// Reset preview to original
|
||
updateImagePreview(stagingState.currentImageSrc, stagingState.currentAltText);
|
||
updateChangeIndicator();
|
||
});
|
||
|
||
// Assemble content for floating menu
|
||
editorContent.appendChild(imagePreview);
|
||
editorContent.appendChild(altTextContainer);
|
||
editorContent.appendChild(changeIndicator);
|
||
editorContent.appendChild(fileInput);
|
||
|
||
// Create controls for side panel layout
|
||
const controls = document.createElement('div');
|
||
controls.className = 'ui-edit-controls';
|
||
controls.style.cssText = `
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
width: 100%;
|
||
`;
|
||
|
||
// Style buttons for side panel layout
|
||
[acceptBtn, cancelBtn, resetBtn].forEach(btn => {
|
||
btn.style.cssText = `
|
||
padding: 8px 12px;
|
||
font-size: 12px;
|
||
border-radius: 6px;
|
||
border: none;
|
||
color: white;
|
||
cursor: pointer;
|
||
font-weight: 600;
|
||
transition: all 0.2s ease;
|
||
width: 100%;
|
||
text-align: center;
|
||
`;
|
||
});
|
||
|
||
acceptBtn.style.background = '#28a745';
|
||
cancelBtn.style.background = '#dc3545';
|
||
resetBtn.style.background = '#fd7e14';
|
||
|
||
controls.appendChild(acceptBtn);
|
||
controls.appendChild(cancelBtn);
|
||
controls.appendChild(resetBtn);
|
||
|
||
// Create unified floating menu using new component
|
||
this.currentFloatingMenu = new FloatingMenu(sectionId, 'image', this);
|
||
const floatingMenu = this.currentFloatingMenu.show(editorContent, controls);
|
||
if (!floatingMenu) return;
|
||
|
||
altTextInput.focus();
|
||
this.editingSections.add(sectionId);
|
||
}
|
||
|
||
/**
|
||
* Image manipulation methods
|
||
* Note: Image replacement is now integrated into the main image editor with drag & drop
|
||
*/
|
||
|
||
resizeImage(sectionId) {
|
||
const section = this.sectionManager.sections.get(sectionId);
|
||
const size = prompt('Enter image width (e.g., 300px, 50%, or leave empty for auto):', '');
|
||
if (size !== null) {
|
||
const imageMatch = section.currentMarkdown.match(/!\[(.*?)\]\((.*?)\)/);
|
||
if (imageMatch) {
|
||
const style = size ? ` style="width: ${size};"` : '';
|
||
const newMarkdown = section.currentMarkdown.replace(
|
||
/!\[(.*?)\]\((.*?)\)/,
|
||
`<img src="${imageMatch[2]}" alt="${imageMatch[1]}"${style}>`
|
||
);
|
||
this.sectionManager.updateContent(sectionId, newMarkdown);
|
||
}
|
||
}
|
||
}
|
||
|
||
addImageCaption(sectionId) {
|
||
const section = this.sectionManager.sections.get(sectionId);
|
||
const caption = prompt('Enter image caption:', '');
|
||
if (caption) {
|
||
const newMarkdown = section.currentMarkdown + `\n\n*${caption}*`;
|
||
this.sectionManager.updateContent(sectionId, newMarkdown);
|
||
}
|
||
}
|
||
|
||
removeImage(sectionId) {
|
||
if (confirm('Are you sure you want to remove this image?')) {
|
||
this.sectionManager.updateContent(sectionId, '');
|
||
this.hideEditor(sectionId);
|
||
}
|
||
}
|
||
|
||
createButton(text, className, handler) {
|
||
const btn = document.createElement('button');
|
||
btn.textContent = text;
|
||
btn.className = className;
|
||
btn.style.cssText = `
|
||
background: #007bff;
|
||
color: white;
|
||
border: none;
|
||
padding: 8px 16px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
`;
|
||
btn.addEventListener('click', handler);
|
||
return btn;
|
||
}
|
||
|
||
handleAccept(event) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
|
||
const sectionId = this.getCurrentEditingSectionId(event.target);
|
||
if (sectionId) {
|
||
this.sectionManager.acceptChanges(sectionId);
|
||
this.hideEditor(sectionId);
|
||
}
|
||
}
|
||
|
||
handleCancel(event) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
|
||
const sectionId = this.getCurrentEditingSectionId(event.target);
|
||
if (sectionId) {
|
||
this.sectionManager.cancelChanges(sectionId);
|
||
this.hideEditor(sectionId);
|
||
}
|
||
}
|
||
|
||
handleReset(event) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
|
||
const sectionId = this.getCurrentEditingSectionId(event.target);
|
||
if (sectionId) {
|
||
this.sectionManager.resetSection(sectionId);
|
||
|
||
// Update DOM to show reset content
|
||
const section = this.sectionManager.sections.get(sectionId);
|
||
if (section) {
|
||
this.updateSectionContent(sectionId, section.currentMarkdown);
|
||
}
|
||
|
||
this.hideEditor(sectionId);
|
||
}
|
||
}
|
||
|
||
handleKeydown(event) {
|
||
// Enhanced keyboard shortcuts for section editing
|
||
const textarea = event.target.closest('textarea, input');
|
||
if (!textarea) return;
|
||
|
||
// Find the section being edited
|
||
const editorContainer = textarea.closest('.ui-edit-editor-container, .ui-edit-image-editor-container');
|
||
if (!editorContainer) return;
|
||
|
||
const sectionElement = editorContainer.parentElement;
|
||
const sectionId = sectionElement ? sectionElement.getAttribute('data-section-id') : null;
|
||
if (!sectionId) return;
|
||
|
||
let shortcutKey = '';
|
||
let shortcutAction = '';
|
||
|
||
// Handle keyboard shortcuts
|
||
if (event.ctrlKey || event.metaKey) {
|
||
switch (event.key) {
|
||
case 'Enter':
|
||
event.preventDefault();
|
||
shortcutKey = (event.ctrlKey ? 'ctrl' : 'cmd') + '+enter';
|
||
shortcutAction = 'accept';
|
||
this.sectionManager.acceptChanges(sectionId);
|
||
debug('Keyboard shortcut: Ctrl+Enter - accepted changes for section ' + sectionId, 'KEYBOARD');
|
||
break;
|
||
case 'Escape':
|
||
event.preventDefault();
|
||
shortcutKey = (event.ctrlKey ? 'ctrl' : 'cmd') + '+escape';
|
||
shortcutAction = 'cancel';
|
||
this.sectionManager.cancelChanges(sectionId);
|
||
debug('Keyboard shortcut: Ctrl+Escape - cancelled changes for section ' + sectionId, 'KEYBOARD');
|
||
break;
|
||
case 's':
|
||
event.preventDefault();
|
||
shortcutKey = (event.ctrlKey ? 'ctrl' : 'cmd') + '+s';
|
||
shortcutAction = 'save';
|
||
this.sectionManager.acceptChanges(sectionId);
|
||
debug('Keyboard shortcut: Ctrl+S - saved changes for section ' + sectionId, 'KEYBOARD');
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Handle plain Escape (without Ctrl)
|
||
if (event.key === 'Escape' && !event.ctrlKey && !event.metaKey) {
|
||
event.preventDefault();
|
||
shortcutKey = 'escape';
|
||
shortcutAction = 'cancel';
|
||
this.sectionManager.cancelChanges(sectionId);
|
||
debug('Keyboard shortcut: Escape - cancelled changes for section ' + sectionId, 'KEYBOARD');
|
||
}
|
||
|
||
// Track keyboard shortcut events
|
||
if (shortcutKey && shortcutAction) {
|
||
this.trackEvent('keyboard-shortcut', {
|
||
sectionId,
|
||
shortcut: shortcutKey,
|
||
action: shortcutAction,
|
||
key: event.key,
|
||
ctrlKey: event.ctrlKey,
|
||
metaKey: event.metaKey,
|
||
event,
|
||
timestamp: Date.now()
|
||
});
|
||
}
|
||
}
|
||
|
||
getCurrentEditingSectionId(button) {
|
||
// Check if button is in a floating menu
|
||
const floatingMenu = button.closest('.ui-edit-floating-menu');
|
||
if (floatingMenu) {
|
||
return floatingMenu.dataset.sectionId;
|
||
}
|
||
|
||
// Fallback to old overlay container method
|
||
const editorContainer = button.closest('.ui-edit-editor-container, .ui-edit-image-editor-container, .ui-edit-overlay-container');
|
||
if (!editorContainer) return null;
|
||
|
||
const sectionElement = editorContainer.parentElement;
|
||
return sectionElement ? sectionElement.getAttribute('data-section-id') : null;
|
||
}
|
||
|
||
hideEditor(sectionId) {
|
||
const element = this.findSectionElement(sectionId);
|
||
|
||
// Remove floating menu if it exists
|
||
const floatingMenu = document.querySelector(`.ui-edit-floating-menu[data-section-id="${sectionId}"]`);
|
||
if (floatingMenu) {
|
||
floatingMenu.remove();
|
||
}
|
||
|
||
// Remove element highlighting
|
||
if (element) {
|
||
element.style.outline = '';
|
||
element.style.outlineOffset = '';
|
||
element.style.backgroundColor = '';
|
||
|
||
// Remove any old editor UI containers from the DOM
|
||
const textEditorContainer = element.querySelector('.ui-edit-editor-container');
|
||
if (textEditorContainer) {
|
||
textEditorContainer.remove();
|
||
}
|
||
|
||
const imageEditorContainer = element.querySelector('.ui-edit-image-editor-container');
|
||
if (imageEditorContainer) {
|
||
imageEditorContainer.remove();
|
||
}
|
||
|
||
// Remove overlay containers and restore original content if needed
|
||
const overlayContainer = element.querySelector('.ui-edit-overlay-container');
|
||
if (overlayContainer) {
|
||
const originalContent = overlayContainer.dataset.originalContent;
|
||
overlayContainer.remove();
|
||
|
||
// Restore element's original positioning
|
||
element.style.position = '';
|
||
|
||
// If original content was stored, restore it
|
||
if (originalContent) {
|
||
element.innerHTML = originalContent;
|
||
} else {
|
||
// Otherwise, refresh the section content
|
||
const section = this.sectionManager.sections.get(sectionId);
|
||
if (section) {
|
||
this.updateSectionContent(sectionId, section.currentMarkdown);
|
||
}
|
||
}
|
||
}
|
||
|
||
const section = this.sectionManager.sections.get(sectionId);
|
||
if (section) {
|
||
this.updateSectionContent(sectionId, section.currentMarkdown);
|
||
}
|
||
}
|
||
|
||
this.editingSections.delete(sectionId);
|
||
}
|
||
|
||
hideCurrentEditor() {
|
||
debug('EDITOR: hideCurrentEditor called', 'EDITOR');
|
||
|
||
// Hide FloatingMenu if it exists
|
||
if (this.currentFloatingMenu) {
|
||
debug('EDITOR: Hiding currentFloatingMenu', 'EDITOR');
|
||
this.currentFloatingMenu.hide();
|
||
this.currentFloatingMenu = null;
|
||
}
|
||
|
||
// Hide any legacy editors
|
||
this.editingSections.forEach(sectionId => {
|
||
const element = this.findSectionElement(sectionId);
|
||
if (element && element.querySelector('.ui-edit-editor-container')) {
|
||
this.hideEditor(sectionId);
|
||
}
|
||
});
|
||
|
||
// CRUCIAL FIX: Ensure ALL sections are reset from editing state
|
||
this.sectionManager.sections.forEach((section, sectionId) => {
|
||
if (section.isEditing()) {
|
||
debug('EDITOR: Force stopping editing for stuck section: ' + sectionId, 'EDITOR');
|
||
section.stopEditing();
|
||
}
|
||
});
|
||
|
||
// Clear the editing sections set
|
||
this.editingSections.clear();
|
||
|
||
// Clean up any floating menu elements
|
||
const floatingMenus = document.querySelectorAll('.ui-edit-floating-menu');
|
||
floatingMenus.forEach(menu => menu.remove());
|
||
|
||
debug('EDITOR: hideCurrentEditor completed', 'EDITOR');
|
||
}
|
||
|
||
updateSectionContent(sectionId, content) {
|
||
const element = this.findSectionElement(sectionId);
|
||
if (!element) return;
|
||
|
||
if (typeof marked !== 'undefined') {
|
||
const html = marked.parse(content);
|
||
const htmlWithTargetBlank = html.replace(/<a href="([^"]*)"([^>]*)>/g, '<a href="$1" target="_blank"$2>');
|
||
element.innerHTML = htmlWithTargetBlank;
|
||
} else {
|
||
element.innerHTML = `<p>${content}</p>`;
|
||
}
|
||
|
||
this.setupSectionElement(element);
|
||
}
|
||
|
||
findSectionElement(sectionId) {
|
||
return this.container.querySelector(`[data-section-id="${sectionId}"]`);
|
||
}
|
||
|
||
setupSectionElement(element) {
|
||
element.className = 'ui-edit-section';
|
||
element.draggable = true; // Enable drag and drop
|
||
element.tabIndex = 0; // Make focusable for accessibility
|
||
element.style.cssText = `
|
||
margin: 16px 0;
|
||
padding: 12px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: all 0.2s ease;
|
||
border: 2px solid transparent;
|
||
position: relative;
|
||
`;
|
||
|
||
// Add drag handle indicator
|
||
const dragHandle = document.createElement('div');
|
||
dragHandle.className = 'ui-edit-drag-handle';
|
||
dragHandle.innerHTML = '⋮⋮';
|
||
dragHandle.style.cssText = `
|
||
position: absolute;
|
||
left: -8px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
color: #ccc;
|
||
font-size: 16px;
|
||
line-height: 1;
|
||
cursor: grab;
|
||
opacity: 0;
|
||
transition: opacity 0.2s ease;
|
||
user-select: none;
|
||
`;
|
||
element.appendChild(dragHandle);
|
||
|
||
element.removeEventListener('mouseenter', element._mouseenterHandler);
|
||
element.removeEventListener('mouseleave', element._mouseleaveHandler);
|
||
|
||
element._mouseenterHandler = () => {
|
||
element.style.backgroundColor = 'rgba(0, 0, 0, 0.02)';
|
||
element.style.borderColor = 'rgba(0, 0, 0, 0.1)';
|
||
dragHandle.style.opacity = '1';
|
||
};
|
||
|
||
element._mouseleaveHandler = () => {
|
||
element.style.backgroundColor = '';
|
||
element.style.borderColor = 'transparent';
|
||
dragHandle.style.opacity = '0';
|
||
};
|
||
|
||
element.addEventListener('mouseenter', element._mouseenterHandler);
|
||
element.addEventListener('mouseleave', element._mouseleaveHandler);
|
||
|
||
// Enhanced accessibility
|
||
element.setAttribute('role', 'article');
|
||
element.setAttribute('aria-label', 'Editable section');
|
||
element.setAttribute('title', 'Click to edit, right-click for options, drag to reorder');
|
||
|
||
// Apply comprehensive styling enhancements
|
||
this.applyComprehensiveStyling(element);
|
||
}
|
||
|
||
/**
|
||
* Apply comprehensive styling enhancements to section elements
|
||
* @param {HTMLElement} element - The section element
|
||
*/
|
||
applyComprehensiveStyling(element) {
|
||
if (!element || !element.dataset.sectionId) return;
|
||
|
||
const sectionId = element.dataset.sectionId;
|
||
const section = this.sectionManager.sections.get(sectionId);
|
||
if (!section) return;
|
||
|
||
// Apply type-specific styling
|
||
this.applyTypeSpecificStyling(element, section);
|
||
|
||
// Apply state-based styling
|
||
this.applyStateStyling(element, section);
|
||
|
||
// Apply responsive design
|
||
this.applyResponsiveStyling(element);
|
||
|
||
// Apply accessibility enhancements
|
||
this.enhanceAccessibility(element, section);
|
||
|
||
// Apply length-based styling
|
||
this.applyLengthBasedStyling(element, section);
|
||
|
||
// Apply performance-optimized transitions
|
||
this.applyOptimizedTransitions(element);
|
||
|
||
// Apply CSS custom properties
|
||
this.applyCSSCustomProperties(element, section);
|
||
|
||
// Apply theme support
|
||
this.applySectionTheme(element, 'light'); // Default theme
|
||
|
||
// Apply content analysis styling
|
||
this.analyzeContentForStyling(element, section);
|
||
|
||
// Apply CSS reset and normalization
|
||
this.applyCSSReset(element);
|
||
|
||
// Setup animation support
|
||
this.setupAnimationSupport(element);
|
||
|
||
// Apply print-friendly styling
|
||
this.applyPrintStyling(element);
|
||
|
||
// Integrate with existing systems
|
||
this.integrateWithMessageSystem(element);
|
||
this.integrateWithControlPanel(element);
|
||
}
|
||
|
||
/**
|
||
* Apply type-specific styling to section elements
|
||
* @param {HTMLElement} element - Section element
|
||
* @param {Section} section - Section object
|
||
*/
|
||
applyTypeSpecificStyling(element, section) {
|
||
// Add base class
|
||
element.classList.add('markitect-section-editable');
|
||
|
||
// Add type-specific class
|
||
const typeClass = `markitect-section-${section.type || 'paragraph'}`;
|
||
element.classList.add(typeClass);
|
||
|
||
// Set data attributes
|
||
element.dataset.sectionType = section.type || 'paragraph';
|
||
element.dataset.sectionId = section.id;
|
||
|
||
// Type-specific styling
|
||
const typeStyles = {
|
||
heading: {
|
||
borderLeft: '4px solid #007acc',
|
||
backgroundColor: 'rgba(0, 122, 204, 0.02)',
|
||
fontWeight: '600'
|
||
},
|
||
code: {
|
||
borderLeft: '4px solid #28a745',
|
||
backgroundColor: 'rgba(40, 167, 69, 0.02)',
|
||
fontFamily: 'monospace'
|
||
},
|
||
list: {
|
||
borderLeft: '4px solid #ffc107',
|
||
backgroundColor: 'rgba(255, 193, 7, 0.02)'
|
||
},
|
||
quote: {
|
||
borderLeft: '4px solid #6f42c1',
|
||
backgroundColor: 'rgba(111, 66, 193, 0.02)',
|
||
fontStyle: 'italic'
|
||
},
|
||
image: {
|
||
borderLeft: '4px solid #fd7e14',
|
||
backgroundColor: 'rgba(253, 126, 20, 0.02)',
|
||
textAlign: 'center'
|
||
},
|
||
table: {
|
||
borderLeft: '4px solid #20c997',
|
||
backgroundColor: 'rgba(32, 201, 151, 0.02)'
|
||
},
|
||
hr: {
|
||
borderLeft: '4px solid #6c757d',
|
||
backgroundColor: 'rgba(108, 117, 125, 0.02)',
|
||
minHeight: '20px'
|
||
}
|
||
};
|
||
|
||
const styles = typeStyles[section.type] || typeStyles.paragraph || {};
|
||
Object.assign(element.style, styles);
|
||
}
|
||
|
||
/**
|
||
* Apply state-based styling
|
||
* @param {HTMLElement} element - Section element
|
||
* @param {Section} section - Section object
|
||
*/
|
||
applyStateStyling(element, section) {
|
||
// Remove existing state classes
|
||
element.classList.remove('section-original', 'section-editing', 'section-modified', 'section-saved');
|
||
|
||
// Apply current state class
|
||
if (section.isEditing()) {
|
||
element.classList.add('section-editing');
|
||
element.style.backgroundColor = 'rgba(0, 122, 204, 0.1)';
|
||
element.style.borderColor = '#007acc';
|
||
} else if (section.hasChanges()) {
|
||
element.classList.add('section-modified');
|
||
element.style.backgroundColor = 'rgba(255, 193, 7, 0.1)';
|
||
element.style.borderColor = '#ffc107';
|
||
} else if (section.state === 'saved') {
|
||
element.classList.add('section-saved');
|
||
element.style.backgroundColor = 'rgba(40, 167, 69, 0.1)';
|
||
element.style.borderColor = '#28a745';
|
||
} else {
|
||
element.classList.add('section-original');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Apply responsive design styling
|
||
* @param {HTMLElement} element - Section element
|
||
*/
|
||
applyResponsiveStyling(element) {
|
||
element.classList.add('section-responsive');
|
||
|
||
// Responsive styles
|
||
element.style.cssText += `
|
||
max-width: 100%;
|
||
min-width: 0;
|
||
overflow-wrap: break-word;
|
||
word-wrap: break-word;
|
||
`;
|
||
|
||
// Responsive behavior based on viewport
|
||
const updateResponsiveStyles = () => {
|
||
const width = window.innerWidth;
|
||
if (width < 768) {
|
||
// Mobile styles
|
||
element.style.margin = '8px 0';
|
||
element.style.padding = '8px';
|
||
element.style.fontSize = '14px';
|
||
} else if (width < 1024) {
|
||
// Tablet styles
|
||
element.style.margin = '12px 0';
|
||
element.style.padding = '10px';
|
||
element.style.fontSize = '15px';
|
||
} else {
|
||
// Desktop styles
|
||
element.style.margin = '16px 0';
|
||
element.style.padding = '12px';
|
||
element.style.fontSize = '16px';
|
||
}
|
||
};
|
||
|
||
updateResponsiveStyles();
|
||
window.addEventListener('resize', updateResponsiveStyles);
|
||
}
|
||
|
||
/**
|
||
* Enhance accessibility features
|
||
* @param {HTMLElement} element - Section element
|
||
* @param {Section} section - Section object
|
||
*/
|
||
enhanceAccessibility(element, section) {
|
||
// Enhanced ARIA attributes
|
||
element.setAttribute('aria-describedby', `section-desc-${section.id}`);
|
||
element.setAttribute('aria-labelledby', `section-label-${section.id}`);
|
||
|
||
// Screen reader support
|
||
const srOnly = document.createElement('span');
|
||
srOnly.className = 'sr-only';
|
||
srOnly.id = `section-desc-${section.id}`;
|
||
srOnly.textContent = `${section.type} section with ${section.currentMarkdown.length} characters`;
|
||
srOnly.style.cssText = `
|
||
position: absolute;
|
||
width: 1px;
|
||
height: 1px;
|
||
padding: 0;
|
||
margin: -1px;
|
||
overflow: hidden;
|
||
clip: rect(0,0,0,0);
|
||
white-space: nowrap;
|
||
border: 0;
|
||
`;
|
||
element.appendChild(srOnly);
|
||
|
||
// Enhanced keyboard navigation
|
||
element.tabIndex = 0;
|
||
element.setAttribute('aria-keyshortcuts', 'Enter Space');
|
||
|
||
// Focus enhancement
|
||
element.addEventListener('focus', () => {
|
||
element.style.outline = '2px solid #007acc';
|
||
element.style.outlineOffset = '2px';
|
||
});
|
||
|
||
element.addEventListener('blur', () => {
|
||
element.style.outline = '';
|
||
element.style.outlineOffset = '';
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Apply length-based visual indicators
|
||
* @param {HTMLElement} element - Section element
|
||
* @param {Section} section - Section object
|
||
*/
|
||
applyLengthBasedStyling(element, section) {
|
||
const length = section.currentMarkdown.length;
|
||
|
||
// Remove existing length classes
|
||
element.classList.remove('section-short', 'section-medium', 'section-long');
|
||
|
||
if (length < 100) {
|
||
element.classList.add('section-short');
|
||
element.style.minHeight = '40px';
|
||
} else if (length < 500) {
|
||
element.classList.add('section-medium');
|
||
element.style.minHeight = '60px';
|
||
} else {
|
||
element.classList.add('section-long');
|
||
element.style.minHeight = '80px';
|
||
element.style.maxHeight = '400px';
|
||
element.style.overflowY = 'auto';
|
||
}
|
||
|
||
// Word count indicator
|
||
const wordCount = section.currentMarkdown.split(/\s+/).length;
|
||
element.dataset.wordCount = wordCount.toString();
|
||
element.setAttribute('title', element.getAttribute('title') + ` (${wordCount} words)`);
|
||
}
|
||
|
||
/**
|
||
* Apply performance-optimized CSS transitions
|
||
* @param {HTMLElement} element - Section element
|
||
*/
|
||
applyOptimizedTransitions(element) {
|
||
// GPU-accelerated transitions
|
||
element.style.willChange = 'transform, opacity';
|
||
element.style.transform = 'translateZ(0)'; // Force GPU layer
|
||
element.style.transition = 'all 0.2s cubic-bezier(0.4, 0, 0.2, 1)';
|
||
|
||
// Hover enhancements
|
||
element.classList.add('section-hoverable');
|
||
|
||
const optimizedMouseEnter = () => {
|
||
element.style.transform = 'translateZ(0) scale(1.01)';
|
||
element.style.boxShadow = '0 4px 12px rgba(0, 0, 0, 0.1)';
|
||
};
|
||
|
||
const optimizedMouseLeave = () => {
|
||
element.style.transform = 'translateZ(0) scale(1)';
|
||
element.style.boxShadow = '';
|
||
};
|
||
|
||
element.addEventListener('mouseenter', optimizedMouseEnter);
|
||
element.addEventListener('mouseleave', optimizedMouseLeave);
|
||
}
|
||
|
||
/**
|
||
* Apply CSS custom properties for advanced styling
|
||
* @param {HTMLElement} element - Section element
|
||
* @param {Section} section - Section object
|
||
*/
|
||
applyCSSCustomProperties(element, section) {
|
||
// CSS variables for dynamic theming
|
||
element.style.setProperty('--section-primary-color', '#007acc');
|
||
element.style.setProperty('--section-background', 'rgba(248, 249, 250, 0.5)');
|
||
element.style.setProperty('--section-border-radius', '6px');
|
||
element.style.setProperty('--section-padding', '12px');
|
||
element.style.setProperty('--section-margin', '16px 0');
|
||
element.style.setProperty('--section-transition', 'all 0.2s ease');
|
||
|
||
// Type-specific CSS variables
|
||
const typeColors = {
|
||
heading: '#007acc',
|
||
code: '#28a745',
|
||
list: '#ffc107',
|
||
quote: '#6f42c1',
|
||
image: '#fd7e14',
|
||
table: '#20c997',
|
||
hr: '#6c757d'
|
||
};
|
||
|
||
element.style.setProperty('--section-type-color', typeColors[section.type] || '#6c757d');
|
||
element.dataset.cssVariables = 'true';
|
||
}
|
||
|
||
/**
|
||
* Apply theme-based styling
|
||
* @param {HTMLElement} element - Section element
|
||
* @param {string} theme - Theme name ('light', 'dark', 'high-contrast')
|
||
*/
|
||
applySectionTheme(element, theme) {
|
||
element.dataset.theme = theme;
|
||
element.classList.remove('theme-light', 'theme-dark', 'theme-high-contrast');
|
||
element.classList.add(`theme-${theme}`);
|
||
|
||
const themes = {
|
||
light: {
|
||
'--section-background': 'rgba(248, 249, 250, 0.5)',
|
||
'--section-text-color': '#212529',
|
||
'--section-border-color': 'rgba(0, 0, 0, 0.1)',
|
||
'--section-hover-bg': 'rgba(0, 0, 0, 0.02)'
|
||
},
|
||
dark: {
|
||
'--section-background': 'rgba(33, 37, 41, 0.8)',
|
||
'--section-text-color': '#f8f9fa',
|
||
'--section-border-color': 'rgba(255, 255, 255, 0.2)',
|
||
'--section-hover-bg': 'rgba(255, 255, 255, 0.05)'
|
||
},
|
||
'high-contrast': {
|
||
'--section-background': '#ffffff',
|
||
'--section-text-color': '#000000',
|
||
'--section-border-color': '#000000',
|
||
'--section-hover-bg': '#f0f0f0'
|
||
}
|
||
};
|
||
|
||
const themeStyles = themes[theme] || themes.light;
|
||
Object.entries(themeStyles).forEach(([property, value]) => {
|
||
element.style.setProperty(property, value);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Analyze content for styling purposes
|
||
* @param {HTMLElement} element - Section element
|
||
* @param {Section} section - Section object
|
||
*/
|
||
analyzeContentForStyling(element, section) {
|
||
const content = section.currentMarkdown.toLowerCase();
|
||
|
||
// Content-based classes
|
||
if (content.includes('```') || content.includes('`')) {
|
||
element.classList.add('contains-code');
|
||
}
|
||
if (content.includes('$$') || content.includes('\\(') || content.includes('\\[')) {
|
||
element.classList.add('contains-math');
|
||
}
|
||
if (content.includes('http') || content.includes('[') && content.includes(']')) {
|
||
element.classList.add('contains-links');
|
||
}
|
||
if (content.includes('![')) {
|
||
element.classList.add('contains-images');
|
||
}
|
||
if (content.includes('|') && content.includes('-')) {
|
||
element.classList.add('contains-tables');
|
||
}
|
||
|
||
// Priority content indicators
|
||
if (content.includes('important') || content.includes('warning') || content.includes('!')) {
|
||
element.classList.add('priority-high');
|
||
element.style.borderLeftWidth = '6px';
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Apply CSS reset and normalization
|
||
* @param {HTMLElement} element - Section element
|
||
*/
|
||
applyCSSReset(element) {
|
||
element.classList.add('css-reset');
|
||
|
||
// Normalize box model
|
||
element.style.boxSizing = 'border-box';
|
||
element.style.margin = '16px 0';
|
||
element.style.padding = '12px';
|
||
|
||
// Reset browser defaults
|
||
const allChildren = element.querySelectorAll('*');
|
||
allChildren.forEach(child => {
|
||
child.style.boxSizing = 'border-box';
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Setup animation support for state transitions
|
||
* @param {HTMLElement} element - Section element
|
||
*/
|
||
setupAnimationSupport(element) {
|
||
element.classList.add('animation-ready');
|
||
|
||
// Animation utility method
|
||
element._animate = (animationType) => {
|
||
this.animateSectionTransition(element, animationType);
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Animate section transitions
|
||
* @param {HTMLElement} element - Section element
|
||
* @param {string} animationType - Type of animation
|
||
*/
|
||
animateSectionTransition(element, animationType) {
|
||
element.classList.add('section-animating');
|
||
|
||
switch (animationType) {
|
||
case 'enter':
|
||
element.classList.add('transition-entering');
|
||
element.style.opacity = '0';
|
||
element.style.transform = 'translateY(-10px)';
|
||
setTimeout(() => {
|
||
element.style.opacity = '1';
|
||
element.style.transform = 'translateY(0)';
|
||
}, 50);
|
||
break;
|
||
case 'leave':
|
||
element.classList.add('transition-leaving');
|
||
element.style.opacity = '0';
|
||
element.style.transform = 'translateY(10px)';
|
||
break;
|
||
case 'highlight':
|
||
element.style.backgroundColor = 'rgba(255, 193, 7, 0.3)';
|
||
setTimeout(() => {
|
||
element.style.backgroundColor = '';
|
||
}, 1000);
|
||
break;
|
||
}
|
||
|
||
setTimeout(() => {
|
||
element.classList.remove('section-animating', 'transition-entering', 'transition-leaving');
|
||
}, 300);
|
||
}
|
||
|
||
/**
|
||
* Apply print-friendly styling
|
||
* @param {HTMLElement} element - Section element
|
||
*/
|
||
applyPrintStyling(element) {
|
||
element.classList.add('print-friendly');
|
||
element.dataset.printOptimized = 'true';
|
||
|
||
// Print-specific styles via CSS
|
||
const printStyles = document.createElement('style');
|
||
printStyles.textContent = `
|
||
@media print {
|
||
.print-friendly {
|
||
break-inside: avoid;
|
||
margin: 8px 0;
|
||
padding: 8px;
|
||
border: 1px solid #000;
|
||
background: white !important;
|
||
color: black !important;
|
||
box-shadow: none !important;
|
||
transform: none !important;
|
||
}
|
||
.ui-edit-drag-handle,
|
||
.sr-only {
|
||
display: none !important;
|
||
}
|
||
}
|
||
`;
|
||
if (!document.querySelector('#section-print-styles')) {
|
||
printStyles.id = 'section-print-styles';
|
||
document.head.appendChild(printStyles);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Update dynamic styles based on section state
|
||
* @param {HTMLElement} element - Section element
|
||
* @param {Object} newStyles - Style updates
|
||
*/
|
||
updateSectionDynamicStyles(element, newStyles) {
|
||
Object.entries(newStyles).forEach(([property, value]) => {
|
||
if (property.startsWith('--')) {
|
||
element.style.setProperty(property, value);
|
||
} else {
|
||
element.style[property] = value;
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Integrate with message system styling
|
||
* @param {HTMLElement} element - Section element
|
||
*/
|
||
integrateWithMessageSystem(element) {
|
||
// Add message system integration class
|
||
element.classList.add('message-system-ready');
|
||
|
||
// Store reference for message positioning
|
||
element.dataset.messageAnchor = 'true';
|
||
}
|
||
|
||
/**
|
||
* Integrate with control panel styling
|
||
* @param {HTMLElement} element - Section element
|
||
*/
|
||
integrateWithControlPanel(element) {
|
||
// Add control panel integration class
|
||
element.classList.add('control-panel-aware');
|
||
|
||
// Adjust positioning to avoid overlap with control panel
|
||
const adjustForControlPanel = () => {
|
||
const controlPanel = document.getElementById('markitect-global-controls');
|
||
if (controlPanel) {
|
||
const rect = controlPanel.getBoundingClientRect();
|
||
const elementRect = element.getBoundingClientRect();
|
||
|
||
// Avoid overlap with control panel
|
||
if (elementRect.right > rect.left && elementRect.top < rect.bottom) {
|
||
element.style.marginRight = `${rect.width + 20}px`;
|
||
}
|
||
}
|
||
};
|
||
|
||
// Check for overlap periodically
|
||
setTimeout(adjustForControlPanel, 100);
|
||
}
|
||
|
||
/**
|
||
* Setup auto-resize for textarea
|
||
* @param {HTMLTextAreaElement} textarea - The textarea element
|
||
*/
|
||
setupAutoResize(textarea) {
|
||
const autoResize = () => {
|
||
const transition = textarea.style.transition;
|
||
textarea.style.transition = 'none';
|
||
|
||
textarea.style.height = 'auto';
|
||
const contentHeight = textarea.scrollHeight;
|
||
const padding = 24;
|
||
|
||
const lineCount = textarea.value.split('\n').length;
|
||
const minHeight = Math.max(60, lineCount * 24 + padding);
|
||
const maxHeight = 360;
|
||
|
||
const newHeight = Math.max(60, Math.min(maxHeight, Math.max(minHeight, contentHeight + 4)));
|
||
textarea.style.height = newHeight + 'px';
|
||
|
||
textarea.style.transition = transition;
|
||
};
|
||
|
||
textarea.addEventListener('input', autoResize);
|
||
textarea.addEventListener('paste', () => setTimeout(autoResize, 10));
|
||
|
||
// Initial sizing
|
||
setTimeout(autoResize, 20);
|
||
}
|
||
|
||
/**
|
||
* Create status panel for real-time status display
|
||
* @returns {HTMLElement} Status panel element
|
||
*/
|
||
// createStatusPanel method removed - floating status panel no longer needed
|
||
// Status information is displayed in the control panel menu instead
|
||
|
||
/**
|
||
* Update status display with current status information
|
||
* @param {Object} status - Status object from SectionManager
|
||
*/
|
||
updateStatusDisplay(status) {
|
||
// Status information is now only displayed in the control panel menu
|
||
// No floating status panel needed since status is integrated into the menu
|
||
return;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Main Editor Integration
|
||
*/
|
||
class MarkitectCleanEditor {
|
||
constructor(markdownContent, containerElement, options = {}) {
|
||
debug('10: MarkitectCleanEditor constructor called', 'EDITOR');
|
||
|
||
this.options = {
|
||
theme: 'github',
|
||
keyboardShortcuts: true,
|
||
autosave: false,
|
||
originalFilename: null,
|
||
...options
|
||
};
|
||
|
||
debug('11: Creating SectionManager', 'EDITOR');
|
||
this.sectionManager = new SectionManager();
|
||
|
||
debug('12: Creating DOMRenderer', 'EDITOR');
|
||
this.domRenderer = new DOMRenderer(this.sectionManager, containerElement);
|
||
|
||
// Set global reference for debug panel
|
||
window.currentDOMRenderer = this.domRenderer;
|
||
|
||
// Initialize debug system for this page load
|
||
this.initializeDebugSystem();
|
||
|
||
this.originalMarkdown = markdownContent;
|
||
|
||
this.cleanMarkdownContent = options.cleanMarkdownContent || markdownContent;
|
||
this.dogtagContent = options.dogtagContent || '';
|
||
|
||
debug('13: About to call initialize()', 'EDITOR');
|
||
this.initialize();
|
||
debug('14: initialize() completed', 'EDITOR');
|
||
}
|
||
|
||
initialize() {
|
||
try {
|
||
debug('15: Starting initialize() - markdown length: ' + this.originalMarkdown.length, 'INIT');
|
||
|
||
const sections = this.sectionManager.createSectionsFromMarkdown(this.originalMarkdown);
|
||
debug('16: Created ' + sections.length + ' sections', 'INIT');
|
||
|
||
// Mark the dogtag section as protected if we have a dogtag
|
||
if (this.dogtagContent) {
|
||
debug('17: Marking dogtag section', 'INIT');
|
||
this.markDogtagSection(sections);
|
||
}
|
||
|
||
// Mark base64 reference sections as protected
|
||
debug('18: Marking base64 reference sections', 'INIT');
|
||
this.markBase64ReferenceSections(sections);
|
||
|
||
console.log(`✓ Initialized clean editor with ${sections.length} sections`);
|
||
|
||
// Add global control panel
|
||
debug('19: Adding global controls', 'INIT');
|
||
this.addGlobalControls();
|
||
|
||
// Setup status tracking
|
||
debug('19.5: Setting up status tracking', 'INIT');
|
||
this.setupStatusTracking();
|
||
|
||
debug('20: Initialize completed successfully', 'INIT');
|
||
return true;
|
||
} catch (error) {
|
||
debug('ERROR in initialize: ' + error.message, 'ERROR');
|
||
console.error('Failed to initialize clean editor:', error);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
markDogtagSection(sections) {
|
||
// Find the section that contains the dogtag content
|
||
const dogtagText = this.dogtagContent.trim();
|
||
if (!dogtagText) return;
|
||
|
||
for (const section of sections) {
|
||
if (section.currentMarkdown.includes(dogtagText)) {
|
||
section.isDogtagSection = true;
|
||
console.log('Marked dogtag section as protected:', section.id);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
markBase64ReferenceSections(sections) {
|
||
// Find sections that contain base64 image references
|
||
if (!window.markitectBase64References) return;
|
||
|
||
const refIds = Object.keys(window.markitectBase64References);
|
||
if (refIds.length === 0) return;
|
||
|
||
for (const section of sections) {
|
||
const markdown = section.currentMarkdown;
|
||
|
||
// Check if this section contains base64 reference syntax
|
||
for (const refId of refIds) {
|
||
if (markdown.includes(`[${refId}]:`)) {
|
||
section.isBase64RefSection = true;
|
||
console.log('Marked base64 reference section as protected:', section.id);
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
addGlobalControls() {
|
||
// Create a floating control panel
|
||
const controlPanel = document.createElement('div');
|
||
controlPanel.id = 'markitect-global-controls';
|
||
controlPanel.style.cssText = `
|
||
position: fixed;
|
||
top: 20px;
|
||
right: 20px;
|
||
background: rgba(248, 249, 250, 0.95);
|
||
border: 1px solid #dee2e6;
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||
z-index: 1000;
|
||
backdrop-filter: blur(8px);
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||
font-size: 14px;
|
||
min-width: 200px;
|
||
`;
|
||
|
||
const title = document.createElement('div');
|
||
title.style.cssText = `
|
||
font-weight: 600;
|
||
margin-bottom: 8px;
|
||
color: #495057;
|
||
border-bottom: 1px solid #dee2e6;
|
||
padding-bottom: 4px;
|
||
`;
|
||
title.textContent = 'Document Controls';
|
||
|
||
const buttonContainer = document.createElement('div');
|
||
buttonContainer.style.cssText = `
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
`;
|
||
|
||
// Save Document button
|
||
const saveButton = document.createElement('button');
|
||
saveButton.id = 'save-document';
|
||
saveButton.textContent = '💾 Save Document';
|
||
saveButton.style.cssText = `
|
||
background: #28a745;
|
||
color: white;
|
||
border: none;
|
||
padding: 8px 12px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
transition: background-color 0.2s;
|
||
`;
|
||
|
||
// Reset All button
|
||
const resetButton = document.createElement('button');
|
||
resetButton.id = 'reset-all';
|
||
resetButton.textContent = '🔄 Reset All';
|
||
resetButton.style.cssText = `
|
||
background: #ffc107;
|
||
color: #212529;
|
||
border: none;
|
||
padding: 8px 12px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
transition: background-color 0.2s;
|
||
`;
|
||
|
||
// Show Status button
|
||
const statusButton = document.createElement('button');
|
||
statusButton.id = 'show-status';
|
||
statusButton.textContent = '📊 Show Status';
|
||
statusButton.style.cssText = `
|
||
background: #17a2b8;
|
||
color: white;
|
||
border: none;
|
||
padding: 8px 12px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
transition: background-color 0.2s;
|
||
`;
|
||
|
||
// Debug button
|
||
const debugButton = document.createElement('button');
|
||
debugButton.id = 'toggle-debug';
|
||
debugButton.textContent = '🔍 Debug';
|
||
debugButton.style.cssText = `
|
||
background: #6c757d;
|
||
color: white;
|
||
border: none;
|
||
padding: 8px 12px;
|
||
border-radius: 4px;
|
||
margin-left: 8px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
transition: background-color 0.2s;
|
||
`;
|
||
|
||
buttonContainer.appendChild(saveButton);
|
||
buttonContainer.appendChild(resetButton);
|
||
buttonContainer.appendChild(statusButton);
|
||
buttonContainer.appendChild(debugButton);
|
||
|
||
// Debug messages container
|
||
const debugContainer = document.createElement('div');
|
||
debugContainer.id = 'debug-messages-container';
|
||
debugContainer.style.cssText = `
|
||
margin-top: 12px;
|
||
max-height: 300px;
|
||
overflow-y: auto;
|
||
border: 1px solid #dee2e6;
|
||
border-radius: 4px;
|
||
background: #f8f9fa;
|
||
padding: 8px;
|
||
font-family: 'Courier New', monospace;
|
||
font-size: 12px;
|
||
line-height: 1.4;
|
||
display: none;
|
||
`;
|
||
|
||
controlPanel.appendChild(title);
|
||
controlPanel.appendChild(buttonContainer);
|
||
controlPanel.appendChild(debugContainer);
|
||
|
||
document.body.appendChild(controlPanel);
|
||
|
||
// Add event listeners
|
||
document.getElementById('save-document').addEventListener('click', () => this.saveDocument());
|
||
document.getElementById('reset-all').addEventListener('click', () => this.resetAllSections());
|
||
document.getElementById('show-status').addEventListener('click', () => this.showDocumentStatus());
|
||
document.getElementById('toggle-debug').addEventListener('click', () => this.toggleDebugPanel());
|
||
|
||
// Store reference for enhanced control panel methods
|
||
this.controlPanel = controlPanel;
|
||
|
||
// Initialize enhanced features
|
||
this.setupControlPanelEnhancements();
|
||
}
|
||
|
||
/**
|
||
* Enhanced method to create floating control panel (for TDD compatibility)
|
||
* @returns {HTMLElement} The control panel element
|
||
*/
|
||
createFloatingControlPanel() {
|
||
// If control panel already exists, return it
|
||
if (this.controlPanel) {
|
||
return this.controlPanel;
|
||
}
|
||
|
||
// Create enhanced control panel
|
||
this.addGlobalControls();
|
||
return this.controlPanel;
|
||
}
|
||
|
||
/**
|
||
* Setup enhanced control panel features
|
||
*/
|
||
setupControlPanelEnhancements() {
|
||
if (!this.controlPanel) return;
|
||
|
||
// Add draggable functionality
|
||
this.makeControlPanelDraggable();
|
||
|
||
// Add collapsible functionality
|
||
this.addCollapsibleFeature();
|
||
|
||
// Add statistics display
|
||
this.addStatisticsDisplay();
|
||
|
||
// Setup keyboard shortcuts
|
||
this.setupControlPanelKeyboard();
|
||
|
||
// Apply responsive design
|
||
this.setupResponsiveControlPanel();
|
||
|
||
// Load user preferences
|
||
this.loadControlPanelPreferences();
|
||
|
||
// Setup animations
|
||
this.setupControlPanelAnimations();
|
||
|
||
// Apply default theme
|
||
this.setControlPanelTheme('light');
|
||
}
|
||
|
||
/**
|
||
* Make control panel draggable
|
||
*/
|
||
makeControlPanelDraggable() {
|
||
if (!this.controlPanel) return;
|
||
|
||
const title = this.controlPanel.querySelector('div');
|
||
if (title) {
|
||
title.style.cursor = 'move';
|
||
title.draggable = true;
|
||
|
||
let isDragging = false;
|
||
let startX, startY, initialX, initialY;
|
||
|
||
title.addEventListener('mousedown', (e) => {
|
||
isDragging = true;
|
||
startX = e.clientX;
|
||
startY = e.clientY;
|
||
initialX = this.controlPanel.offsetLeft;
|
||
initialY = this.controlPanel.offsetTop;
|
||
title.style.userSelect = 'none';
|
||
});
|
||
|
||
document.addEventListener('mousemove', (e) => {
|
||
if (!isDragging) return;
|
||
|
||
const deltaX = e.clientX - startX;
|
||
const deltaY = e.clientY - startY;
|
||
|
||
this.controlPanel.style.left = `${initialX + deltaX}px`;
|
||
this.controlPanel.style.top = `${initialY + deltaY}px`;
|
||
this.controlPanel.style.right = 'auto'; // Override right positioning
|
||
});
|
||
|
||
document.addEventListener('mouseup', () => {
|
||
if (isDragging) {
|
||
isDragging = false;
|
||
title.style.userSelect = '';
|
||
this.saveControlPanelPreferences();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Add collapsible/expandable functionality
|
||
*/
|
||
addCollapsibleFeature() {
|
||
if (!this.controlPanel) return;
|
||
|
||
const title = this.controlPanel.querySelector('div');
|
||
const buttonContainer = this.controlPanel.querySelector('div:last-child');
|
||
|
||
if (title && buttonContainer) {
|
||
// Add toggle button
|
||
const toggleBtn = document.createElement('span');
|
||
toggleBtn.textContent = '▼';
|
||
toggleBtn.className = 'panel-toggle';
|
||
toggleBtn.style.cssText = `
|
||
float: right;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
transition: transform 0.3s ease;
|
||
`;
|
||
|
||
title.appendChild(toggleBtn);
|
||
|
||
// Toggle functionality
|
||
let isCollapsed = false;
|
||
toggleBtn.addEventListener('click', () => {
|
||
this.toggleControlPanel();
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Toggle control panel collapsed state
|
||
*/
|
||
toggleControlPanel() {
|
||
if (!this.controlPanel) return;
|
||
|
||
const buttonContainer = this.controlPanel.querySelector('div:last-child');
|
||
const toggleBtn = this.controlPanel.querySelector('.panel-toggle');
|
||
|
||
if (buttonContainer && toggleBtn) {
|
||
const isCollapsed = buttonContainer.style.display === 'none';
|
||
|
||
if (isCollapsed) {
|
||
buttonContainer.style.display = 'flex';
|
||
toggleBtn.textContent = '▼';
|
||
toggleBtn.style.transform = 'rotate(0deg)';
|
||
this.controlPanel.classList.remove('collapsed');
|
||
} else {
|
||
buttonContainer.style.display = 'none';
|
||
toggleBtn.textContent = '▶';
|
||
toggleBtn.style.transform = 'rotate(-90deg)';
|
||
this.controlPanel.classList.add('collapsed');
|
||
}
|
||
|
||
this.saveControlPanelPreferences();
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Add real-time statistics display
|
||
*/
|
||
addStatisticsDisplay() {
|
||
if (!this.controlPanel) return;
|
||
|
||
const statsDiv = document.createElement('div');
|
||
statsDiv.className = 'control-panel-stats';
|
||
statsDiv.style.cssText = `
|
||
margin-top: 8px;
|
||
padding-top: 8px;
|
||
border-top: 1px solid #dee2e6;
|
||
font-size: 12px;
|
||
color: #6c757d;
|
||
`;
|
||
|
||
// Insert before button container
|
||
const buttonContainer = this.controlPanel.querySelector('div:last-child');
|
||
this.controlPanel.insertBefore(statsDiv, buttonContainer);
|
||
|
||
// Update stats periodically
|
||
this.updateControlPanelStats();
|
||
setInterval(() => this.updateControlPanelStats(), 3000);
|
||
}
|
||
|
||
/**
|
||
* Update control panel statistics
|
||
*/
|
||
updateControlPanelStats() {
|
||
const statsDiv = this.controlPanel?.querySelector('.control-panel-stats');
|
||
if (!statsDiv) return;
|
||
|
||
const status = this.sectionManager.getDocumentStatus();
|
||
const eventStats = this.domRenderer.getEventStats();
|
||
|
||
statsDiv.innerHTML = `
|
||
<div class="stat-item"><strong>${status.totalSections}</strong> sections</div>
|
||
<div class="stat-item"><strong>${status.editingSections}</strong> editing</div>
|
||
<div class="stat-item"><strong>${eventStats.totalEvents}</strong> events</div>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* Setup keyboard shortcuts for control panel
|
||
*/
|
||
setupControlPanelKeyboard() {
|
||
document.addEventListener('keydown', (e) => {
|
||
this.handleControlPanelKeyboard(e);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Handle control panel keyboard shortcuts
|
||
* @param {KeyboardEvent} e - Keyboard event
|
||
*/
|
||
handleControlPanelKeyboard(e) {
|
||
// Ctrl+P: Toggle panel
|
||
if (e.ctrlKey && e.key === 'p') {
|
||
e.preventDefault();
|
||
this.toggleControlPanel();
|
||
this.domRenderer.trackEvent('keyboard-shortcut', { shortcut: 'ctrl+p', action: 'toggle-panel' });
|
||
}
|
||
|
||
// Ctrl+S: Save document
|
||
if (e.ctrlKey && e.key === 's') {
|
||
e.preventDefault();
|
||
this.saveDocument();
|
||
this.domRenderer.trackEvent('keyboard-shortcut', { shortcut: 'ctrl+s', action: 'save' });
|
||
}
|
||
|
||
// Ctrl+Shift+S: Show status
|
||
if (e.ctrlKey && e.shiftKey && e.key === 'S') {
|
||
e.preventDefault();
|
||
this.showDocumentStatus();
|
||
this.domRenderer.trackEvent('keyboard-shortcut', { shortcut: 'ctrl+shift+s', action: 'status' });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Setup responsive design for control panel
|
||
*/
|
||
setupResponsiveControlPanel() {
|
||
this.adjustControlPanelForViewport(window.innerWidth);
|
||
|
||
window.addEventListener('resize', () => {
|
||
this.adjustControlPanelForViewport(window.innerWidth);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Adjust control panel for different viewport sizes
|
||
* @param {number} width - Viewport width
|
||
*/
|
||
adjustControlPanelForViewport(width) {
|
||
if (!this.controlPanel) return;
|
||
|
||
if (width < 768) {
|
||
// Mobile layout
|
||
this.controlPanel.style.cssText += `
|
||
top: 10px;
|
||
right: 10px;
|
||
left: auto;
|
||
min-width: 150px;
|
||
font-size: 12px;
|
||
`;
|
||
} else {
|
||
// Desktop layout
|
||
this.controlPanel.style.cssText += `
|
||
top: 20px;
|
||
right: 20px;
|
||
left: auto;
|
||
min-width: 200px;
|
||
font-size: 14px;
|
||
`;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Save control panel preferences to localStorage
|
||
*/
|
||
saveControlPanelPreferences() {
|
||
if (!this.controlPanel) return;
|
||
|
||
try {
|
||
const preferences = {
|
||
top: this.controlPanel.style.top,
|
||
left: this.controlPanel.style.left,
|
||
right: this.controlPanel.style.right,
|
||
collapsed: this.controlPanel.classList.contains('collapsed'),
|
||
theme: this.controlPanel.dataset.theme || 'light'
|
||
};
|
||
|
||
localStorage.setItem('markitect-control-panel-prefs', JSON.stringify(preferences));
|
||
} catch (error) {
|
||
console.warn('Could not save control panel preferences:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Load control panel preferences from localStorage
|
||
*/
|
||
loadControlPanelPreferences() {
|
||
if (!this.controlPanel) return;
|
||
|
||
try {
|
||
const saved = localStorage.getItem('markitect-control-panel-prefs');
|
||
if (saved) {
|
||
const preferences = JSON.parse(saved);
|
||
|
||
if (preferences.top) this.controlPanel.style.top = preferences.top;
|
||
if (preferences.left) this.controlPanel.style.left = preferences.left;
|
||
if (preferences.right) this.controlPanel.style.right = preferences.right;
|
||
|
||
if (preferences.collapsed) {
|
||
this.toggleControlPanel();
|
||
}
|
||
|
||
if (preferences.theme) {
|
||
this.setControlPanelTheme(preferences.theme);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.warn('Could not load control panel preferences:', error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Setup control panel animations
|
||
*/
|
||
setupControlPanelAnimations() {
|
||
if (!this.controlPanel) return;
|
||
|
||
this.controlPanel.style.transition = 'all 0.3s ease';
|
||
this.animateControlPanel('fadeIn');
|
||
}
|
||
|
||
/**
|
||
* Animate control panel
|
||
* @param {string} animation - Animation type
|
||
*/
|
||
animateControlPanel(animation) {
|
||
if (!this.controlPanel) return;
|
||
|
||
switch (animation) {
|
||
case 'fadeIn':
|
||
this.controlPanel.style.opacity = '0';
|
||
setTimeout(() => {
|
||
this.controlPanel.style.opacity = '1';
|
||
}, 100);
|
||
break;
|
||
case 'slideIn':
|
||
this.controlPanel.style.transform = 'translateX(100%)';
|
||
setTimeout(() => {
|
||
this.controlPanel.style.transform = 'translateX(0)';
|
||
}, 100);
|
||
break;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Set control panel theme
|
||
* @param {string} theme - Theme name ('light', 'dark')
|
||
*/
|
||
setControlPanelTheme(theme) {
|
||
if (!this.controlPanel) return;
|
||
|
||
this.controlPanel.dataset.theme = theme;
|
||
|
||
const themes = {
|
||
light: {
|
||
background: 'rgba(248, 249, 250, 0.95)',
|
||
border: '#dee2e6',
|
||
text: '#495057',
|
||
buttonColors: {
|
||
save: '#28a745',
|
||
reset: '#ffc107',
|
||
status: '#17a2b8'
|
||
}
|
||
},
|
||
dark: {
|
||
background: 'rgba(33, 37, 41, 0.95)',
|
||
border: '#495057',
|
||
text: '#f8f9fa',
|
||
buttonColors: {
|
||
save: '#198754',
|
||
reset: '#fd7e14',
|
||
status: '#0dcaf0'
|
||
}
|
||
}
|
||
};
|
||
|
||
const selectedTheme = themes[theme] || themes.light;
|
||
|
||
this.controlPanel.style.background = selectedTheme.background;
|
||
this.controlPanel.style.borderColor = selectedTheme.border;
|
||
this.controlPanel.style.color = selectedTheme.text;
|
||
|
||
// Update button colors
|
||
const buttons = this.controlPanel.querySelectorAll('button');
|
||
buttons.forEach((button, index) => {
|
||
const colors = Object.values(selectedTheme.buttonColors);
|
||
if (colors[index]) {
|
||
button.style.backgroundColor = colors[index];
|
||
}
|
||
});
|
||
|
||
this.saveControlPanelPreferences();
|
||
}
|
||
|
||
saveDocument() {
|
||
try {
|
||
const markdown = this.sectionManager.getDocumentMarkdown();
|
||
|
||
// Generate intelligent filename
|
||
const filename = this.generateSaveFilename();
|
||
|
||
// Create a download link
|
||
const blob = new Blob([markdown], { type: 'text/markdown' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
|
||
this.showMessage(`Document saved as ${filename}!`, 'success');
|
||
console.log('Document saved:', filename, markdown.length, 'characters');
|
||
} catch (error) {
|
||
this.showMessage('Failed to save document: ' + error.message, 'error');
|
||
console.error('Save failed:', error);
|
||
}
|
||
}
|
||
|
||
resetAllSections() {
|
||
if (!confirm('Reset all sections to their original content? This will lose all changes and cannot be undone.')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const sections = this.sectionManager.getAllSections();
|
||
sections.forEach(section => {
|
||
this.sectionManager.resetSection(section.id);
|
||
});
|
||
|
||
this.showMessage('All sections reset to original content', 'info');
|
||
console.log('All sections reset');
|
||
} catch (error) {
|
||
this.showMessage('Failed to reset sections: ' + error.message, 'error');
|
||
console.error('Reset failed:', error);
|
||
}
|
||
}
|
||
|
||
showStatus() {
|
||
const sections = this.sectionManager.getSectionStatus();
|
||
const totalSections = sections.length;
|
||
const editedSections = sections.filter(s => s.hasChanges).length;
|
||
const currentlyEditing = sections.filter(s => s.isEditing).length;
|
||
|
||
const statusHtml = `
|
||
<h3>Document Status</h3>
|
||
<p><strong>Total Sections:</strong> ${totalSections}</p>
|
||
<p><strong>Modified Sections:</strong> ${editedSections}</p>
|
||
<p><strong>Currently Editing:</strong> ${currentlyEditing}</p>
|
||
<hr>
|
||
<h4>Section Details:</h4>
|
||
<ul>
|
||
${sections.map(s => `
|
||
<li>
|
||
<strong>${s.id}</strong> (${s.type})
|
||
- ${s.state}
|
||
${s.hasChanges ? ' ✏️' : ''}
|
||
${s.isEditing ? ' 🖊️' : ''}
|
||
</li>
|
||
`).join('')}
|
||
</ul>
|
||
`;
|
||
|
||
this.showModal('Document Status', statusHtml);
|
||
}
|
||
|
||
showMessage(message, type = 'info', options = {}) {
|
||
// Enhanced professional message system with color-coded positioning
|
||
const {
|
||
position = 'top-center',
|
||
duration = 3000,
|
||
dismissible = true,
|
||
icon = true,
|
||
animation = true
|
||
} = options;
|
||
|
||
const messageDiv = document.createElement('div');
|
||
messageDiv.className = `markitect-message markitect-message-${type}`;
|
||
|
||
// Get positioning styles
|
||
const positionStyles = this.getMessagePositionStyles(position);
|
||
|
||
// Get color scheme for message type
|
||
const colors = this.getMessageColors(type);
|
||
|
||
// Create icon if enabled
|
||
const iconHtml = icon ? this.getMessageIcon(type) : '';
|
||
|
||
messageDiv.style.cssText = `
|
||
position: fixed;
|
||
${positionStyles}
|
||
background: ${colors.background};
|
||
color: ${colors.text};
|
||
border: 1px solid ${colors.border};
|
||
border-left: 4px solid ${colors.accent};
|
||
border-radius: 8px;
|
||
padding: 16px 20px;
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
z-index: 10001;
|
||
box-shadow: 0 8px 24px rgba(0,0,0,0.12), 0 4px 8px rgba(0,0,0,0.08);
|
||
backdrop-filter: blur(8px);
|
||
max-width: 400px;
|
||
min-width: 200px;
|
||
transform: ${animation ? 'translateY(-20px)' : 'none'};
|
||
opacity: ${animation ? '0' : '1'};
|
||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
cursor: ${dismissible ? 'pointer' : 'default'};
|
||
`;
|
||
|
||
// Set message content with icon
|
||
messageDiv.innerHTML = `
|
||
<div style="display: flex; align-items: flex-start; gap: 12px;">
|
||
${iconHtml}
|
||
<div style="flex: 1; line-height: 1.4;">
|
||
${message}
|
||
</div>
|
||
${dismissible ? '<div style="margin-left: auto; opacity: 0.7; font-size: 16px; line-height: 1;">×</div>' : ''}
|
||
</div>
|
||
`;
|
||
|
||
// Add to DOM and handle stacking
|
||
document.body.appendChild(messageDiv);
|
||
this.stackMessages();
|
||
|
||
// Animation entrance
|
||
if (animation) {
|
||
requestAnimationFrame(() => {
|
||
messageDiv.style.transform = 'translateY(0)';
|
||
messageDiv.style.opacity = '1';
|
||
});
|
||
}
|
||
|
||
// Auto-dismiss functionality
|
||
const autoRemove = () => {
|
||
if (messageDiv.parentNode) {
|
||
messageDiv.style.transform = 'translateY(-20px)';
|
||
messageDiv.style.opacity = '0';
|
||
setTimeout(() => {
|
||
if (messageDiv.parentNode) {
|
||
messageDiv.parentNode.removeChild(messageDiv);
|
||
this.stackMessages();
|
||
}
|
||
}, 300);
|
||
}
|
||
};
|
||
|
||
// Manual dismiss functionality
|
||
if (dismissible) {
|
||
messageDiv.addEventListener('click', autoRemove);
|
||
}
|
||
|
||
// Auto-dismiss timer
|
||
if (duration > 0) {
|
||
setTimeout(autoRemove, duration);
|
||
}
|
||
|
||
return messageDiv;
|
||
}
|
||
|
||
/**
|
||
* Get position styles for message positioning
|
||
* @param {string} position - Position identifier
|
||
* @returns {string} CSS positioning styles
|
||
*/
|
||
getMessagePositionStyles(position) {
|
||
const positions = {
|
||
'top-left': 'top: 20px; left: 20px;',
|
||
'top-center': 'top: 20px; left: 50%; transform: translateX(-50%);',
|
||
'top-right': 'top: 20px; right: 20px;',
|
||
'center': 'top: 50%; left: 50%; transform: translate(-50%, -50%);',
|
||
'bottom-left': 'bottom: 20px; left: 20px;',
|
||
'bottom-center': 'bottom: 20px; left: 50%; transform: translateX(-50%);',
|
||
'bottom-right': 'bottom: 20px; right: 20px;'
|
||
};
|
||
return positions[position] || positions['top-center'];
|
||
}
|
||
|
||
/**
|
||
* Get color scheme for message type
|
||
* @param {string} type - Message type
|
||
* @returns {Object} Color scheme object
|
||
*/
|
||
getMessageColors(type) {
|
||
const schemes = {
|
||
success: {
|
||
background: '#d4edda',
|
||
text: '#155724',
|
||
border: '#c3e6cb',
|
||
accent: '#28a745'
|
||
},
|
||
error: {
|
||
background: '#f8d7da',
|
||
text: '#721c24',
|
||
border: '#f5c6cb',
|
||
accent: '#dc3545'
|
||
},
|
||
warning: {
|
||
background: '#fff3cd',
|
||
text: '#856404',
|
||
border: '#ffeaa7',
|
||
accent: '#ffc107'
|
||
},
|
||
info: {
|
||
background: '#d1ecf1',
|
||
text: '#0c5460',
|
||
border: '#bee5eb',
|
||
accent: '#17a2b8'
|
||
},
|
||
debug: {
|
||
background: '#e2e3e5',
|
||
text: '#383d41',
|
||
border: '#d6d8db',
|
||
accent: '#6c757d'
|
||
}
|
||
};
|
||
return schemes[type] || schemes.info;
|
||
}
|
||
|
||
/**
|
||
* Get icon HTML for message type
|
||
* @param {string} type - Message type
|
||
* @returns {string} Icon HTML
|
||
*/
|
||
getMessageIcon(type) {
|
||
const icons = {
|
||
success: '<div style="color: #28a745; font-size: 18px; font-weight: bold;">✓</div>',
|
||
error: '<div style="color: #dc3545; font-size: 18px; font-weight: bold;">✕</div>',
|
||
warning: '<div style="color: #ffc107; font-size: 18px; font-weight: bold;">⚠</div>',
|
||
info: '<div style="color: #17a2b8; font-size: 18px; font-weight: bold;">ℹ</div>',
|
||
debug: '<div style="color: #6c757d; font-size: 18px; font-weight: bold;">🐛</div>'
|
||
};
|
||
return icons[type] || icons.info;
|
||
}
|
||
|
||
/**
|
||
* Stack messages to prevent overlap
|
||
*/
|
||
stackMessages() {
|
||
const messages = Array.from(document.querySelectorAll('.markitect-message')).filter(el =>
|
||
el.style.display !== 'none' && el.parentNode
|
||
);
|
||
|
||
// Group messages by position
|
||
const messageGroups = {
|
||
'top': [],
|
||
'center': [],
|
||
'bottom': []
|
||
};
|
||
|
||
messages.forEach(msg => {
|
||
const styles = msg.style;
|
||
if (styles.top && styles.top !== 'auto' && styles.top !== '') {
|
||
messageGroups.top.push(msg);
|
||
} else if (styles.bottom && styles.bottom !== 'auto' && styles.bottom !== '') {
|
||
messageGroups.bottom.push(msg);
|
||
} else {
|
||
messageGroups.center.push(msg);
|
||
}
|
||
});
|
||
|
||
// Stack top messages downward
|
||
let topOffset = 20;
|
||
messageGroups.top.forEach(msg => {
|
||
msg.style.top = `${topOffset}px`;
|
||
topOffset += msg.offsetHeight + 12;
|
||
});
|
||
|
||
// Stack bottom messages upward
|
||
let bottomOffset = 20;
|
||
messageGroups.bottom.forEach(msg => {
|
||
msg.style.bottom = `${bottomOffset}px`;
|
||
bottomOffset += msg.offsetHeight + 12;
|
||
});
|
||
}
|
||
|
||
showModal(title, content) {
|
||
// Remove existing modal if present
|
||
const existingModal = document.getElementById('markitect-modal');
|
||
if (existingModal) {
|
||
existingModal.remove();
|
||
}
|
||
|
||
const modalOverlay = document.createElement('div');
|
||
modalOverlay.id = 'markitect-modal';
|
||
modalOverlay.style.cssText = `
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 10000;
|
||
`;
|
||
|
||
const modal = document.createElement('div');
|
||
modal.style.cssText = `
|
||
background: white;
|
||
border-radius: 8px;
|
||
padding: 24px;
|
||
max-width: 600px;
|
||
max-height: 80vh;
|
||
overflow-y: auto;
|
||
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
|
||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||
`;
|
||
|
||
const closeBtn = document.createElement('button');
|
||
closeBtn.textContent = '×';
|
||
closeBtn.style.cssText = `
|
||
float: right;
|
||
background: none;
|
||
border: none;
|
||
font-size: 24px;
|
||
cursor: pointer;
|
||
color: #6c757d;
|
||
margin: -8px -8px 0 0;
|
||
`;
|
||
|
||
const modalContent = document.createElement('div');
|
||
modalContent.innerHTML = `<h2>${title}</h2>${content}`;
|
||
|
||
function closeModal() {
|
||
modalOverlay.remove();
|
||
}
|
||
|
||
closeBtn.addEventListener('click', closeModal);
|
||
modalOverlay.addEventListener('click', (e) => {
|
||
if (e.target === modalOverlay) closeModal();
|
||
});
|
||
|
||
modal.appendChild(closeBtn);
|
||
modal.appendChild(modalContent);
|
||
modalOverlay.appendChild(modal);
|
||
document.body.appendChild(modalOverlay);
|
||
}
|
||
|
||
/**
|
||
* Show comprehensive document status dialog with detailed statistics
|
||
*/
|
||
showDocumentStatus() {
|
||
const status = this.sectionManager.getDocumentStatus();
|
||
const eventStats = this.domRenderer.getEventStats();
|
||
const sections = this.sectionManager.getAllSections();
|
||
|
||
// Calculate additional statistics
|
||
const sectionTypes = {};
|
||
const sectionSizes = { small: 0, medium: 0, large: 0 };
|
||
let totalCharacters = 0;
|
||
let averageLength = 0;
|
||
|
||
sections.forEach(section => {
|
||
const type = section.type || 'paragraph';
|
||
sectionTypes[type] = (sectionTypes[type] || 0) + 1;
|
||
|
||
const length = section.currentMarkdown.length;
|
||
totalCharacters += length;
|
||
|
||
if (length < 100) sectionSizes.small++;
|
||
else if (length < 500) sectionSizes.medium++;
|
||
else sectionSizes.large++;
|
||
});
|
||
|
||
if (sections.length > 0) {
|
||
averageLength = Math.round(totalCharacters / sections.length);
|
||
}
|
||
|
||
// Create comprehensive status HTML
|
||
const statusHtml = `
|
||
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; line-height: 1.6;">
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 24px;">
|
||
<!-- Document Overview -->
|
||
<div style="background: #f8f9fa; padding: 16px; border-radius: 8px; border-left: 4px solid #007acc;">
|
||
<h3 style="margin: 0 0 12px 0; color: #007acc; font-size: 16px;">📄 Document Overview</h3>
|
||
<div style="display: grid; gap: 8px;">
|
||
<div><strong>Total Sections:</strong> ${status.totalSections}</div>
|
||
<div><strong>Total Characters:</strong> ${totalCharacters.toLocaleString()}</div>
|
||
<div><strong>Average Section Length:</strong> ${averageLength} chars</div>
|
||
<div><strong>Document Status:</strong>
|
||
<span style="color: ${status.hasUnsavedChanges ? '#f39c12' : '#27ae60'}; font-weight: bold;">
|
||
${status.hasUnsavedChanges ? '⚠️ Has Changes' : '✅ All Saved'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Section States -->
|
||
<div style="background: #f8f9fa; padding: 16px; border-radius: 8px; border-left: 4px solid #27ae60;">
|
||
<h3 style="margin: 0 0 12px 0; color: #27ae60; font-size: 16px;">📝 Section States</h3>
|
||
<div style="display: grid; gap: 8px;">
|
||
<div><strong>Currently Editing:</strong> ${status.editingSections} sections</div>
|
||
<div><strong>Modified (Unsaved):</strong> ${status.modifiedSections} sections</div>
|
||
<div><strong>Saved:</strong> ${status.savedSections} sections</div>
|
||
<div><strong>Original:</strong> ${status.totalSections - status.modifiedSections - status.savedSections} sections</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-bottom: 24px;">
|
||
<!-- Section Types -->
|
||
<div style="background: #f8f9fa; padding: 16px; border-radius: 8px; border-left: 4px solid #8e44ad;">
|
||
<h3 style="margin: 0 0 12px 0; color: #8e44ad; font-size: 16px;">🏷️ Section Types</h3>
|
||
<div style="display: grid; gap: 6px; font-size: 14px;">
|
||
${Object.entries(sectionTypes).map(([type, count]) =>
|
||
`<div><strong>${type.charAt(0).toUpperCase() + type.slice(1)}:</strong> ${count}</div>`
|
||
).join('')}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Section Sizes -->
|
||
<div style="background: #f8f9fa; padding: 16px; border-radius: 8px; border-left: 4px solid #e67e22;">
|
||
<h3 style="margin: 0 0 12px 0; color: #e67e22; font-size: 16px;">📏 Section Sizes</h3>
|
||
<div style="display: grid; gap: 6px; font-size: 14px;">
|
||
<div><strong>Small (<100 chars):</strong> ${sectionSizes.small}</div>
|
||
<div><strong>Medium (100-500 chars):</strong> ${sectionSizes.medium}</div>
|
||
<div><strong>Large (>500 chars):</strong> ${sectionSizes.large}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Event Statistics -->
|
||
<div style="background: #f8f9fa; padding: 16px; border-radius: 8px; border-left: 4px solid #3498db; margin-bottom: 20px;">
|
||
<h3 style="margin: 0 0 12px 0; color: #3498db; font-size: 16px;">⚡ Event Statistics</h3>
|
||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; font-size: 14px;">
|
||
<div><strong>Total Events:</strong> ${eventStats.totalEvents}</div>
|
||
<div><strong>Section Clicks:</strong> ${eventStats.stats['section-click'] || 0}</div>
|
||
<div><strong>Hover Events:</strong> ${(eventStats.stats['section-hover-enter'] || 0) + (eventStats.stats['section-hover-leave'] || 0)}</div>
|
||
<div><strong>Keyboard Shortcuts:</strong> ${eventStats.stats['keyboard-shortcut'] || 0}</div>
|
||
<div><strong>Context Menus:</strong> ${eventStats.stats['section-context-menu'] || 0}</div>
|
||
<div><strong>Drag Events:</strong> ${(eventStats.stats['section-drag-start'] || 0) + (eventStats.stats['section-drag-over'] || 0)}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Recent Activity -->
|
||
${eventStats.recentEvents.length > 0 ? `
|
||
<div style="background: #f8f9fa; padding: 16px; border-radius: 8px; border-left: 4px solid #9b59b6;">
|
||
<h3 style="margin: 0 0 12px 0; color: #9b59b6; font-size: 16px;">🕒 Recent Activity (Last 10 Events)</h3>
|
||
<div style="max-height: 200px; overflow-y: auto;">
|
||
${eventStats.recentEvents.slice(-10).reverse().map(event => `
|
||
<div style="padding: 6px; margin: 4px 0; background: white; border-radius: 4px; font-size: 13px; border-left: 3px solid #ddd;">
|
||
<strong>${event.type}</strong> - ${event.timestamp}
|
||
${event.data.sectionId ? `<br/><span style="color: #666;">Section: ${event.data.sectionId.substring(0, 12)}...</span>` : ''}
|
||
${event.data.shortcut ? `<br/><span style="color: #666;">Shortcut: ${event.data.shortcut}</span>` : ''}
|
||
</div>
|
||
`).join('')}
|
||
</div>
|
||
</div>
|
||
` : ''}
|
||
|
||
<!-- Section Details Table -->
|
||
<div style="margin-top: 20px;">
|
||
<h3 style="margin: 0 0 12px 0; color: #2c3e50; font-size: 16px;">📋 Section Details</h3>
|
||
<div style="max-height: 300px; overflow-y: auto; border: 1px solid #ddd; border-radius: 4px;">
|
||
<table style="width: 100%; border-collapse: collapse; font-size: 13px;">
|
||
<thead style="background: #34495e; color: white; position: sticky; top: 0;">
|
||
<tr>
|
||
<th style="padding: 8px; text-align: left; border-bottom: 1px solid #ddd;">Section</th>
|
||
<th style="padding: 8px; text-align: left; border-bottom: 1px solid #ddd;">Type</th>
|
||
<th style="padding: 8px; text-align: left; border-bottom: 1px solid #ddd;">State</th>
|
||
<th style="padding: 8px; text-align: left; border-bottom: 1px solid #ddd;">Length</th>
|
||
<th style="padding: 8px; text-align: left; border-bottom: 1px solid #ddd;">Changes</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
${sections.map((section, index) => {
|
||
const hasChanges = section.editingMarkdown !== section.currentMarkdown;
|
||
const stateColor = section.state === 'editing' ? '#3498db' :
|
||
section.state === 'modified' ? '#f39c12' :
|
||
section.state === 'saved' ? '#27ae60' : '#95a5a6';
|
||
const preview = section.currentMarkdown.substring(0, 40).replace(/\n/g, ' ') +
|
||
(section.currentMarkdown.length > 40 ? '...' : '');
|
||
|
||
return `
|
||
<tr style="border-bottom: 1px solid #eee;">
|
||
<td style="padding: 8px; max-width: 200px; overflow: hidden; text-overflow: ellipsis;" title="${section.currentMarkdown.substring(0, 100)}">
|
||
${preview}
|
||
</td>
|
||
<td style="padding: 8px;">
|
||
<span style="background: #ecf0f1; padding: 2px 6px; border-radius: 3px; font-size: 11px;">
|
||
${section.type || 'paragraph'}
|
||
</span>
|
||
</td>
|
||
<td style="padding: 8px;">
|
||
<span style="color: ${stateColor}; font-weight: bold;">
|
||
${section.state || 'original'}
|
||
</span>
|
||
</td>
|
||
<td style="padding: 8px;">${section.currentMarkdown.length} chars</td>
|
||
<td style="padding: 8px; color: ${hasChanges ? '#e74c3c' : '#27ae60'};">
|
||
${hasChanges ? '●' : '○'}
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('')}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
this.showModal('📊 Comprehensive Document Status', statusHtml);
|
||
}
|
||
|
||
/**
|
||
* Toggle the debug panel on/off
|
||
*/
|
||
toggleDebugPanel() {
|
||
console.log('toggleDebugPanel called');
|
||
const debugContainer = document.getElementById('debug-messages-container');
|
||
const debugButton = document.getElementById('toggle-debug');
|
||
|
||
console.log('Elements found:', {
|
||
debugContainer: !!debugContainer,
|
||
debugButton: !!debugButton
|
||
});
|
||
|
||
if (!debugContainer || !debugButton) return;
|
||
|
||
if (debugPanelActive) {
|
||
// Hide debug panel
|
||
console.log('Hiding debug panel');
|
||
debugContainer.style.display = 'none';
|
||
debugButton.textContent = '🔍 Debug';
|
||
debugButton.style.background = '#6c757d';
|
||
debugPanelActive = false;
|
||
} else {
|
||
// Show debug panel
|
||
console.log('Showing debug panel');
|
||
debugContainer.style.display = 'block';
|
||
debugButton.textContent = '🔍 Debug (ON)';
|
||
debugButton.style.background = '#28a745';
|
||
debugPanelActive = true;
|
||
console.log('About to call this.updateDebugPanel()');
|
||
this.updateDebugPanel();
|
||
}
|
||
console.log('debugPanelActive is now:', debugPanelActive);
|
||
}
|
||
|
||
/**
|
||
* Update the debug panel with current messages
|
||
*/
|
||
updateDebugPanel() {
|
||
const debugContainer = document.getElementById('debug-messages-container');
|
||
if (!debugContainer || !debugPanelActive) {
|
||
console.log('updateDebugPanel skipped:', {
|
||
hasContainer: !!debugContainer,
|
||
debugPanelActive
|
||
});
|
||
return;
|
||
}
|
||
|
||
console.log('updateDebugPanel running, messages count:', debugMessages.length);
|
||
|
||
if (debugMessages.length === 0) {
|
||
debugContainer.innerHTML = '<div style="color: #6c757d; font-style: italic; padding: 12px;">No debug messages yet. Click sections to generate debug output.</div>';
|
||
return;
|
||
}
|
||
|
||
// Show the last 50 messages in reverse order (newest first)
|
||
const recentMessages = debugMessages.slice(-50).reverse();
|
||
|
||
const messagesHtml = recentMessages.map(msg => {
|
||
const categoryColor = {
|
||
'INFO': '#17a2b8',
|
||
'WARNING': '#ffc107',
|
||
'ERROR': '#dc3545',
|
||
'SUCCESS': '#28a745',
|
||
'DEBUG': '#6f42c1'
|
||
}[msg.category] || '#6c757d';
|
||
|
||
return `
|
||
<div style="margin-bottom: 6px; padding: 4px; border-left: 3px solid ${categoryColor}; background: white; border-radius: 2px;">
|
||
<span style="color: #6c757d; font-size: 11px;">[${msg.timestamp}]</span>
|
||
<span style="color: ${categoryColor}; font-weight: bold;">${msg.category}:</span>
|
||
<span style="color: #333;">${msg.message}</span>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
debugContainer.innerHTML = `
|
||
<div style="margin-bottom: 8px; padding: 6px; background: #e9ecef; border-radius: 4px; font-weight: bold; color: #495057;">
|
||
Debug Messages (${debugMessages.length} total, showing last ${recentMessages.length})
|
||
<button id="debug-clear-btn" style="float: right; background: #dc3545; color: white; border: none; padding: 2px 6px; border-radius: 2px; font-size: 11px; cursor: pointer;">Clear</button>
|
||
</div>
|
||
<div style="max-height: 250px; overflow-y: auto;">
|
||
${messagesHtml}
|
||
</div>
|
||
`;
|
||
|
||
// Add event listener for clear button
|
||
const clearBtn = debugContainer.querySelector('#debug-clear-btn');
|
||
if (clearBtn) {
|
||
clearBtn.addEventListener('click', () => {
|
||
console.log('Clear button clicked'); // Debug log
|
||
window.clearDebugMessages();
|
||
});
|
||
}
|
||
|
||
// Auto-scroll to bottom to show newest messages
|
||
const scrollContainer = debugContainer.querySelector('div[style*="overflow-y"]');
|
||
if (scrollContainer) {
|
||
scrollContainer.scrollTop = scrollContainer.scrollHeight;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Initialize the debug system for this page load
|
||
*/
|
||
initializeDebugSystem() {
|
||
// Clear any debug messages from rendering time
|
||
debugMessages = [];
|
||
debugPanelActive = false;
|
||
|
||
console.log('Debug system initialized - messages cleared for new session');
|
||
|
||
// Add a welcome message to show the system is working
|
||
debug('initializeDebugSystem: Debug system ready for new session', 'INFO');
|
||
}
|
||
|
||
getDocumentMarkdown() {
|
||
return this.sectionManager.getDocumentMarkdown();
|
||
}
|
||
|
||
escapeRegex(str) {
|
||
return str.replace(/[.*+?^${}()|[]\]/g, '\\\\$&');
|
||
}
|
||
|
||
convertDataUrlToReference(markdown) {
|
||
if (!window.markitectBase64References) {
|
||
return markdown;
|
||
}
|
||
|
||
let convertedMarkdown = markdown;
|
||
|
||
Object.entries(window.markitectBase64References).forEach(([refId, refData]) => {
|
||
const dataUrl = refData.full_data_url;
|
||
const escapedDataUrl = this.escapeRegex(dataUrl);
|
||
const dataUrlPattern = new RegExp(`!\\[([^\\]]*)\\]\\(${escapedDataUrl}\\)`, 'g');
|
||
convertedMarkdown = convertedMarkdown.replace(dataUrlPattern, `![$1][${refId}]`);
|
||
});
|
||
|
||
return convertedMarkdown;
|
||
}
|
||
|
||
/**
|
||
* Setup real-time status tracking
|
||
*/
|
||
setupStatusTracking() {
|
||
// Listen for status updates from SectionManager
|
||
this.sectionManager.on('status-updated', (status) => {
|
||
this.domRenderer.updateStatusDisplay(status);
|
||
});
|
||
|
||
// Start periodic status tracking
|
||
this.sectionManager.startStatusTracking(2000); // Update every 2 seconds
|
||
|
||
// Initial status display
|
||
this.sectionManager.updateGlobalStatus();
|
||
|
||
console.log('✓ Real-time status tracking initialized');
|
||
}
|
||
|
||
/**
|
||
* Generate intelligent save filename using 4-method fallback system
|
||
* @returns {string} Generated filename with .md extension
|
||
*/
|
||
generateSaveFilename() {
|
||
// Method 1: Original filename from options
|
||
if (this.options.originalFilename) {
|
||
return this.sanitizeFilename(this.options.originalFilename);
|
||
}
|
||
|
||
// Method 2: Page title extraction
|
||
const titleFilename = this.extractFilenameFromTitle();
|
||
if (titleFilename) {
|
||
return this.sanitizeFilename(titleFilename + '.md');
|
||
}
|
||
|
||
// Method 3: URL pathname analysis
|
||
const urlFilename = this.extractFilenameFromUrl();
|
||
if (urlFilename) {
|
||
return this.sanitizeFilename(urlFilename + '.md');
|
||
}
|
||
|
||
// Method 4: First heading extraction
|
||
const headingFilename = this.extractFilenameFromHeading();
|
||
if (headingFilename) {
|
||
return this.sanitizeFilename(headingFilename + '.md');
|
||
}
|
||
|
||
// Method 5: Timestamp generation (fallback)
|
||
return this.generateTimestampFilename();
|
||
}
|
||
|
||
/**
|
||
* Sanitize filename to be filesystem-safe
|
||
* @param {string} filename - Raw filename
|
||
* @returns {string} Sanitized filename
|
||
*/
|
||
sanitizeFilename(filename) {
|
||
if (!filename) return 'document.md';
|
||
|
||
// Remove or replace filesystem-unsafe characters
|
||
let sanitized = filename
|
||
.replace(/[\/\\:*?"<>|]/g, '-') // Replace unsafe chars with dashes
|
||
.replace(/\s+/g, '-') // Replace spaces with dashes
|
||
.replace(/-+/g, '-') // Replace multiple dashes with single dash
|
||
.replace(/^-|-$/g, '') // Remove leading/trailing dashes
|
||
.trim();
|
||
|
||
// Ensure it ends with .md
|
||
if (!sanitized.endsWith('.md')) {
|
||
sanitized += '.md';
|
||
}
|
||
|
||
// Ensure it's not empty
|
||
if (sanitized === '.md') {
|
||
sanitized = 'document.md';
|
||
}
|
||
|
||
return sanitized;
|
||
}
|
||
|
||
/**
|
||
* Extract filename from page title
|
||
* @returns {string|null} Filename or null if not suitable
|
||
*/
|
||
extractFilenameFromTitle() {
|
||
if (typeof document === 'undefined' || !document.title) {
|
||
return null;
|
||
}
|
||
|
||
let title = document.title.trim();
|
||
|
||
// Remove common website suffixes
|
||
title = title
|
||
.split('|')[0] // Remove " | Website"
|
||
.split('-')[0] // Remove " - Website"
|
||
.split('•')[0] // Remove " • Website"
|
||
.trim();
|
||
|
||
if (title.length < 3 || title.length > 100) {
|
||
return null;
|
||
}
|
||
|
||
return title;
|
||
}
|
||
|
||
/**
|
||
* Extract filename from URL pathname
|
||
* @returns {string|null} Filename or null if not suitable
|
||
*/
|
||
extractFilenameFromUrl() {
|
||
if (typeof window === 'undefined' || !window.location) {
|
||
return null;
|
||
}
|
||
|
||
const pathname = window.location.pathname;
|
||
if (!pathname || pathname === '/') {
|
||
return null;
|
||
}
|
||
|
||
// Get the last segment of the path
|
||
const segments = pathname.split('/').filter(s => s.length > 0);
|
||
if (segments.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
const lastSegment = segments[segments.length - 1];
|
||
|
||
// Remove file extensions
|
||
const filenameBase = lastSegment.replace(/\.[^.]*$/, '');
|
||
|
||
if (filenameBase.length < 3 || filenameBase.length > 100) {
|
||
return null;
|
||
}
|
||
|
||
return filenameBase;
|
||
}
|
||
|
||
/**
|
||
* Extract filename from first heading in markdown
|
||
* @returns {string|null} Filename or null if no heading found
|
||
*/
|
||
extractFilenameFromHeading() {
|
||
if (!this.originalMarkdown) {
|
||
return null;
|
||
}
|
||
|
||
const lines = this.originalMarkdown.split('\n');
|
||
|
||
// Find first heading line
|
||
for (const line of lines) {
|
||
const trimmed = line.trim();
|
||
if (/^#{1,6}\s/.test(trimmed)) {
|
||
// Extract heading text (remove # symbols and trim)
|
||
const headingText = trimmed.replace(/^#{1,6}\s*/, '').trim();
|
||
|
||
if (headingText.length >= 3 && headingText.length <= 100) {
|
||
return headingText;
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Generate timestamp-based filename as final fallback
|
||
* @returns {string} Timestamp-based filename
|
||
*/
|
||
generateTimestampFilename() {
|
||
const now = new Date();
|
||
const timestamp = now.getFullYear().toString() +
|
||
(now.getMonth() + 1).toString().padStart(2, '0') +
|
||
now.getDate().toString().padStart(2, '0') + '-' +
|
||
now.getHours().toString().padStart(2, '0') +
|
||
now.getMinutes().toString().padStart(2, '0');
|
||
|
||
return `document-${timestamp}.md`;
|
||
}
|
||
|
||
/**
|
||
* Cleanup method to stop status tracking
|
||
*/
|
||
destroy() {
|
||
if (this.sectionManager) {
|
||
this.sectionManager.stopStatusTracking();
|
||
}
|
||
}
|
||
}
|
||
|
||
// Initialize the clean editor system
|
||
let markitectCleanEditor;
|
||
|
||
function initializeCleanEditor() {
|
||
debug('1: initializeCleanEditor called', 'INIT');
|
||
|
||
const container = document.getElementById('markdown-content');
|
||
if (!container) {
|
||
debug('2: FAILED - Markdown content container not found', 'ERROR');
|
||
return;
|
||
}
|
||
debug('3: Container found', 'INIT');
|
||
|
||
if (typeof window.MarkitectEditor === 'undefined') {
|
||
debug('4: FAILED - MarkitectEditor not found', 'ERROR');
|
||
return;
|
||
}
|
||
debug('5: MarkitectEditor found', 'INIT');
|
||
|
||
debug('6: Creating editor with content length: ' + markdownContentWithDogtag.length, 'INIT');
|
||
|
||
try {
|
||
markitectCleanEditor = new window.MarkitectEditor.MarkitectCleanEditor(markdownContentWithDogtag, container, {
|
||
cleanMarkdownContent: markdownContent,
|
||
dogtagContent: dogtagContent
|
||
});
|
||
debug('7: Editor instance created', 'INIT');
|
||
|
||
window.markitectCleanEditor = markitectCleanEditor; // Make globally available
|
||
debug('8: Editor made globally available', 'INIT');
|
||
|
||
const contentAfterInit = container.innerHTML;
|
||
debug('9: Container has content: ' + (contentAfterInit.length > 0 ? 'YES (' + contentAfterInit.length + ' chars)' : 'NO'), 'INIT');
|
||
|
||
console.log('✅ Clean section editor initialized successfully');
|
||
} catch (error) {
|
||
debug('ERROR in editor creation: ' + error.message, 'ERROR');
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// Document scroll indicators
|
||
function initializeScrollIndicators() {
|
||
console.log('✅ Document scroll indicators initialized');
|
||
}
|
||
|
||
// Export for module systems
|
||
if (typeof module !== 'undefined' && module.exports) {
|
||
module.exports = { EditState, SectionType, Section, SectionManager, DOMRenderer, MarkitectCleanEditor };
|
||
global.EditState = EditState;
|
||
global.SectionType = SectionType;
|
||
global.Section = Section;
|
||
} else {
|
||
window.MarkitectEditor = { EditState, SectionType, Section, SectionManager, DOMRenderer, MarkitectCleanEditor };
|
||
window.EditState = EditState;
|
||
window.SectionType = SectionType;
|
||
window.Section = Section;
|
||
} |