testdrive-jsui/docs/prototypes/DebugControlContent.html

4092 lines
157 KiB
HTML
Raw Permalink Normal View History

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test Document</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
line-height: 1.6;
color: #333333;
background-color: #ffffff;
}
#markdown-content {
min-height: 200px;
}
h1, h2, h3, h4, h5, h6 {
color: #333333;
}
pre {
background-color: #f6f8fa;
color: #333333;
padding: 1rem;
border-radius: 6px;
overflow-x: auto;
border: 1px solid #d0d7de;
}
code {
background-color: #f6f8fa;
color: #333333;
padding: 0.2em 0.4em;
border-radius: 3px;
font-size: 0.9em;
}
pre code {
background: none;
padding: 0;
}
blockquote {
border-left: 4px solid #dfe2e5;
margin: 0;
padding-left: 1rem;
color: #6a737d;
}
table {
font-size: 0.85em;
border-collapse: collapse;
margin: 1rem 0;
width: 100%;
border: 1px solid #d0d7de;
}
th, td {
font-size: inherit;
border: 1px solid #d0d7de;
padding: 0.5rem;
text-align: left;
}
th {
background-color: #f6f8fa;
font-weight: 600;
}
img {
max-width: 12cm;
max-height: 20cm;
height: auto;
display: block;
margin: 1rem auto;
}</style>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"
onload="window.markitectMarkedLoaded = true"
onerror="window.markitectMarkedError = true"></script>
</head>
<body class="markitect-edit-mode">
<div id="markdown-content"></div>
<script>
const markdownContent = "# Test Document\n\nIntroduction section with some content.\n\n## Features\n\nThis section describes various features.\n\n### Feature A\n\nDetails about the first feature.\n\n### Feature B\n\nInformation about the second feature.\n\n## Conclusion\n\nFinal summary section.\n";
const markdownContentWithDogtag = "# Test Document\n\nIntroduction section with some content.\n\n## Features\n\nThis section describes various features.\n\n### Feature A\n\nDetails about the first feature.\n\n### Feature B\n\nInformation about the second feature.\n\n## Conclusion\n\nFinal summary section.\n\n\n---\n*-- html from markdown by <a href=\"https://coulomb.social/open/MarkiTect\" target=\"_blank\">MarkiTect</a> on 2025-11-11 00:01:28 by <a href=\"https://coulomb.social/open/worsch\" target=\"_blank\">worsch</a>*";
const dogtagContent = "\n\n---\n*-- html from markdown by <a href=\"https://coulomb.social/open/MarkiTect\" target=\"_blank\">MarkiTect</a> on 2025-11-11 00:01:28 by <a href=\"https://coulomb.social/open/worsch\" target=\"_blank\">worsch</a>*";
window.markitectBase64References = {};
const MARKITECT_EDIT_MODE = true;
const MARKITECT_EDITOR_CONFIG = {
mode: 'edit',
theme: 'github',
keyboardShortcuts: true,
autosave: false,
sections: true,
originalFilename: 'tmp1x9ds_fj',
version: 'Markitect v0.8.1.dev23+g3839a6761.d20251110',
repoName: 'Markitect'
};
// Make config available globally
window.editorConfig = MARKITECT_EDITOR_CONFIG;
// === js/core/section-manager.js ===
/**
* SectionManager Component
*
* Extracted from monolithic editor.js as part of architecture refactoring.
* Manages the collection of sections and their state transitions.
*
* Dependencies:
* - EditState enum (imported)
* - SectionType enum (imported)
* - Section class (imported)
* - debug function (imported)
*/
// Import dependencies - these will be separate modules
const EditState = Object.freeze({
ORIGINAL: 'original',
EDITING: 'editing',
MODIFIED: 'modified',
SAVED: 'saved'
});
const SectionType = Object.freeze({
HEADING: 'heading',
PARAGRAPH: 'paragraph',
LIST: 'list',
CODE: 'code',
QUOTE: 'quote',
TABLE: 'table',
HR: 'hr',
IMAGE: 'image'
});
// Debug function (will be extracted to utils)
function debug(message, category = 'INFO') {
// Simple console debug for now - will be enhanced later
console.log(`DEBUG ${category}: ${message}`);
}
/**
* Section Class - manages individual section state and content
*/
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();
}
static generateId(markdown, position, strategy = 'hash', parentId = null) {
return this.generateIdWithStrategy(markdown, position, strategy, parentId);
}
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);
}
}
static generateAdvancedId(content, position, sectionType) {
const contentHash = this.generateCryptoHash(content);
const safeType = sectionType || 'paragraph';
const typePrefix = safeType.substring(0, 3);
const positionHex = position.toString(16).padStart(2, '0');
return `section-${typePrefix}-${contentHash}-${positionHex}`;
}
static generateCryptoHash(content) {
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;
}
const hexHash = Math.abs(hash).toString(16).padStart(8, '0');
return hexHash.substring(0, 8);
}
static normalizeContentForHashing(content) {
if (!content || typeof content !== 'string') {
return '';
}
return content
.trim()
.replace(/\s+/g, ' ')
.replace(/\r\n/g, '\n')
.toLowerCase();
}
static sanitizeContentForId(content) {
if (!content || typeof content !== 'string') {
return '';
}
return content
.replace(/<[^>]*>/g, '')
.replace(/javascript:/gi, '')
.replace(/[^\w\s\-_.#]/g, '')
.trim();
}
static generateTimestampId(content, position = 0, sectionType = 'paragraph') {
const timestamp = Date.now().toString(36);
const contentSnippet = this.generateCryptoHash(content || '').substring(0, 4);
const safeType = sectionType || 'paragraph';
const typePrefix = safeType.substring(0, 3);
return `section-${typePrefix}-${contentSnippet}-${timestamp}`;
}
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}`;
}
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}`;
}
}
static detectType(markdown) {
if (!markdown || typeof markdown !== 'string') {
return SectionType.PARAGRAPH;
}
const content = markdown.replace(/^\n+|\n+$/g, '');
if (!content) {
return SectionType.PARAGRAPH;
}
const trimmed = content.trim();
// Detection order matters - most specific first
if (this.isHeading(trimmed)) {
return SectionType.HEADING;
}
if (this.isImage(trimmed)) {
return SectionType.IMAGE;
}
if (this.isCodeBlock(trimmed)) {
return SectionType.CODE;
}
return SectionType.PARAGRAPH;
}
static isHeading(trimmed) {
const headingPattern = /^#{1,6}\s+.+/;
return headingPattern.test(trimmed);
}
static isImage(trimmed) {
const imagePattern = /!\[.*?\]\([^)]+\)/;
return imagePattern.test(trimmed);
}
static isCodeBlock(trimmed) {
if (trimmed.startsWith('```') || trimmed.startsWith('~~~')) {
return true;
}
if (trimmed.includes('```') || trimmed.includes('~~~')) {
const codeBlockPattern = /```[\s\S]*?```|~~~[\s\S]*?~~~/;
if (codeBlockPattern.test(trimmed)) {
return true;
}
}
return false;
}
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 (this.editingMarkdown && this.editingMarkdown !== this.currentMarkdown) {
this.pendingMarkdown = this.editingMarkdown;
this.state = EditState.MODIFIED;
} else {
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,
type: this.type,
originalLength: this.originalMarkdown.length,
currentLength: this.currentMarkdown.length
};
}
isImage() {
return this.type === SectionType.IMAGE;
}
redetectType(content = null) {
const markdown = content || this.currentMarkdown;
const oldType = this.type;
this.type = Section.detectType(markdown);
if (oldType !== this.type) {
debug(`Section ${this.id} type changed from ${oldType} to ${this.type}`, 'TYPE_DETECTION');
}
return this.type;
}
}
/**
* SectionManager - 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) {
// Split content into blocks separated by double newlines
const blocks = markdownContent.split(/\n\s*\n/);
const sections = [];
let position = 0;
for (const block of blocks) {
const trimmedBlock = block.trim();
if (!trimmedBlock) continue;
// Check if this block should be split further
const lines = trimmedBlock.split('\n');
let currentSection = '';
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const isHeading = /^#{1,6}\s/.test(line.trim());
const isImage = /^\s*!\[.*?\]\(.*?\)\s*$/.test(line);
// Each heading or image starts a new section
if ((isHeading || isImage) && currentSection.trim()) {
// Save the previous section
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;
}
}
// Save the final section from this block
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);
position++;
}
}
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`);
}
const oldType = section.type;
section.updateContent(markdown);
const newType = section.redetectType(markdown);
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());
}
getDocumentStatus() {
const sections = Array.from(this.sections.values());
const editingSections = sections.filter(section => section.isEditing).length;
return {
totalSections: sections.length,
editingSections: editingSections
};
}
extractHeadings(content) {
if (!content) return [];
const lines = content.split('\n');
return lines.filter(line => /^#{1,6}\s/.test(line.trim()));
}
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.createSectionsFromMarkdown(newContent);
// Emit section-split event
this.emit('section-split', {
originalSectionId: sectionId,
newSections: newSections,
count: newSections.length
});
return newSections;
}
createSectionsFromContent(content) {
return this.createSectionsFromMarkdown(content);
}
}
// Export for use in tests and other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = { SectionManager, Section, EditState, SectionType };
}
// Export for browser use
if (typeof window !== 'undefined') {
window.SectionManager = SectionManager;
window.Section = Section;
window.EditState = EditState;
window.SectionType = SectionType;
}
// === js/components/debug-panel.js ===
/**
* DebugPanel Component
*
* Extracted from monolithic editor.js as part of architecture refactoring.
* Handles debug message display and management for client-side debugging.
*
* Dependencies:
* - None (standalone component)
*/
/**
* DebugPanel - Manages debug message display and interaction
*/
class DebugPanel {
constructor() {
this.messages = [];
this.isActive = false;
this.maxMessages = 1000; // Keep last 1000 messages
}
/**
* Add a debug message
*/
addMessage(message, category = 'INFO') {
const messageObj = {
message,
category,
timestamp: new Date().toLocaleTimeString()
};
this.messages.push(messageObj);
// Keep only last maxMessages
if (this.messages.length > this.maxMessages) {
this.messages = this.messages.slice(-this.maxMessages);
}
// Auto-update if panel is visible
if (this.isActive) {
this.update();
}
}
/**
* Toggle the debug panel on/off
*/
toggle() {
const debugContainer = document.getElementById('debug-messages-container');
const debugButton = document.getElementById('toggle-debug');
if (!debugContainer || !debugButton) {
console.warn('DebugPanel: Required DOM elements not found');
return;
}
if (this.isActive) {
this.hide();
} else {
this.show();
}
}
/**
* Show the debug panel
*/
show() {
const debugContainer = document.getElementById('debug-messages-container');
const debugButton = document.getElementById('toggle-debug');
if (!debugContainer || !debugButton) {
console.warn('DebugPanel: Required DOM elements not found');
return;
}
debugContainer.style.display = 'block';
debugButton.textContent = '🔍 Debug (ON)';
debugButton.style.background = '#28a745';
this.isActive = true;
this.update();
}
/**
* Hide the debug panel
*/
hide() {
const debugContainer = document.getElementById('debug-messages-container');
const debugButton = document.getElementById('toggle-debug');
if (!debugContainer || !debugButton) {
console.warn('DebugPanel: Required DOM elements not found');
return;
}
debugContainer.style.display = 'none';
debugButton.textContent = '🔍 Debug';
debugButton.style.background = '#6c757d';
this.isActive = false;
}
/**
* Update the debug panel with current messages
*/
update() {
const debugContainer = document.getElementById('debug-messages-container');
if (!debugContainer || !this.isActive) {
return;
}
if (this.messages.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 = this.messages.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 (${this.messages.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', () => {
this.clear();
});
}
// Auto-scroll to bottom to show newest messages
const scrollContainer = debugContainer.querySelector('div[style*="overflow-y"]');
if (scrollContainer) {
scrollContainer.scrollTop = scrollContainer.scrollHeight;
}
}
/**
* Clear all debug messages
*/
clear() {
this.messages = [];
this.update();
}
/**
* Get the number of messages
*/
getMessageCount() {
return this.messages.length;
}
/**
* Get recent messages
*/
getRecentMessages(count = 10) {
return this.messages.slice(-count);
}
}
// Export for use in tests and other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = { DebugPanel };
}
// Export for browser use
if (typeof window !== 'undefined') {
window.DebugPanel = DebugPanel;
}
// === js/components/document-controls.js ===
/**
* DocumentControls Component
*
* Extracted from monolithic editor.js as part of architecture refactoring.
* Handles the floating control panel and document-level actions.
*
* Dependencies:
* - None (standalone component)
*/
/**
* DocumentControls - Manages the floating control panel and its buttons
*/
class DocumentControls {
constructor() {
this.controlPanel = null;
this.buttons = new Map();
this.eventHandlers = new Map();
this.isVisible = true;
}
/**
* Create the control panel and add it to the DOM
*/
create() {
if (this.controlPanel) {
this.destroy(); // Remove existing panel
}
// Also remove any existing panel with the same ID in the DOM
const existingPanel = document.getElementById('markitect-global-controls');
if (existingPanel && existingPanel.parentNode) {
existingPanel.parentNode.removeChild(existingPanel);
}
// Create the floating control panel
this.controlPanel = document.createElement('div');
this.controlPanel.id = 'markitect-global-controls';
this.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;
`;
// Add title
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';
// Create button container
const buttonContainer = document.createElement('div');
buttonContainer.id = 'button-container';
buttonContainer.style.cssText = `
display: flex;
flex-direction: column;
gap: 6px;
`;
this.controlPanel.appendChild(title);
this.controlPanel.appendChild(buttonContainer);
// Add default buttons
this.addDefaultButtons();
// Add debug messages container
this.addDebugContainer();
// Add to DOM
document.body.appendChild(this.controlPanel);
}
/**
* Add default buttons to the control panel
*/
addDefaultButtons() {
// Save Document button
this.addButton('save-document', '💾 Save Document', '#28a745');
// Reset All button
this.addButton('reset-all', '🔄 Reset All', '#ffc107', '#212529');
// Show Status button
this.addButton('show-status', '📊 Show Status', '#17a2b8');
// Debug button
this.addButton('toggle-debug', '🔍 Debug', '#6c757d');
}
/**
* Add debug container to the control panel
*/
addDebugContainer() {
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;
`;
this.controlPanel.appendChild(debugContainer);
}
/**
* Add a button to the control panel
*/
addButton(id, text, backgroundColor, textColor = 'white') {
const buttonContainer = this.controlPanel.querySelector('#button-container');
if (!buttonContainer) {
throw new Error('Button container not found. Call create() first.');
}
const button = document.createElement('button');
button.id = id;
button.textContent = text;
button.style.cssText = `
background: ${backgroundColor};
color: ${textColor};
border: none;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: background-color 0.2s;
`;
buttonContainer.appendChild(button);
this.buttons.set(id, button);
return button;
}
/**
* Remove a button from the control panel
*/
removeButton(id) {
const button = this.buttons.get(id);
if (button && button.parentNode) {
button.parentNode.removeChild(button);
this.buttons.delete(id);
this.eventHandlers.delete(id);
}
}
/**
* Set event handlers for buttons
*/
setEventHandlers(handlers) {
for (const [buttonId, handler] of Object.entries(handlers)) {
const button = this.buttons.get(buttonId);
if (button) {
// Remove existing handler if any
if (this.eventHandlers.has(buttonId)) {
button.removeEventListener('click', this.eventHandlers.get(buttonId));
}
// Add new handler
button.addEventListener('click', handler);
this.eventHandlers.set(buttonId, handler);
}
}
}
/**
* Show the control panel
*/
show() {
if (this.controlPanel) {
this.controlPanel.style.display = 'block';
this.isVisible = true;
}
}
/**
* Hide the control panel
*/
hide() {
if (this.controlPanel) {
this.controlPanel.style.display = 'none';
this.isVisible = false;
}
}
/**
* Update status display (can be extended as needed)
*/
updateStatus(status) {
// This method can be extended to show status information
// For now, it just stores the status for potential display
this.lastStatus = status;
// Could update a status indicator in the panel if needed
if (status && this.controlPanel) {
const title = this.controlPanel.querySelector('div');
if (title) {
const statusText = `Document Controls (${status.totalSections} sections, ${status.editingSections} editing)`;
// Could update title or add status indicator
}
}
}
/**
* Get the control panel element
*/
getControlPanel() {
return this.controlPanel;
}
/**
* Destroy the control panel and clean up
*/
destroy() {
if (this.controlPanel && this.controlPanel.parentNode) {
this.controlPanel.parentNode.removeChild(this.controlPanel);
}
// Clean up references
this.controlPanel = null;
this.buttons.clear();
this.eventHandlers.clear();
this.isVisible = true;
}
/**
* Check if the control panel is visible
*/
isVisible() {
return this.isVisible && this.controlPanel && this.controlPanel.style.display !== 'none';
}
/**
* Get all button IDs
*/
getButtonIds() {
return Array.from(this.buttons.keys());
}
/**
* Get a specific button by ID
*/
getButton(id) {
return this.buttons.get(id);
}
}
// Export for use in tests and other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = { DocumentControls };
}
// Export for browser use
if (typeof window !== 'undefined') {
window.DocumentControls = DocumentControls;
}
// === js/components/dom-renderer.js ===
/**
* DOMRenderer Component
*
* Extracted from monolithic editor.js as part of architecture refactoring.
* Handles all DOM interactions and UI rendering for section editing.
*
* Dependencies:
* - FloatingMenu component (to be extracted)
* - debug function (imported from utils)
*/
// Import dependencies (placeholders for now)
function debug(message, category = 'INFO') {
console.log(`DEBUG ${category}: ${message}`);
}
/**
* Simple FloatingMenu implementation (will be extracted to separate component later)
*/
class FloatingMenu {
constructor(sectionId, type, renderer) {
this.sectionId = sectionId;
this.type = type;
this.renderer = renderer;
this.element = null;
this.isVisible = false;
}
show(contentElement, controlsElement) {
if (this.isVisible) this.hide();
const targetElement = this.renderer.findSectionElement(this.sectionId);
if (!targetElement) return null;
// Get content dimensions and position
const rect = targetElement.getBoundingClientRect();
const viewport = {
width: window.innerWidth,
height: window.innerHeight
};
// Calculate content width and responsive extension
const contentWidth = rect.width;
const buttonAreaWidth = 120; // Space needed for buttons
const minMenuWidth = Math.max(300, contentWidth); // At least content width or 300px
const preferredMenuWidth = contentWidth + buttonAreaWidth;
// Check if we have space to extend to the right
const spaceOnRight = viewport.width - rect.right;
const canExtendRight = spaceOnRight >= buttonAreaWidth + 20; // 20px margin
// Determine final menu width
let menuWidth;
if (canExtendRight && viewport.width >= 800) { // Only on wide screens
menuWidth = Math.min(preferredMenuWidth, viewport.width - rect.left - 20);
} else {
menuWidth = Math.min(minMenuWidth, viewport.width - 40); // 20px margins
}
// Create floating menu element
this.element = document.createElement('div');
this.element.className = 'ui-edit-floating-menu';
this.element.style.cssText = `
position: fixed;
z-index: 10000;
background: white;
border: 1px solid #ddd;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
padding: 0;
width: ${menuWidth}px;
box-sizing: border-box;
`;
// Add headline
const headline = document.createElement('div');
headline.className = 'ui-edit-headline';
headline.textContent = `Editing ${this.type === 'image' ? 'Image' : 'Section'}`;
headline.style.cssText = `
background: #f8f9fa;
border-bottom: 1px solid #ddd;
padding: 8px 16px;
font-weight: 600;
font-size: 12px;
color: #495057;
border-radius: 8px 8px 0 0;
text-transform: uppercase;
letter-spacing: 0.5px;
`;
// Create content wrapper with padding
const contentWrapper = document.createElement('div');
contentWrapper.style.cssText = `
padding: 16px;
`;
this.element.appendChild(headline);
// Position directly over content (overlay positioning)
let left = rect.left;
let top = rect.top;
// Ensure menu doesn't go off-screen horizontally
if (left + menuWidth > viewport.width) {
left = viewport.width - menuWidth - 20;
}
if (left < 10) {
left = 10;
}
// For vertical positioning, prefer staying on top of content
// Only move if absolutely necessary
const menuHeight = this.type === 'image' ? 350 : 200; // Better height estimates
const wouldGoOffBottom = top + menuHeight > viewport.height;
const wouldGoOffTop = top < 10;
if (wouldGoOffBottom && !wouldGoOffTop) {
// Try to fit by moving up, but keep some overlay if possible
const maxTop = viewport.height - menuHeight - 10;
top = Math.max(rect.top - 50, maxTop); // Prefer staying near original position
} else if (wouldGoOffTop) {
top = 10; // Minimum distance from top
}
// Otherwise, keep the original overlay position
this.element.style.left = `${left}px`;
this.element.style.top = `${top}px`;
// Add content to wrapper
if (contentElement) {
contentWrapper.appendChild(contentElement);
}
if (controlsElement) {
contentWrapper.appendChild(controlsElement);
}
this.element.appendChild(contentWrapper);
// Add close button to headline
const closeButton = document.createElement('button');
closeButton.textContent = '×';
closeButton.style.cssText = `
position: absolute;
top: 4px;
right: 8px;
background: none;
border: none;
font-size: 18px;
cursor: pointer;
color: #666;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background-color 0.2s ease;
`;
closeButton.addEventListener('mouseover', () => {
closeButton.style.backgroundColor = '#e9ecef';
});
closeButton.addEventListener('mouseout', () => {
closeButton.style.backgroundColor = 'transparent';
});
closeButton.addEventListener('click', (event) => {
event.stopPropagation();
this.hide();
});
this.element.appendChild(closeButton);
document.body.appendChild(this.element);
this.isVisible = true;
return this.element;
}
hide() {
if (this.element && this.element.parentNode) {
this.element.parentNode.removeChild(this.element);
}
this.element = null;
this.isVisible = false;
// Stop editing state in the section manager
const section = this.renderer.sectionManager.sections.get(this.sectionId);
if (section && section.isEditing()) {
section.stopEditing();
}
// Remove from editing sections
this.renderer.editingSections.delete(this.sectionId);
}
}
/**
* DOMRenderer - Handles DOM interactions and section rendering
*/
class DOMRenderer {
constructor(sectionManager, container) {
this.sectionManager = sectionManager;
this.container = container;
this.editingSections = new Set();
this.currentFloatingMenu = null;
this.eventListenersAttached = false;
this.lastClickTime = 0;
this.clickDebounceMs = 300; // Prevent rapid clicks
// Enhanced Event System - Track 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.handleKeydown = this.handleKeydown.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);
});
}
/**
* Render all sections to the DOM
*/
renderAllSections(sections) {
debug('21: renderAllSections called with ' + sections.length + ' sections', 'RENDER');
// Clear container
this.container.innerHTML = '';
debug('22: Container cleared', 'RENDER');
const contentArea = this.container.querySelector('#markdown-content') || this.container;
// Render each section
sections.forEach((section, index) => {
debug('23: Creating element for section ' + (index + 1) + ': ' + section.id, 'RENDER');
const element = this.renderSection(section);
if (element) {
contentArea.appendChild(element);
}
});
debug('24: All section elements added to container', 'RENDER');
// Attach event listeners only once
if (!this.eventListenersAttached) {
this.container.addEventListener('click', this.handleSectionClick);
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');
}
/**
* Render a single section to DOM element
*/
renderSection(section) {
const element = document.createElement('div');
element.className = 'ui-edit-section';
element.setAttribute('data-section-id', section.id);
// Add section content
// Render all sections using markdown rendering (images need HTML conversion too)
const content = this.simpleMarkdownRender(section.currentMarkdown);
element.innerHTML = content;
// Add styling
element.style.cssText = `
margin: 16px 0;
padding: 12px;
border: 1px solid transparent;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
`;
element.addEventListener('mouseenter', () => {
element.style.backgroundColor = 'rgba(0, 122, 204, 0.05)';
element.style.borderColor = 'rgba(0, 122, 204, 0.2)';
});
element.addEventListener('mouseleave', () => {
if (!section.isEditing()) {
element.style.backgroundColor = 'transparent';
element.style.borderColor = 'transparent';
}
});
debug('SECTION: Section element setup complete for ' + section.id, 'SECTION');
return element;
}
/**
* Simple markdown rendering (placeholder)
*/
simpleMarkdownRender(markdown) {
return markdown
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
.replace(/!\[(.*?)\]\((.*?)\)/gim, '<img src="$2" alt="$1" style="max-width: 100%; height: auto; display: block; margin: 1rem auto;" />')
.replace(/\[([^\]]+)\]\(([^)]+)\)/gim, '<a href="$2" target="_blank">$1</a>')
.replace(/\*\*(.*?)\*\*/gim, '<strong>$1</strong>')
.replace(/\*(.*?)\*/gim, '<em>$1</em>')
.replace(/\n/gim, '<br>');
}
/**
* Find DOM element for a section
*/
findSectionElement(sectionId) {
return this.container.querySelector(`[data-section-id="${sectionId}"]`);
}
/**
* Handle section click events
*/
handleSectionClick(event) {
debug('handleSectionClick: Click detected on target: ' + event.target.tagName + ' ' + (event.target.className || ''), 'CLICK');
// Debounce rapid clicks
const now = Date.now();
if (now - this.lastClickTime < this.clickDebounceMs) {
debug('handleSectionClick: Click debounced (too rapid)', 'CLICK');
return;
}
this.lastClickTime = now;
// 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,
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, checking if dialog is visible: ' + sectionId, 'CLICK');
// If section is editing but no dialog is visible, allow re-opening
const existingDialog = document.querySelector('.ui-edit-floating-menu');
if (existingDialog) {
debug('handleSectionClick: Dialog already visible, ignoring click', 'CLICK');
return;
} else {
debug('handleSectionClick: Section editing but no dialog visible, proceeding to show editor', 'CLICK');
}
}
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);
}
}
/**
* Show editor for a section
*/
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
const editorContent = document.createElement('div');
editorContent.className = 'ui-edit-editor-content';
// Check if we have space for side-by-side layout
const targetElement = this.findSectionElement(sectionId);
const rect = targetElement ? targetElement.getBoundingClientRect() : null;
const viewport = { width: window.innerWidth, height: window.innerHeight };
const hasWideLayout = rect && viewport.width >= 800 && (viewport.width - rect.right) >= 120;
if (hasWideLayout) {
// Side-by-side layout: textarea on left, controls on right
editorContent.style.cssText = `
display: flex;
gap: 16px;
flex: 1;
min-width: 0;
align-items: flex-start;
`;
} else {
// Stacked layout: textarea above, controls below
editorContent.style.cssText = `
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
min-width: 0;
`;
}
// Create textarea container
const textareaContainer = document.createElement('div');
textareaContainer.style.cssText = hasWideLayout ? 'flex: 1; min-width: 0;' : 'width: 100%;';
// Create textarea
const textarea = document.createElement('textarea');
textarea.value = content || section.currentMarkdown;
textarea.style.cssText = `
width: 100%;
min-height: 120px;
padding: 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 14px;
line-height: 1.5;
resize: vertical;
box-sizing: border-box;
`;
// Create controls
const controls = document.createElement('div');
if (hasWideLayout) {
controls.style.cssText = `
display: flex;
flex-direction: column;
gap: 8px;
min-width: 100px;
flex-shrink: 0;
`;
} else {
controls.style.cssText = `
display: flex;
gap: 8px;
justify-content: flex-end;
flex-wrap: wrap;
`;
}
const acceptButton = document.createElement('button');
acceptButton.textContent = hasWideLayout ? '✓' : 'Accept';
acceptButton.style.cssText = `
background: #28a745;
color: white;
border: none;
padding: ${hasWideLayout ? '8px 12px' : '8px 16px'};
border-radius: 4px;
cursor: pointer;
${hasWideLayout ? 'width: 100%;' : ''}
font-size: ${hasWideLayout ? '14px' : '13px'};
`;
const cancelButton = document.createElement('button');
cancelButton.textContent = hasWideLayout ? '✗' : 'Cancel';
cancelButton.style.cssText = `
background: #dc3545;
color: white;
border: none;
padding: ${hasWideLayout ? '8px 12px' : '8px 16px'};
border-radius: 4px;
cursor: pointer;
${hasWideLayout ? 'width: 100%;' : ''}
font-size: ${hasWideLayout ? '14px' : '13px'};
`;
const resetButton = document.createElement('button');
resetButton.textContent = hasWideLayout ? '↺' : '↺ Reset';
resetButton.style.cssText = `
background: #fd7e14;
color: white;
border: none;
padding: ${hasWideLayout ? '8px 12px' : '8px 16px'};
border-radius: 4px;
cursor: pointer;
${hasWideLayout ? 'width: 100%;' : ''}
font-size: ${hasWideLayout ? '14px' : '13px'};
`;
controls.appendChild(acceptButton);
controls.appendChild(cancelButton);
controls.appendChild(resetButton);
// Assemble the layout
textareaContainer.appendChild(textarea);
if (hasWideLayout) {
editorContent.appendChild(textareaContainer);
editorContent.appendChild(controls);
} else {
editorContent.appendChild(textareaContainer);
editorContent.appendChild(controls);
}
// Create floating menu
const floatingMenu = new FloatingMenu(sectionId, 'text', this);
this.currentFloatingMenu = floatingMenu;
this.editingSections.add(sectionId);
floatingMenu.show(editorContent);
// Add event listeners
acceptButton.addEventListener('click', () => {
this.sectionManager.updateContent(sectionId, textarea.value);
this.sectionManager.acceptChanges(sectionId);
floatingMenu.hide();
this.currentFloatingMenu = null; // Clear reference
});
cancelButton.addEventListener('click', () => {
this.sectionManager.cancelChanges(sectionId);
floatingMenu.hide();
this.currentFloatingMenu = null; // Clear reference
});
resetButton.addEventListener('click', () => {
// Reset textarea to original content and apply the change
const section = this.sectionManager.sections.get(sectionId);
if (section) {
textarea.value = section.originalMarkdown;
// Actually update the section content to original and accept the changes
this.sectionManager.updateContent(sectionId, section.originalMarkdown);
this.sectionManager.acceptChanges(sectionId);
// Close the editor
floatingMenu.hide();
this.currentFloatingMenu = null;
}
});
// Auto-focus textarea
setTimeout(() => textarea.focus(), 100);
}
/**
* Show advanced image editor with drag & drop, file upload, and preview
*/
showImageEditor(sectionId, section) {
debug('showImageEditor: called for image section: ' + sectionId, 'EDITOR');
// Track staging state for this editor
const stagingState = {
originalMarkdown: section.originalMarkdown,
currentAltText: '',
currentImageSrc: '',
stagedImageSrc: null,
stagedAltText: null,
hasChanges: false
};
// Parse markdown to extract image info
const imageMatch = section.currentMarkdown.match(/!\[(.*?)\]\((.*?)\)/);
if (imageMatch) {
const [, altText, imageSrc] = imageMatch;
stagingState.currentAltText = altText;
stagingState.currentImageSrc = imageSrc;
}
// Check if we have space for side-by-side layout
const targetElement = this.findSectionElement(sectionId);
const rect = targetElement ? targetElement.getBoundingClientRect() : null;
const viewport = { width: window.innerWidth, height: window.innerHeight };
const hasWideLayout = rect && viewport.width >= 800 && (viewport.width - rect.right) >= 120;
// Create image editor content area
const editorContent = document.createElement('div');
editorContent.className = 'ui-edit-image-content';
if (hasWideLayout) {
// Side-by-side layout: content on left, controls on right
editorContent.style.cssText = `
display: flex;
gap: 16px;
flex: 1;
min-width: 0;
align-items: flex-start;
`;
} else {
// Stacked layout: content above, controls below
editorContent.style.cssText = `
display: flex;
flex-direction: column;
gap: 15px;
flex: 1;
min-width: 0;
`;
}
// Create content container for image and alt text
const contentContainer = document.createElement('div');
contentContainer.style.cssText = hasWideLayout ? 'flex: 1; min-width: 0;' : 'width: 100%;';
if (!hasWideLayout) {
contentContainer.style.cssText += `
display: flex;
flex-direction: column;
gap: 15px;
`;
} else {
contentContainer.style.cssText += `
display: flex;
flex-direction: column;
gap: 12px;
`;
}
// Image preview with drop zone
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: 150px;
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: 16px;
`;
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: 14px;
`;
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: 12px;">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
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();
});
altTextContainer.appendChild(altTextLabel);
altTextContainer.appendChild(altTextInput);
// Change indicator
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';
}
};
// Assemble content container
contentContainer.appendChild(imagePreview);
contentContainer.appendChild(altTextContainer);
contentContainer.appendChild(changeIndicator);
contentContainer.appendChild(fileInput);
// Create controls
const controls = document.createElement('div');
controls.className = 'ui-edit-controls';
if (hasWideLayout) {
controls.style.cssText = `
display: flex;
flex-direction: column;
gap: 8px;
min-width: 100px;
flex-shrink: 0;
`;
} else {
controls.style.cssText = `
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
`;
}
const acceptBtn = document.createElement('button');
acceptBtn.textContent = hasWideLayout ? '✓' : '✓ Accept';
acceptBtn.style.cssText = `
padding: ${hasWideLayout ? '8px 12px' : '8px 12px'};
font-size: ${hasWideLayout ? '14px' : '12px'};
border-radius: 6px;
border: none;
color: white;
cursor: pointer;
font-weight: 600;
transition: all 0.2s ease;
width: 100%;
text-align: center;
background: #28a745;
`;
const cancelBtn = document.createElement('button');
cancelBtn.textContent = hasWideLayout ? '✗' : '✗ Cancel';
cancelBtn.style.cssText = `
padding: ${hasWideLayout ? '8px 12px' : '8px 12px'};
font-size: ${hasWideLayout ? '14px' : '12px'};
border-radius: 6px;
border: none;
color: white;
cursor: pointer;
font-weight: 600;
transition: all 0.2s ease;
width: 100%;
text-align: center;
background: #dc3545;
`;
const resetBtn = document.createElement('button');
resetBtn.textContent = hasWideLayout ? '↺' : '↺ Reset';
resetBtn.style.cssText = `
padding: ${hasWideLayout ? '8px 12px' : '8px 12px'};
font-size: ${hasWideLayout ? '14px' : '12px'};
border-radius: 6px;
border: none;
color: white;
cursor: pointer;
font-weight: 600;
transition: all 0.2s ease;
width: 100%;
text-align: center;
background: #fd7e14;
`;
controls.appendChild(acceptBtn);
controls.appendChild(cancelBtn);
controls.appendChild(resetBtn);
// Event handlers
acceptBtn.addEventListener('click', () => {
// 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);
}
// Accept changes and hide editor
this.sectionManager.acceptChanges(sectionId);
this.currentFloatingMenu.hide();
this.currentFloatingMenu = null;
});
cancelBtn.addEventListener('click', () => {
// Discard all staged changes and hide editor
this.sectionManager.cancelChanges(sectionId);
this.currentFloatingMenu.hide();
this.currentFloatingMenu = null;
});
resetBtn.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
// Reset to original content
const originalImageMatch = stagingState.originalMarkdown.match(/!\[(.*?)\]\((.*?)\)/);
if (originalImageMatch) {
const [, originalAltText, originalImageSrc] = originalImageMatch;
// Update staging state to original values
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 = originalAltText;
// Trigger input event to ensure UI consistency
const inputEvent = new Event('input', { bubbles: true, cancelable: true });
altTextInput.dispatchEvent(inputEvent);
// Reset preview to original image
updateImagePreview(originalImageSrc, originalAltText);
// Update change indicator
updateChangeIndicator();
// Actually update the section content to original and accept the changes
this.sectionManager.updateContent(sectionId, stagingState.originalMarkdown);
this.sectionManager.acceptChanges(sectionId);
// Close the editor
this.currentFloatingMenu.hide();
this.currentFloatingMenu = null;
}
});
// Assemble the final layout
if (hasWideLayout) {
editorContent.appendChild(contentContainer);
editorContent.appendChild(controls);
} else {
editorContent.appendChild(contentContainer);
editorContent.appendChild(controls);
}
// Create floating menu
const floatingMenu = new FloatingMenu(sectionId, 'image', this);
this.currentFloatingMenu = floatingMenu;
this.editingSections.add(sectionId);
floatingMenu.show(editorContent);
}
/**
* Hide current editor
*/
hideCurrentEditor() {
debug('EDITOR: hideCurrentEditor called', 'EDITOR');
if (this.currentFloatingMenu) {
this.currentFloatingMenu.hide();
this.currentFloatingMenu = null;
}
debug('EDITOR: hideCurrentEditor completed', 'EDITOR');
}
/**
* Track event for analytics
*/
trackEvent(eventType, data) {
const eventRecord = {
type: eventType,
data: data,
timestamp: new Date().toISOString()
};
this.eventHistory.push(eventRecord);
if (this.eventStats.hasOwnProperty(eventType)) {
this.eventStats[eventType]++;
}
// Keep only last 100 events
if (this.eventHistory.length > 100) {
this.eventHistory = this.eventHistory.slice(-100);
}
}
/**
* Get event statistics
*/
getEventStats() {
const totalEvents = Object.values(this.eventStats).reduce((sum, count) => sum + count, 0);
return {
stats: { ...this.eventStats },
totalEvents,
recentEvents: this.eventHistory.slice(-10)
};
}
/**
* Handle keyboard shortcuts
*/
handleKeydown(event) {
// Basic keyboard shortcut handling
if (event.ctrlKey || event.metaKey) {
if (event.key === 'Enter') {
// Accept changes
const activeSection = Array.from(this.editingSections)[0];
if (activeSection) {
this.trackEvent('keyboard-shortcut', { shortcut: 'ctrl+enter', action: 'accept' });
}
} else if (event.key === 'Escape') {
// Cancel changes
const activeSection = Array.from(this.editingSections)[0];
if (activeSection) {
this.trackEvent('keyboard-shortcut', { shortcut: 'escape', action: 'cancel' });
this.hideCurrentEditor();
}
}
}
}
}
// Export for use in tests and other modules
if (typeof module !== 'undefined' && module.exports) {
module.exports = { DOMRenderer, FloatingMenu };
}
// Export for browser use
if (typeof window !== 'undefined') {
window.DOMRenderer = DOMRenderer;
window.FloatingMenu = FloatingMenu;
}
// === Component Initialization ===
document.addEventListener('DOMContentLoaded', function() {
// Create container for the markdown content
const container = document.getElementById('markdown-content') || document.body;
// Initialize components
const sectionManager = new SectionManager();
const domRenderer = new DOMRenderer(sectionManager, container);
const debugPanel = new DebugPanel();
const documentControls = new DocumentControls();
// Create document controls
documentControls.create();
// Define abstract Control class for UI controls (same as viewing mode)
const Control = {
// Abstract control properties
element: null,
isExpanded: false,
isHeaderOnly: false, // New state for header-only mode
isDragging: false,
isResizing: false, // New state for resizing mode
dragOffset: { x: 0, y: 0 },
resizeStartSize: { width: 280, height: 'auto' },
originalPosition: { top: '80px', left: '20px' },
defaultSize: { width: 280, minWidth: 200, minHeight: 150 },
// Configuration properties (to be overridden by subclasses)
config: {
icon: '?',
title: 'Control',
className: 'control',
defaultContent: 'Template only',
ariaLabel: 'Control',
position: 'w' // Default compass position: west (middle-left)
},
// Compass positioning system (top-aligned for proper expansion)
compassPositions: {
// North positions (top)
'n': { top: '20px', left: '50%', transform: 'translateX(-50%)' },
'nne': { top: '20px', left: '65%', transform: 'translateX(-50%)' },
'ne': { top: '20px', right: '20px' },
'ene': { top: '80px', right: '20px' }, // Top-aligned instead of center
// East positions (right)
'e': { top: '50vh', right: '20px', transform: 'translateY(-20px)' }, // Anchor at icon level
'ese': { top: 'calc(65vh - 20px)', right: '20px' }, // Top-aligned
'se': { bottom: '20px', right: '20px' },
'sse': { bottom: '20px', right: '35%', transform: 'translateX(50%)' },
// South positions (bottom)
's': { bottom: '20px', left: '50%', transform: 'translateX(-50%)' },
'ssw': { bottom: '20px', left: '35%', transform: 'translateX(-50%)' },
'sw': { bottom: '20px', left: '20px' },
'wsw': { bottom: '80px', left: '20px' }, // Top-aligned instead of center
// West positions (left) - top-aligned for proper expansion
'w': { top: '50vh', left: '20px', transform: 'translateY(-20px)' }, // Anchor at icon level
'wnw': { top: '80px', left: '20px' }, // Top-aligned instead of center
'nw': { top: '20px', left: '20px' },
'nnw': { top: '20px', left: '35%', transform: 'translateX(-50%)' }
},
// Get expansion direction based on compass position
getExpansionDirection: function() {
const pos = this.config.position;
const rightBorderPositions = ['ne', 'ene', 'e', 'ese', 'se'];
const bottomBorderPositions = ['sw', 'ssw', 's', 'sse', 'se'];
return {
header: rightBorderPositions.includes(pos) ? 'left' : 'right',
body: bottomBorderPositions.includes(pos) ? 'up' : 'down'
};
},
// Calculate position styles based on compass direction
getPositionStyles: function() {
const compassPos = this.compassPositions[this.config.position] || this.compassPositions['w'];
return {
position: 'fixed',
top: compassPos.top || 'auto',
right: compassPos.right || 'auto',
bottom: compassPos.bottom || 'auto',
left: compassPos.left || 'auto',
transform: compassPos.transform || 'none',
zIndex: 1001
};
},
// Abstract methods (to be implemented by subclasses)
buildContent: function() {
const content = this.element.querySelector('.control-content');
content.innerHTML = `<p style="padding: 1rem; color: #666;">${this.config.defaultContent}</p>`;
},
// Concrete methods (shared by all controls)
createControl: function() {
console.log(`🎛️ Creating ${this.config.title} control...`);
this.element = document.createElement('div');
this.element.className = this.config.className;
this.element.innerHTML = `
<button class="control-toggle" aria-label="${this.config.ariaLabel}">${this.config.icon}</button>
<div class="control-panel" style="display: none;">
<div class="control-header">
<span class="control-icon">${this.config.icon}</span>
<h3>${this.config.title}</h3>
<button class="control-close"></button>
</div>
<div class="control-content">Loading...</div>
</div>
`;
// Position using compass direction
const positionStyles = this.getPositionStyles();
this.element.style.cssText = `
position: ${positionStyles.position};
top: ${positionStyles.top};
right: ${positionStyles.right};
bottom: ${positionStyles.bottom};
left: ${positionStyles.left};
transform: ${positionStyles.transform};
z-index: ${positionStyles.zIndex};
background: rgba(255, 255, 255, 0.95);
border: 1px solid #e1e5e9;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
backdrop-filter: blur(8px);
width: 40px;
transition: all 0.3s ease;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
`;
// Store original position for reset
this.originalPosition = {
top: positionStyles.top,
right: positionStyles.right,
bottom: positionStyles.bottom,
left: positionStyles.left,
transform: positionStyles.transform
};
// Style toggle button
const toggleBtn = this.element.querySelector('.control-toggle');
toggleBtn.style.cssText = `
width: 100%;
height: 40px;
border: none;
background: transparent;
cursor: pointer;
font-size: 16px;
color: #666;
transition: color 0.2s ease;
`;
// Handle click to build content on-demand
toggleBtn.addEventListener('click', () => {
if (this.isExpanded) {
this.collapse();
} else {
console.log(`🎛️ ${this.config.title} toggle clicked - building content...`);
this.buildContent();
}
});
// Close button handler
const closeBtn = this.element.querySelector('.control-close');
closeBtn.addEventListener('click', () => {
this.collapse();
});
document.body.appendChild(this.element);
console.log(`🎛️ ${this.config.title} control created`);
},
styleHeader: function() {
const header = this.element.querySelector('.control-header');
// Style the header to show icon, title, and close button in one line
// Match the height of the collapsed icon state (40px)
header.style.cssText = `
display: flex;
align-items: center;
justify-content: space-between;
height: 40px;
padding: 0 1rem;
border-bottom: 1px solid #eee;
margin-bottom: 0;
`;
const icon = header.querySelector('.control-icon');
if (icon) {
icon.style.cssText = `
font-size: 16px;
color: #666;
margin-right: 0.5rem;
cursor: grab;
user-select: none;
`;
// Make icon draggable
this.setupDragHandlers(icon);
}
const title = header.querySelector('h3');
if (title) {
title.style.cssText = `
margin: 0;
font-size: 0.9rem;
font-weight: 600;
flex-grow: 1;
line-height: 1;
cursor: pointer;
user-select: none;
`;
// Add click handler to toggle header-only mode
title.addEventListener('click', () => {
this.toggleHeaderOnly();
});
}
const closeBtn = header.querySelector('.control-close');
if (closeBtn) {
closeBtn.style.cssText = `
background: none;
border: none;
font-size: 14px;
cursor: pointer;
color: #666;
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
`;
}
},
styleContent: function() {
const content = this.element.querySelector('.control-content');
const expansion = this.getExpansionDirection();
// Style the content area based on expansion direction
let contentStyles = `
padding: 0.5rem;
overflow-y: auto;
`;
if (expansion.body === 'up') {
// Body expands upward (for bottom border positions)
contentStyles += `
max-height: calc(80vh - 40px);
`;
content.parentElement.style.flexDirection = 'column-reverse';
} else {
// Body expands downward (default)
contentStyles += `
max-height: calc(80vh - 40px);
`;
content.parentElement.style.flexDirection = 'column';
}
content.style.cssText = contentStyles;
},
expand: function() {
this.isExpanded = true;
const panel = this.element.querySelector('.control-panel');
const toggleBtn = this.element.querySelector('.control-toggle');
// Get expansion direction based on compass position
const expansion = this.getExpansionDirection();
// Apply expansion styling based on direction
if (expansion.header === 'left') {
// Header expands to the left (for right border positions)
this.element.style.width = '300px';
this.element.style.transformOrigin = 'top right';
} else {
// Header expands to the right (default)
this.element.style.width = '300px';
this.element.style.transformOrigin = 'top left';
}
panel.style.display = 'block';
toggleBtn.style.display = 'none';
this.styleHeader();
this.styleContent();
this.addResizeHandle();
},
collapse: function() {
this.isExpanded = false;
this.isHeaderOnly = false; // Reset header-only state
const panel = this.element.querySelector('.control-panel');
const toggleBtn = this.element.querySelector('.control-toggle');
panel.style.display = 'none';
// Reset size to default
this.element.style.width = '40px';
this.element.style.height = 'auto';
// Remove resize handle
this.removeResizeHandle();
toggleBtn.style.display = 'block';
// Reset position to original compass location
this.element.style.top = this.originalPosition.top;
this.element.style.right = this.originalPosition.right;
this.element.style.bottom = this.originalPosition.bottom;
this.element.style.left = this.originalPosition.left;
this.element.style.transform = this.originalPosition.transform;
},
toggleHeaderOnly: function() {
if (!this.isExpanded) {
// If collapsed, first expand normally
this.buildContent();
return;
}
const content = this.element.querySelector('.control-content');
if (this.isHeaderOnly) {
// Show content area (go to full expanded mode)
this.isHeaderOnly = false;
content.style.display = 'block';
console.log(`🎛️ ${this.config.title} expanded to full view`);
} else {
// Hide content area (go to header-only mode)
this.isHeaderOnly = true;
content.style.display = 'none';
console.log(`🎛️ ${this.config.title} collapsed to header only`);
}
},
setupDragHandlers: function(dragElement) {
dragElement.addEventListener('mousedown', (e) => {
this.isDragging = true;
const rect = this.element.getBoundingClientRect();
const iconRect = dragElement.getBoundingClientRect();
// Calculate offset relative to the icon position, not the element
this.dragOffset.x = e.clientX - rect.left;
this.dragOffset.y = iconRect.top - rect.top + (iconRect.height / 2); // Keep mouse at icon center
dragElement.style.cursor = 'grabbing';
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!this.isDragging || !this.isExpanded) return;
const newX = e.clientX - this.dragOffset.x;
const newY = e.clientY - this.dragOffset.y;
// Keep within viewport bounds
const maxX = window.innerWidth - this.element.offsetWidth;
const maxY = window.innerHeight - this.element.offsetHeight;
const boundedX = Math.max(0, Math.min(newX, maxX));
const boundedY = Math.max(0, Math.min(newY, maxY));
this.element.style.left = boundedX + 'px';
this.element.style.top = boundedY + 'px';
});
document.addEventListener('mouseup', () => {
if (this.isDragging) {
this.isDragging = false;
dragElement.style.cursor = 'grab';
}
});
},
// Add resize handle to expanded control (same as viewing mode)
addResizeHandle: function() {
// Remove existing resize handle if any
this.removeResizeHandle();
const resizeHandle = document.createElement('div');
resizeHandle.className = 'control-resize-handle';
resizeHandle.innerHTML = '↘';
resizeHandle.style.cssText = `
position: absolute;
bottom: 0;
right: 0;
width: 20px;
height: 20px;
background: rgba(108, 117, 125, 0.8);
color: white;
cursor: nw-resize;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
line-height: 1;
border-top-left-radius: 4px;
user-select: none;
z-index: 1002;
`;
this.element.appendChild(resizeHandle);
this.setupResizeHandlers(resizeHandle);
},
// Remove resize handle
removeResizeHandle: function() {
const existingHandle = this.element.querySelector('.control-resize-handle');
if (existingHandle) {
existingHandle.remove();
}
},
// Set up resize event handlers
setupResizeHandlers: function(resizeHandle) {
resizeHandle.addEventListener('mousedown', (e) => {
this.isResizing = true;
const rect = this.element.getBoundingClientRect();
this.resizeStartSize = {
width: rect.width,
height: rect.height,
startX: e.clientX,
startY: e.clientY
};
resizeHandle.style.cursor = 'nw-resize';
resizeHandle.style.background = 'rgba(40, 167, 69, 0.9)';
e.preventDefault();
e.stopPropagation(); // Prevent triggering drag
});
document.addEventListener('mousemove', (e) => {
if (!this.isResizing || !this.isExpanded) return;
const deltaX = e.clientX - this.resizeStartSize.startX;
const deltaY = e.clientY - this.resizeStartSize.startY;
const newWidth = Math.max(this.defaultSize.minWidth, this.resizeStartSize.width + deltaX);
const newHeight = Math.max(this.defaultSize.minHeight, this.resizeStartSize.height + deltaY);
// Check viewport bounds
const maxWidth = window.innerWidth - this.element.offsetLeft;
const maxHeight = window.innerHeight - this.element.offsetTop;
const boundedWidth = Math.min(newWidth, maxWidth - 20);
const boundedHeight = Math.min(newHeight, maxHeight - 20);
this.element.style.width = boundedWidth + 'px';
this.element.style.height = boundedHeight + 'px';
// Ensure content areas resize properly
this.updateContentSize();
});
document.addEventListener('mouseup', () => {
if (this.isResizing) {
this.isResizing = false;
resizeHandle.style.cursor = 'nw-resize';
resizeHandle.style.background = 'rgba(108, 117, 125, 0.8)';
}
});
},
// Update content area sizes during resize
updateContentSize: function() {
const content = this.element.querySelector('.control-content');
if (content) {
// Adjust content height to fit the resized control
const headerHeight = 40; // Header is 40px
const padding = 16; // Account for padding
const controlHeight = this.element.offsetHeight;
const availableHeight = controlHeight - headerHeight - padding;
content.style.maxHeight = Math.max(100, availableHeight) + 'px';
}
}
};
// Create ContentsControl for edit mode (new implementation based on Control class)
try {
const contentsControl = Object.create(Control);
// Configure for contents navigation in edit mode
contentsControl.config = {
icon: '☰',
title: 'Contents',
className: 'contents-control edit-mode',
defaultContent: 'No headings found',
ariaLabel: 'Document Navigation',
position: 'wnw' // West-north-west positioning
};
// Override buildContent method for navigation functionality
contentsControl.buildContent = function() {
const content = this.element.querySelector('.control-content');
// Build navigation content from current DOM
const allHeadings = document.querySelectorAll('h1, h2, h3');
// Filter out headings that contain "Contents" or similar navigation-related text
const headings = Array.from(allHeadings).filter(heading => {
const text = heading.textContent.trim().toLowerCase();
return !text.includes('contents') && !text.includes('table of contents') && !text.includes('navigation');
});
console.log("📋 Found headings for navigation:", headings.length);
if (headings.length === 0) {
content.innerHTML = '<p style="padding: 1rem; color: #666;">No headings found</p>';
} else {
let navHtml = '';
headings.forEach((heading, index) => {
if (!heading.id) {
heading.id = `heading-${index + 1}`;
}
const level = parseInt(heading.tagName.substring(1));
const indent = (level - 1) * 1;
navHtml += `
<a href="#${heading.id}"
style="display: block; padding: 0.5rem; margin-left: ${indent}rem;
text-decoration: none; color: #333; font-size: 0.9rem;
border-radius: 4px; cursor: pointer;"
onmouseover="this.style.backgroundColor='#f5f5f5'"
onmouseout="this.style.backgroundColor=''"
onclick="event.preventDefault(); document.getElementById('${heading.id}').scrollIntoView({behavior: 'smooth'}); if (window.innerWidth <= 768) setTimeout(() => contentsControl.collapse(), 500);">
${heading.textContent.trim()}
</a>
`;
});
content.innerHTML = navHtml;
}
// Show panel
this.expand();
};
// Initialize the ContentsControl
contentsControl.createControl();
// Make globally available for mobile collapse
window.contentsControl = contentsControl;
} catch (error) {
console.error("ContentsControl failed to initialize:", error);
}
// Step 7: Initialize Independent Debug System
try {
// Create independent debug system using IndexedDB for persistence
window.MarkitectDebugSystem = {
db: null,
messages: [],
maxMessages: 1000,
isEnabled: true,
subscribers: [],
// Selection and filtering system
selectionCriteria: {
includeDocumentEvents: true,
includeSystemEvents: false,
includeControlEvents: true,
includeEditingEvents: true,
includeNavigationEvents: false,
includedHeadings: new Set(), // Track which document headings to monitor
excludedSources: new Set(['ContentsControl', 'DocumentNavigator'])
},
// Initialize IndexedDB for persistence
async init() {
return new Promise((resolve, reject) => {
const request = indexedDB.open('MarkitectDebugDB', 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
this.loadMessages().then(resolve);
};
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains('messages')) {
const store = db.createObjectStore('messages', { keyPath: 'id', autoIncrement: true });
store.createIndex('timestamp', 'timestamp', { unique: false });
store.createIndex('category', 'category', { unique: false });
}
};
});
},
// Add a debug message with selection filtering
async addMessage(message, category = 'INFO', source = 'System', context = {}) {
// Check if this message should be included based on selection criteria
if (!this.shouldIncludeMessage(message, category, source, context)) {
return null;
}
const messageObj = {
message: String(message),
category: category.toUpperCase(),
source: source,
context: context,
timestamp: new Date().toISOString(),
displayTime: new Date().toLocaleTimeString()
};
// Add to memory
this.messages.push(messageObj);
// Keep only last maxMessages in memory
if (this.messages.length > this.maxMessages) {
this.messages = this.messages.slice(-this.maxMessages);
}
// Persist to IndexedDB
if (this.db) {
try {
const transaction = this.db.transaction(['messages'], 'readwrite');
const store = transaction.objectStore('messages');
await store.add(messageObj);
} catch (e) {
console.warn('Failed to persist debug message:', e);
}
}
// Notify subscribers
this.subscribers.forEach(callback => {
try { callback(messageObj); } catch (e) { console.error('Debug subscriber error:', e); }
});
return messageObj;
},
// Selection filtering logic
shouldIncludeMessage(message, category, source, context) {
if (!this.isEnabled) return false;
// Check excluded sources
if (this.selectionCriteria.excludedSources.has(source)) {
return false;
}
// Category-based filtering
const categoryChecks = {
'DOCUMENT': () => this.selectionCriteria.includeDocumentEvents,
'SYSTEM': () => this.selectionCriteria.includeSystemEvents,
'CONTROL': () => this.selectionCriteria.includeControlEvents,
'EDITING': () => this.selectionCriteria.includeEditingEvents,
'NAVIGATION': () => this.selectionCriteria.includeNavigationEvents
};
// Check if we have a specific category filter
if (context.eventType && categoryChecks[context.eventType]) {
return categoryChecks[context.eventType]();
}
// Document heading specific filtering
if (context.headingId) {
return this.selectionCriteria.includedHeadings.has(context.headingId) ||
this.selectionCriteria.includedHeadings.size === 0; // Include all if none specifically selected
}
// Default: include based on general settings
return category === 'ERROR' || // Always include errors
this.selectionCriteria.includeSystemEvents;
},
// Document structure awareness
scanDocumentStructure() {
const headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
const documentStructure = {
headings: [],
totalSections: 0
};
headings.forEach((heading, index) => {
// Skip control headings (like Contents, Debug, etc.)
const text = heading.textContent.trim().toLowerCase();
if (text.includes('contents') || text.includes('debug') ||
text.includes('control') || text.includes('navigation')) {
return;
}
if (!heading.id) {
heading.id = `document-heading-${index + 1}`;
}
documentStructure.headings.push({
id: heading.id,
text: heading.textContent.trim(),
level: parseInt(heading.tagName.substring(1)),
element: heading
});
});
documentStructure.totalSections = documentStructure.headings.length;
this.documentStructure = documentStructure;
// Auto-include all document headings for monitoring
this.selectionCriteria.includedHeadings.clear();
documentStructure.headings.forEach(heading => {
this.selectionCriteria.includedHeadings.add(heading.id);
});
this.addMessage(`Document structure scanned: ${documentStructure.totalSections} sections found`,
'INFO', 'DocumentScanner', { eventType: 'DOCUMENT', headings: documentStructure.headings });
return documentStructure;
},
// Selection management methods
includeHeading(headingId) {
this.selectionCriteria.includedHeadings.add(headingId);
},
excludeHeading(headingId) {
this.selectionCriteria.includedHeadings.delete(headingId);
},
includeSource(source) {
this.selectionCriteria.excludedSources.delete(source);
},
excludeSource(source) {
this.selectionCriteria.excludedSources.add(source);
},
// Get selection criteria for UI
getSelectionCriteria() {
return {
...this.selectionCriteria,
includedHeadings: Array.from(this.selectionCriteria.includedHeadings),
excludedSources: Array.from(this.selectionCriteria.excludedSources)
};
},
// Load messages from IndexedDB
async loadMessages() {
if (!this.db) return;
try {
const transaction = this.db.transaction(['messages'], 'readonly');
const store = transaction.objectStore('messages');
const request = store.getAll();
request.onsuccess = () => {
this.messages = request.result.slice(-this.maxMessages);
console.log(`📊 Loaded ${this.messages.length} debug messages from IndexedDB`);
};
} catch (e) {
console.warn('Failed to load debug messages:', e);
}
},
// Clear all messages
async clearMessages() {
this.messages = [];
if (this.db) {
try {
const transaction = this.db.transaction(['messages'], 'readwrite');
const store = transaction.objectStore('messages');
await store.clear();
} catch (e) {
console.warn('Failed to clear debug messages from DB:', e);
}
}
this.subscribers.forEach(callback => {
try { callback({ type: 'clear' }); } catch (e) { console.error('Debug subscriber error:', e); }
});
},
// Subscribe to debug updates
subscribe(callback) {
this.subscribers.push(callback);
return () => {
const index = this.subscribers.indexOf(callback);
if (index > -1) this.subscribers.splice(index, 1);
};
},
// Get messages (with filtering)
getMessages(category = null, limit = null) {
let filtered = this.messages;
if (category) {
filtered = filtered.filter(msg => msg.category === category.toUpperCase());
}
if (limit) {
filtered = filtered.slice(-limit);
}
return filtered;
},
// Get recent messages
getRecentMessages(count = 50) {
return this.messages.slice(-count);
},
// Export messages as JSON
exportMessages() {
return JSON.stringify(this.messages, null, 2);
}
};
// Initialize the debug system
window.MarkitectDebugSystem.init().then(() => {
console.log('📊 Markitect Debug System initialized');
// Add initial message
window.MarkitectDebugSystem.addMessage('Markitect Debug System initialized', 'INFO', 'DebugSystem', {eventType: 'SYSTEM'});
}).catch(error => {
console.warn('📊 Debug System initialization failed, using memory only:', error);
window.MarkitectDebugSystem.addMessage('Debug System initialized (memory only)', 'WARNING', 'DebugSystem', {eventType: 'SYSTEM'});
});
} catch (error) {
console.error("Debug System initialization failed:", error);
}
// Step 8: Initialize DebugControl (new implementation based on Control class)
try {
const debugControl = Object.create(Control);
// Configure for debug functionality
debugControl.config = {
icon: '🪲',
title: 'Debug',
className: 'debug-control',
defaultContent: 'Debug panel controls',
ariaLabel: 'Debug Control',
position: 'ese' // East-south-east positioning
};
// Override buildContent method for debug functionality
debugControl.buildContent = function() {
console.log("🪲 Building debug control content...");
try {
const content = this.element.querySelector('.control-content');
if (!content) {
console.error("🪲 Debug control content element not found");
return;
}
// Build debug control panel with selection and filtering
content.innerHTML = `
<div style="padding: 0.5rem;">
<!-- Debug Control Header -->
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;
padding-bottom: 0.5rem; border-bottom: 1px solid #eee;">
<h4 style="margin: 0; font-size: 0.9rem; font-weight: 600;">Debug Messages</h4>
<div>
<button onclick="window.MarkitectDebugSystem?.scanDocumentStructure()"
style="padding: 0.25rem 0.5rem; background: #28a745; color: white;
border: none; border-radius: 3px; cursor: pointer; font-size: 0.7rem; margin-right: 0.25rem;">
📊 Scan
</button>
<button onclick="window.MarkitectDebugSystem?.clearMessages()"
style="padding: 0.25rem 0.5rem; background: #dc3545; color: white;
border: none; border-radius: 3px; cursor: pointer; font-size: 0.7rem;">
Clear
</button>
</div>
</div>
<!-- Selection Controls -->
<div style="margin-bottom: 0.5rem; padding: 0.5rem; background: #f8f9fa; border-radius: 4px; font-size: 0.8rem;">
<div style="font-weight: bold; margin-bottom: 0.25rem;">Event Types:</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.25rem;">
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="checkbox" id="docEvents" checked onchange="window.MarkitectDebugSystem.selectionCriteria.includeDocumentEvents = this.checked" style="margin-right: 0.25rem;">
<span style="font-size: 0.75rem;">Document</span>
</label>
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="checkbox" id="editEvents" checked onchange="window.MarkitectDebugSystem.selectionCriteria.includeEditingEvents = this.checked" style="margin-right: 0.25rem;">
<span style="font-size: 0.75rem;">Editing</span>
</label>
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="checkbox" id="ctrlEvents" checked onchange="window.MarkitectDebugSystem.selectionCriteria.includeControlEvents = this.checked" style="margin-right: 0.25rem;">
<span style="font-size: 0.75rem;">Controls</span>
</label>
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="checkbox" id="sysEvents" onchange="window.MarkitectDebugSystem.selectionCriteria.includeSystemEvents = this.checked" style="margin-right: 0.25rem;">
<span style="font-size: 0.75rem;">System</span>
</label>
</div>
</div>
<!-- Debug Switch -->
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.5rem;
padding: 0.5rem; background: #e9ecef; border-radius: 4px;">
<span style="font-size: 0.9rem; font-weight: bold;">Debug System:</span>
<label style="display: flex; align-items: center; cursor: pointer;">
<input type="checkbox" id="debugToggle" checked
onchange="window.MarkitectDebugSystem.isEnabled = this.checked; document.getElementById('debugStatus').textContent = this.checked ? 'ON' : 'OFF'; document.getElementById('debugStatus').style.color = this.checked ? '#28a745' : '#dc3545';"
style="margin-right: 0.5rem;">
<span id="debugStatus" style="color: #28a745; font-weight: bold;">ON</span>
</label>
</div>
<!-- Debug Messages Container -->
<div id="debugMessagesContainer" style="max-height: 180px; overflow-y: auto;
border: 1px solid #ddd; border-radius: 4px;
background: #f8f9fa; font-family: monospace; font-size: 0.8rem;">
<div style="padding: 0.5rem; color: #666; text-align: center;">
Click 📊 Scan to analyze document structure
</div>
</div>
<!-- Export and Tools -->
<div style="margin-top: 0.5rem; display: flex; gap: 0.25rem;">
<button onclick="navigator.clipboard.writeText(window.MarkitectDebugSystem.exportMessages()).then(() => window.MarkitectDebugSystem.addMessage('Debug messages exported to clipboard', 'SUCCESS', 'DebugControl'))"
style="flex: 1; padding: 0.4rem; background: #17a2b8; color: white;
border: none; border-radius: 3px; cursor: pointer; font-size: 0.8rem;">
📋 Export
</button>
<button onclick="console.table(window.MarkitectDebugSystem.getSelectionCriteria())"
style="flex: 1; padding: 0.4rem; background: #6f42c1; color: white;
border: none; border-radius: 3px; cursor: pointer; font-size: 0.8rem;">
🔍 Criteria
</button>
</div>
</div>
`;
console.log("🪲 Debug control HTML content built successfully");
// Note: Debug switch state is set directly in HTML (checked attribute)
// No need to call updateDebugStatus() since checkbox manages its own state
// Set up periodic update of debug messages
try {
this.setupMessageUpdates();
} catch (e) {
console.log("🪲 Message updates setup failed:", e);
}
// Show panel and call expand
console.log("🪲 Calling expand...");
this.expand();
console.log("🪲 Expand called successfully");
} catch (error) {
console.error("🪲 Error in buildContent:", error);
// Fallback simple content
const content = this.element.querySelector('.control-content');
if (content) {
content.innerHTML = `<div style="padding: 1rem; color: #666;">Debug control error: ${error.message}</div>`;
this.expand();
}
}
};
// Add method to update debug switch status (updated for new debug system)
debugControl.updateDebugStatus = function() {
const checkbox = this.element.querySelector('#debugToggle');
const statusText = this.element.querySelector('#debugStatus');
if (checkbox && statusText) {
try {
if (window.MarkitectDebugSystem) {
// Use the new debug system's enabled state
const isEnabled = window.MarkitectDebugSystem.isEnabled;
checkbox.checked = isEnabled;
statusText.textContent = isEnabled ? 'ON' : 'OFF';
statusText.style.color = isEnabled ? '#28a745' : '#dc3545';
} else {
// Fallback when debug system is not available
checkbox.checked = false;
statusText.textContent = 'UNAVAILABLE';
statusText.style.color = '#dc3545';
}
} catch (e) {
console.log("🪲 Error updating debug status:", e);
checkbox.checked = false;
statusText.textContent = 'ERROR';
statusText.style.color = '#dc3545';
}
}
};
// Add method to setup message updates
debugControl.setupMessageUpdates = function() {
try {
// Update messages every 500ms when debug control is open
this.messageUpdateInterval = setInterval(() => {
if (this.isExpanded && !this.isHeaderOnly) {
this.updateMessages();
// Note: updateDebugStatus() removed - checkbox manages its own state
}
}, 500);
console.log("🪲 Message update interval set up successfully");
} catch (e) {
console.log("🪲 Failed to set up message updates:", e);
}
};
// Add method to update debug messages display using new system
debugControl.updateMessages = function() {
const container = this.element.querySelector('#debugMessagesContainer');
if (!container) return;
try {
if (window.MarkitectDebugSystem) {
const messages = window.MarkitectDebugSystem.getRecentMessages(50);
if (messages && messages.length > 0) {
// Show messages in reverse order (newest first)
const reversedMessages = [...messages].reverse();
container.innerHTML = `
<div style="padding: 0.25rem 0.5rem; background: #e9ecef; font-size: 0.7rem; color: #6c757d;">
${messages.length} messages (newest first) |
<span onclick="window.MarkitectDebugSystem.addMessage('Test message from debug control', 'INFO', 'DebugControl', {eventType: 'CONTROL'})"
style="cursor: pointer; text-decoration: underline;">Add Test</span>
</div>
${reversedMessages.map(msg => `
<div style="padding: 0.25rem 0.5rem; border-bottom: 1px solid #e9ecef;
color: ${this.getMessageColor(msg.category)};">
<div style="display: flex; justify-content: space-between; align-items: flex-start;">
<div style="flex: 1;">
<small style="color: #6c757d;">[${msg.displayTime}]</small>
<span style="font-weight: bold; margin: 0 0.25rem 0 0.5rem;">${msg.category}:</span>
${msg.message}
</div>
${msg.source ? `<small style="color: #868e96; font-size: 0.7rem; margin-left: 0.5rem;">${msg.source}</small>` : ''}
</div>
${msg.context && msg.context.headingId ? `
<div style="font-size: 0.7rem; color: #6c757d; padding-left: 1rem;">
📍 Section: ${msg.context.headingId}
</div>
` : ''}
</div>
`).join('')}
`;
} else {
container.innerHTML = `
<div style="padding: 0.5rem; color: #666; text-align: center;">
No debug messages yet<br>
<button onclick="window.MarkitectDebugSystem.addMessage('First debug message', 'INFO')"
style="margin-top: 0.5rem; padding: 0.25rem 0.5rem; background: #28a745; color: white; border: none; border-radius: 3px; cursor: pointer;">
Add Test Message
</button>
</div>
`;
}
} else {
container.innerHTML = `
<div style="padding: 0.5rem; color: #dc3545; text-align: center;">
Debug system not initialized
</div>
`;
}
} catch (e) {
console.log("🪲 Error updating messages:", e);
container.innerHTML = `
<div style="padding: 0.5rem; color: #dc3545; text-align: center;">
Error loading messages: ${e.message}
</div>
`;
}
};
// Add method to get message colors by type
debugControl.getMessageColor = function(type) {
const colors = {
'ERROR': '#dc3545',
'WARNING': '#fd7e14',
'SUCCESS': '#28a745',
'INFO': '#17a2b8',
'DEBUG': '#6f42c1'
};
return colors[type] || '#495057';
};
// Override collapse to clean up intervals
const originalCollapse = debugControl.collapse;
debugControl.collapse = function() {
if (this.messageUpdateInterval) {
clearInterval(this.messageUpdateInterval);
this.messageUpdateInterval = null;
}
originalCollapse.call(this);
};
// Create and show the debug control
console.log("🪲 Creating debug control...");
debugControl.createControl();
console.log("🪲 Debug control created, element:", debugControl.element);
// Make debug control globally accessible
window.debugControl = debugControl;
console.log("🪲 Debug control setup complete and globally accessible");
} catch (error) {
console.error("DebugControl failed to initialize:", error);
}
// Wire up event handlers
documentControls.setEventHandlers({
'save-document': () => {
console.log('Save document clicked');
try {
// Get current markdown content from section manager
const currentMarkdown = sectionManager.getDocumentMarkdown();
// Create filename with timestamp suffix following the established convention
const now = new Date();
const timestamp = now.toISOString().slice(0, 19).replace(/:/g, '-').replace('T', '-');
// Extract original filename from config or use default
const originalFilename = window.editorConfig?.originalFilename || 'document';
const editedFilename = `${originalFilename}-edited-${timestamp}.md`;
// Create and download the file
const blob = new Blob([currentMarkdown], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = editedFilename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
// Log success to debug system
window.MarkitectDebugSystem?.addMessage(`Document saved as: ${editedFilename}`, 'SUCCESS', 'DocumentSaver', {eventType: 'DOCUMENT'});
console.log(`Document successfully saved as: ${editedFilename}`);
} catch (error) {
window.MarkitectDebugSystem?.addMessage(`Save failed: ${error.message}`, 'ERROR', 'DocumentSaver', {eventType: 'DOCUMENT'});
console.error('Save error:', error);
}
},
'reset-all': () => {
console.log('Reset all clicked');
// Hide any open editors
domRenderer.hideCurrentEditor();
// Reset all sections to original state
const allSections = Array.from(sectionManager.sections.values());
allSections.forEach(section => {
section.resetToOriginal();
});
// Re-render all sections
domRenderer.renderAllSections(allSections);
window.MarkitectDebugSystem?.addMessage(`Reset all sections to original state`, 'INFO', 'SectionManager', {eventType: 'EDITING'});
}
});
// Set up debug system integration with section-aware logging
sectionManager.on('sections-created', (data) => {
window.MarkitectDebugSystem?.addMessage(`Created ${data.count} sections`, 'INFO', 'SectionManager', {eventType: 'DOCUMENT'});
});
sectionManager.on('edit-started', (data) => {
window.MarkitectDebugSystem?.addMessage(`Edit started for section: ${data.sectionId}`, 'DEBUG', 'SectionManager', {eventType: 'EDITING', sectionId: data.sectionId});
});
sectionManager.on('changes-accepted', (data) => {
window.MarkitectDebugSystem?.addMessage(`Changes accepted for section: ${data.sectionId}`, 'SUCCESS', 'SectionManager', {eventType: 'EDITING', sectionId: data.sectionId});
// Re-render the section to show updated content
const section = sectionManager.sections.get(data.sectionId);
if (section) {
const sectionElement = domRenderer.findSectionElement(data.sectionId);
if (sectionElement) {
const newElement = domRenderer.renderSection(section);
sectionElement.parentNode.replaceChild(newElement, sectionElement);
window.MarkitectDebugSystem?.addMessage(`DOM updated for section: ${data.sectionId}`, 'INFO', 'DOMRenderer', {eventType: 'EDITING', sectionId: data.sectionId});
}
}
});
sectionManager.on('changes-cancelled', (data) => {
window.MarkitectDebugSystem?.addMessage(`Changes cancelled for section: ${data.sectionId}`, 'WARNING', 'SectionManager', {eventType: 'EDITING', sectionId: data.sectionId});
});
// Initialize with markdown content
const markdownToRender = markdownContent || '';
if (markdownToRender.trim()) {
const sections = sectionManager.createSectionsFromMarkdown(markdownToRender);
domRenderer.renderAllSections(sections);
window.MarkitectDebugSystem?.addMessage(`Initialized with ${sections.length} sections`, 'INFO', 'DocumentRenderer', {eventType: 'DOCUMENT'});
} else {
window.MarkitectDebugSystem?.addMessage('No markdown content to initialize', 'WARNING', 'DocumentRenderer', {eventType: 'DOCUMENT'});
}
// Make components globally available for debugging
window.markitectComponents = {
sectionManager,
domRenderer,
debugPanel,
documentControls
};
console.log('Markitect modular editor initialized successfully');
});
// Always render content first (graceful degradation)
document.addEventListener('DOMContentLoaded', function() {
console.log("Rendering content...");
// Check if modular components are being used
if (typeof SectionManager !== 'undefined') {
console.log("✓ Modular components detected - skipping direct content rendering");
console.log("✓ Content will be rendered by modular architecture");
return;
}
const contentDiv = document.getElementById('markdown-content');
// Step 1: Ensure content is always displayed (fallback for non-modular mode)
if (contentDiv) {
if (typeof marked !== 'undefined') {
try {
const html = marked.parse(markdownContentWithDogtag);
// Add target="_blank" to all links
const htmlWithTargetBlank = html.replace(/<a href="([^"]*)"([^>]*)>/g, '<a href="$1" target="_blank"$2>');
contentDiv.innerHTML = htmlWithTargetBlank;
console.log("✓ Content rendered successfully");
console.log('✓ Markdown rendered successfully');
} catch (error) {
contentDiv.innerHTML = '<p>Error rendering markdown: ' + error.message + '</p>';
console.error("Content rendered with errors");
console.error("Markdown parsing failed:", error.message);
}
} else {
// Fallback: display raw markdown with basic formatting
const fallbackHtml = markdownContent
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/^- (.*$)/gim, '<li>$1</li>')
.replace(/\n\n/g, '<br><br>')
.replace(/\n/g, '<br>');
contentDiv.innerHTML = '<div style="white-space: pre-wrap;">' + fallbackHtml + '</div>';
console.warn("Content rendered with fallback parser");
console.warn("CDN library failed to load - using basic fallback rendering");
}
}
// Step 2: Initialize edit/insert capabilities if enabled
if ((typeof MARKITECT_EDIT_MODE !== 'undefined' && MARKITECT_EDIT_MODE) ||
(typeof MARKITECT_INSERT_MODE !== 'undefined' && MARKITECT_INSERT_MODE)) {
const mode = (typeof MARKITECT_INSERT_MODE !== 'undefined' && MARKITECT_INSERT_MODE) ? 'insert' : 'edit';
console.log(`Initializing clean ${mode} capabilities...`);
try {
console.log("Creating clean editor instance...");
initializeCleanEditor();
if (mode === 'insert') {
console.log("✓ Clean insert mode active - click any section to edit (headings 1-3 protected)");
} else {
console.log("✓ Clean edit mode active - click any section to edit");
}
} catch (error) {
console.error(`Clean ${mode} mode failed to initialize:`, error);
}
}
// Step 3: Initialize document scroll indicators (always available)
try {
initializeScrollIndicators();
} catch (error) {
console.error("Scroll indicators failed to initialize:", error);
}
// Step 4: Define abstract Control class for UI controls
const Control = {
// Abstract control properties
element: null,
isExpanded: false,
isHeaderOnly: false, // New state for header-only mode
isDragging: false,
isResizing: false, // New state for resizing mode
dragOffset: { x: 0, y: 0 },
resizeStartSize: { width: 280, height: 'auto' },
originalPosition: { top: '80px', left: '20px' },
defaultSize: { width: 280, minWidth: 200, minHeight: 150 },
// Configuration properties (to be overridden by subclasses)
config: {
icon: '?',
title: 'Control',
className: 'control',
defaultContent: 'Template only',
ariaLabel: 'Control',
position: 'w' // Default compass position: west (middle-left)
},
// Compass positioning system (top-aligned for proper expansion)
compassPositions: {
// North positions (top)
'n': { top: '20px', left: '50%', transform: 'translateX(-50%)' },
'nne': { top: '20px', left: '65%', transform: 'translateX(-50%)' },
'ne': { top: '20px', right: '20px' },
'ene': { top: '80px', right: '20px' }, // Top-aligned instead of center
// East positions (right)
'e': { top: '50vh', right: '20px', transform: 'translateY(-20px)' }, // Anchor at icon level
'ese': { top: 'calc(65vh - 20px)', right: '20px' }, // Top-aligned
'se': { bottom: '20px', right: '20px' },
'sse': { bottom: '20px', right: '35%', transform: 'translateX(50%)' },
// South positions (bottom)
's': { bottom: '20px', left: '50%', transform: 'translateX(-50%)' },
'ssw': { bottom: '20px', left: '35%', transform: 'translateX(-50%)' },
'sw': { bottom: '20px', left: '20px' },
'wsw': { bottom: '80px', left: '20px' }, // Top-aligned instead of center
// West positions (left) - top-aligned for proper expansion
'w': { top: '50vh', left: '20px', transform: 'translateY(-20px)' }, // Anchor at icon level
'wnw': { top: '80px', left: '20px' }, // Top-aligned instead of center
'nw': { top: '20px', left: '20px' },
'nnw': { top: '20px', left: '35%', transform: 'translateX(-50%)' }
},
// Get expansion direction based on compass position
getExpansionDirection: function() {
const pos = this.config.position;
const rightBorderPositions = ['ne', 'ene', 'e', 'ese', 'se'];
const bottomBorderPositions = ['sw', 'ssw', 's', 'sse', 'se'];
return {
header: rightBorderPositions.includes(pos) ? 'left' : 'right',
body: bottomBorderPositions.includes(pos) ? 'up' : 'down'
};
},
// Calculate position styles based on compass direction
getPositionStyles: function() {
const compassPos = this.compassPositions[this.config.position] || this.compassPositions['w'];
return {
position: 'fixed',
top: compassPos.top || 'auto',
right: compassPos.right || 'auto',
bottom: compassPos.bottom || 'auto',
left: compassPos.left || 'auto',
transform: compassPos.transform || 'none',
zIndex: 1000
};
},
// Abstract methods (to be implemented by subclasses)
buildContent: function() {
const content = this.element.querySelector('.control-content');
content.innerHTML = `<p style="padding: 1rem; color: #666;">${this.config.defaultContent}</p>`;
},
// Concrete methods (shared by all controls)
createControl: function() {
console.log(`🎛️ Creating ${this.config.title} control...`);
this.element = document.createElement('div');
this.element.className = this.config.className;
this.element.innerHTML = `
<button class="control-toggle" aria-label="${this.config.ariaLabel}">${this.config.icon}</button>
<div class="control-panel" style="display: none;">
<div class="control-header">
<span class="control-icon">${this.config.icon}</span>
<h3>${this.config.title}</h3>
<button class="control-close"></button>
</div>
<div class="control-content">Loading...</div>
</div>
`;
// Position using compass direction
const positionStyles = this.getPositionStyles();
this.element.style.cssText = `
position: ${positionStyles.position};
top: ${positionStyles.top};
right: ${positionStyles.right};
bottom: ${positionStyles.bottom};
left: ${positionStyles.left};
transform: ${positionStyles.transform};
z-index: ${positionStyles.zIndex};
background: rgba(255, 255, 255, 0.95);
border: 1px solid #e1e5e9;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
backdrop-filter: blur(8px);
width: 40px;
transition: all 0.3s ease;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
`;
// Store original position for reset
this.originalPosition = {
top: positionStyles.top,
right: positionStyles.right,
bottom: positionStyles.bottom,
left: positionStyles.left,
transform: positionStyles.transform
};
// Style toggle button
const toggleBtn = this.element.querySelector('.control-toggle');
toggleBtn.style.cssText = `
width: 100%;
height: 40px;
border: none;
background: transparent;
cursor: pointer;
font-size: 16px;
color: #666;
transition: color 0.2s ease;
`;
// Handle click to build content on-demand
toggleBtn.addEventListener('click', () => {
if (this.isExpanded) {
this.collapse();
} else {
console.log(`🎛️ ${this.config.title} toggle clicked - building content...`);
this.buildContent();
}
});
// Close button handler
const closeBtn = this.element.querySelector('.control-close');
closeBtn.addEventListener('click', () => {
this.collapse();
});
// Responsive behavior
window.addEventListener('resize', () => {
if (window.innerWidth <= 768) {
this.element.style.display = 'none';
} else {
this.element.style.display = '';
}
});
document.body.appendChild(this.element);
// Hide on mobile
if (window.innerWidth <= 768) {
this.element.style.display = 'none';
}
console.log(`🎛️ ${this.config.title} control created`);
},
styleHeader: function() {
const header = this.element.querySelector('.control-header');
// Style the header to show icon, title, and close button in one line
// Match the height of the collapsed icon state (40px)
header.style.cssText = `
display: flex;
align-items: center;
justify-content: space-between;
height: 40px;
padding: 0 1rem;
border-bottom: 1px solid #eee;
margin-bottom: 0;
`;
const icon = header.querySelector('.control-icon');
if (icon) {
icon.style.cssText = `
font-size: 16px;
color: #666;
margin-right: 0.5rem;
cursor: grab;
user-select: none;
`;
// Make icon draggable
this.setupDragHandlers(icon);
}
const title = header.querySelector('h3');
if (title) {
title.style.cssText = `
margin: 0;
font-size: 0.9rem;
font-weight: 600;
flex-grow: 1;
line-height: 1;
cursor: pointer;
user-select: none;
`;
// Add click handler to toggle header-only mode
title.addEventListener('click', () => {
this.toggleHeaderOnly();
});
}
const closeBtn = header.querySelector('.control-close');
if (closeBtn) {
closeBtn.style.cssText = `
background: none;
border: none;
font-size: 14px;
cursor: pointer;
color: #666;
padding: 0;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
`;
}
},
styleContent: function() {
const content = this.element.querySelector('.control-content');
const expansion = this.getExpansionDirection();
// Style the content area based on expansion direction
let contentStyles = `
padding: 0.5rem;
overflow-y: auto;
`;
if (expansion.body === 'up') {
// Body expands upward (for bottom border positions)
contentStyles += `
max-height: calc(80vh - 40px);
`;
content.parentElement.style.flexDirection = 'column-reverse';
} else {
// Body expands downward (default)
contentStyles += `
max-height: calc(80vh - 40px);
`;
content.parentElement.style.flexDirection = 'column';
}
content.style.cssText = contentStyles;
},
expand: function() {
this.isExpanded = true;
const panel = this.element.querySelector('.control-panel');
const toggleBtn = this.element.querySelector('.control-toggle');
// Get expansion direction based on compass position
const expansion = this.getExpansionDirection();
// Apply expansion styling based on direction
if (expansion.header === 'left') {
// Header expands to the left (for right border positions)
this.element.style.width = '280px';
this.element.style.transformOrigin = 'top right';
} else {
// Header expands to the right (default)
this.element.style.width = '280px';
this.element.style.transformOrigin = 'top left';
}
panel.style.display = 'block';
toggleBtn.style.display = 'none';
this.styleHeader();
this.styleContent();
this.addResizeHandle();
},
collapse: function() {
this.isExpanded = false;
this.isHeaderOnly = false; // Reset header-only state
const panel = this.element.querySelector('.control-panel');
const toggleBtn = this.element.querySelector('.control-toggle');
panel.style.display = 'none';
// Reset size to default
this.element.style.width = '40px';
this.element.style.height = 'auto';
// Remove resize handle
this.removeResizeHandle();
toggleBtn.style.display = 'block';
// Reset position to original compass location
this.element.style.top = this.originalPosition.top;
this.element.style.right = this.originalPosition.right;
this.element.style.bottom = this.originalPosition.bottom;
this.element.style.left = this.originalPosition.left;
this.element.style.transform = this.originalPosition.transform;
},
toggleHeaderOnly: function() {
if (!this.isExpanded) {
// If collapsed, first expand normally
this.buildContent();
return;
}
const content = this.element.querySelector('.control-content');
if (this.isHeaderOnly) {
// Show content area (go to full expanded mode)
this.isHeaderOnly = false;
content.style.display = 'block';
console.log(`🎛️ ${this.config.title} expanded to full view`);
} else {
// Hide content area (go to header-only mode)
this.isHeaderOnly = true;
content.style.display = 'none';
console.log(`🎛️ ${this.config.title} collapsed to header only`);
}
},
setupDragHandlers: function(dragElement) {
dragElement.addEventListener('mousedown', (e) => {
this.isDragging = true;
const rect = this.element.getBoundingClientRect();
const iconRect = dragElement.getBoundingClientRect();
// Calculate offset relative to the icon position, not the element
this.dragOffset.x = e.clientX - rect.left;
this.dragOffset.y = iconRect.top - rect.top + (iconRect.height / 2); // Keep mouse at icon center
dragElement.style.cursor = 'grabbing';
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!this.isDragging || !this.isExpanded) return;
const newX = e.clientX - this.dragOffset.x;
const newY = e.clientY - this.dragOffset.y;
// Keep within viewport bounds
const maxX = window.innerWidth - this.element.offsetWidth;
const maxY = window.innerHeight - this.element.offsetHeight;
const boundedX = Math.max(0, Math.min(newX, maxX));
const boundedY = Math.max(0, Math.min(newY, maxY));
this.element.style.left = boundedX + 'px';
this.element.style.top = boundedY + 'px';
});
document.addEventListener('mouseup', () => {
if (this.isDragging) {
this.isDragging = false;
dragElement.style.cursor = 'grab';
}
});
},
// Add resize handle to expanded control
addResizeHandle: function() {
// Remove existing resize handle if any
this.removeResizeHandle();
const resizeHandle = document.createElement('div');
resizeHandle.className = 'control-resize-handle';
resizeHandle.innerHTML = '↘';
resizeHandle.style.cssText = `
position: absolute;
bottom: 0;
right: 0;
width: 20px;
height: 20px;
background: rgba(108, 117, 125, 0.8);
color: white;
cursor: nw-resize;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
line-height: 1;
border-top-left-radius: 4px;
user-select: none;
z-index: 1001;
`;
this.element.appendChild(resizeHandle);
this.setupResizeHandlers(resizeHandle);
},
// Remove resize handle
removeResizeHandle: function() {
const existingHandle = this.element.querySelector('.control-resize-handle');
if (existingHandle) {
existingHandle.remove();
}
},
// Set up resize event handlers
setupResizeHandlers: function(resizeHandle) {
resizeHandle.addEventListener('mousedown', (e) => {
this.isResizing = true;
const rect = this.element.getBoundingClientRect();
this.resizeStartSize = {
width: rect.width,
height: rect.height,
startX: e.clientX,
startY: e.clientY
};
resizeHandle.style.cursor = 'nw-resize';
resizeHandle.style.background = 'rgba(40, 167, 69, 0.9)';
e.preventDefault();
e.stopPropagation(); // Prevent triggering drag
});
document.addEventListener('mousemove', (e) => {
if (!this.isResizing || !this.isExpanded) return;
const deltaX = e.clientX - this.resizeStartSize.startX;
const deltaY = e.clientY - this.resizeStartSize.startY;
const newWidth = Math.max(this.defaultSize.minWidth, this.resizeStartSize.width + deltaX);
const newHeight = Math.max(this.defaultSize.minHeight, this.resizeStartSize.height + deltaY);
// Check viewport bounds
const maxWidth = window.innerWidth - this.element.offsetLeft;
const maxHeight = window.innerHeight - this.element.offsetTop;
const boundedWidth = Math.min(newWidth, maxWidth - 20);
const boundedHeight = Math.min(newHeight, maxHeight - 20);
this.element.style.width = boundedWidth + 'px';
this.element.style.height = boundedHeight + 'px';
// Ensure content areas resize properly
this.updateContentSize();
});
document.addEventListener('mouseup', () => {
if (this.isResizing) {
this.isResizing = false;
resizeHandle.style.cursor = 'nw-resize';
resizeHandle.style.background = 'rgba(108, 117, 125, 0.8)';
}
});
},
// Update content area sizes during resize
updateContentSize: function() {
const content = this.element.querySelector('.control-content');
if (content) {
// Adjust content height to fit the resized control
const headerHeight = 40; // Header is 40px
const padding = 16; // Account for padding
const controlHeight = this.element.offsetHeight;
const availableHeight = controlHeight - headerHeight - padding;
content.style.maxHeight = Math.max(100, availableHeight) + 'px';
}
}
};
// Step 5: Initialize ContentsControl (new implementation based on Control class)
try {
const contentsControl = Object.create(Control);
// Configure for contents navigation
contentsControl.config = {
icon: '☰',
title: 'Contents',
className: 'contents-control',
defaultContent: 'No headings found',
ariaLabel: 'Document Navigation',
position: 'wnw' // West-north-west positioning
};
// Override buildContent method for navigation functionality
contentsControl.buildContent = function() {
const content = this.element.querySelector('.control-content');
// Build navigation content from current DOM
const allHeadings = document.querySelectorAll('h1, h2, h3');
// Filter out headings that contain "Contents" or similar navigation-related text
const headings = Array.from(allHeadings).filter(heading => {
const text = heading.textContent.trim().toLowerCase();
return !text.includes('contents') && !text.includes('table of contents') && !text.includes('navigation');
});
console.log("📋 Found headings for navigation:", headings.length);
if (headings.length === 0) {
content.innerHTML = '<p style="padding: 1rem; color: #666;">No headings found</p>';
} else {
let navHtml = '';
headings.forEach((heading, index) => {
if (!heading.id) {
heading.id = `heading-${index + 1}`;
}
const level = parseInt(heading.tagName.substring(1));
const indent = (level - 1) * 1;
navHtml += `
<a href="#${heading.id}"
style="display: block; padding: 0.5rem; margin-left: ${indent}rem;
text-decoration: none; color: #333; font-size: 0.9rem;
border-radius: 4px; cursor: pointer;"
onmouseover="this.style.backgroundColor='#f5f5f5'"
onmouseout="this.style.backgroundColor=''"
onclick="event.preventDefault(); document.getElementById('${heading.id}').scrollIntoView({behavior: 'smooth'}); if (window.innerWidth <= 768) setTimeout(() => contentsControl.collapse(), 500);">
${heading.textContent.trim()}
</a>
`;
});
content.innerHTML = navHtml;
}
// Show panel
this.expand();
};
// Initialize the ContentsControl
contentsControl.createControl();
// Make globally available for mobile collapse
window.contentsControl = contentsControl;
} catch (error) {
console.error("ContentsControl failed to initialize:", error);
}
});
// Handle CDN loading errors
window.addEventListener('load', function() {
if (window.markitectMarkedError) {
console.error("CDN library failed to load - network or firewall blocking marked.js");
}
});
</script>
</body>
</html>