markitect-main/markitect/static/editor.js

1769 lines
59 KiB
JavaScript
Raw Normal View History

// Clean Editor Architecture
/**
* Test-Driven Section Editor Implementation
*
* A clean, object-oriented approach to handling section editing
* that can be tested independently of the DOM.
*/
// Debug system - choose one of: 'off', 'console', 'alerts'
const DEBUG_MODE = 'console';
// Advanced State Management
const EditState = Object.freeze({
ORIGINAL: 'original',
EDITING: 'editing',
MODIFIED: 'modified',
SAVED: 'saved'
});
function debug(message, category = 'INFO') {
const prefix = `DEBUG ${category}:`;
switch (DEBUG_MODE) {
case 'off':
// No debugging output
break;
case 'console':
console.log(prefix, message);
break;
case 'alerts':
alert(`${prefix} ${message}`);
console.log(prefix, message); // Also log to console for reference
break;
default:
console.warn('Invalid DEBUG_MODE. Use: off, console, or alerts');
}
}
// Enums for clear state management (already defined above)
const SectionType = Object.freeze({
HEADING: 'heading',
PARAGRAPH: 'paragraph',
LIST: 'list',
CODE: 'code',
QUOTE: 'quote',
IMAGE: 'image',
OTHER: 'other'
});
/**
* Section class - Represents a single editable section
*/
class Section {
constructor(id, markdown, type) {
this.id = id;
this.originalMarkdown = markdown;
this.currentMarkdown = markdown;
this.editingMarkdown = markdown;
this.pendingMarkdown = null;
this.type = type;
this.state = EditState.ORIGINAL;
this.domElement = null;
this.lastSaved = null;
this.created = new Date();
}
static generateId(markdown, position) {
const cleanText = markdown.replace(/[^a-zA-Z0-9]/g, '');
const hash = cleanText.substring(0, 8) + position;
return `section-${hash}`;
}
static detectType(markdown) {
const trimmed = markdown.trim();
if (trimmed.startsWith('#')) return SectionType.HEADING;
if (trimmed.startsWith('```')) return SectionType.CODE;
if (trimmed.startsWith('>')) return SectionType.QUOTE;
if (trimmed.includes('![')) return SectionType.IMAGE;
if (trimmed.startsWith('-') || trimmed.startsWith('*') || trimmed.startsWith('1.')) return SectionType.LIST;
return SectionType.PARAGRAPH;
}
startEdit() {
if (this.state === EditState.EDITING) {
throw new Error(`Section ${this.id} is already being edited`);
}
this.editingMarkdown = this.pendingMarkdown || this.currentMarkdown;
this.state = EditState.EDITING;
return this.editingMarkdown;
}
updateContent(markdown) {
if (this.state !== EditState.EDITING) {
throw new Error(`Section ${this.id} is not in editing state`);
}
this.editingMarkdown = markdown;
}
acceptChanges() {
if (this.state !== EditState.EDITING) {
throw new Error(`Section ${this.id} is not in editing state`);
}
this.currentMarkdown = this.editingMarkdown;
this.editingMarkdown = null;
this.pendingMarkdown = null;
this.state = EditState.SAVED;
this.lastSaved = new Date();
return this.currentMarkdown;
}
cancelChanges() {
if (this.state !== EditState.EDITING) {
throw new Error(`Section ${this.id} is not in editing state`);
}
this.editingMarkdown = null;
if (this.pendingMarkdown !== null) {
this.state = EditState.MODIFIED;
return this.pendingMarkdown;
} else if (this.lastSaved !== null) {
this.state = EditState.SAVED;
return this.currentMarkdown;
} else {
this.state = this.hasChanges() ? EditState.MODIFIED : EditState.ORIGINAL;
return this.currentMarkdown;
}
}
stopEditing() {
if (this.state !== EditState.EDITING) {
return this.state;
}
// If we have editing changes that differ from current content, preserve them as pending
if (this.editingMarkdown && this.editingMarkdown !== this.currentMarkdown) {
this.pendingMarkdown = this.editingMarkdown;
this.state = EditState.MODIFIED; // Has pending changes
} else {
// No changes made during this edit session
this.pendingMarkdown = null;
if (this.lastSaved !== null) {
this.state = EditState.SAVED;
} else {
this.state = this.hasChanges() ? EditState.MODIFIED : EditState.ORIGINAL;
}
}
this.editingMarkdown = null;
return this.state;
}
resetToOriginal() {
this.currentMarkdown = this.originalMarkdown;
this.editingMarkdown = this.originalMarkdown;
this.pendingMarkdown = null;
this.state = EditState.ORIGINAL;
return this.originalMarkdown;
}
isEditing() {
return this.state === EditState.EDITING;
}
hasChanges() {
return this.currentMarkdown !== this.originalMarkdown;
}
getStatus() {
return {
id: this.id,
state: this.state,
hasChanges: this.hasChanges(),
isEditing: this.isEditing(),
contentLength: this.currentMarkdown.length,
lastSaved: this.lastSaved,
sectionType: this.sectionType,
// Legacy compatibility
type: this.type,
originalLength: this.originalMarkdown.length,
currentLength: this.currentMarkdown.length
};
}
isImage() {
return this.type === SectionType.IMAGE;
}
isProtectedHeading() {
// In insert mode, headings 1-3 are protected
if (this.type === SectionType.HEADING) {
const headingMatch = this.originalMarkdown.match(/^(#{1,3})\s/);
if (headingMatch) {
this.headingLevel = headingMatch[1].length;
return this.headingLevel <= 3;
}
}
return false;
}
getHeadingContent() {
if (this.type === SectionType.HEADING) {
const lines = this.currentMarkdown.split('\n');
return lines.slice(1).join('\n').trim();
}
return this.currentMarkdown;
}
}
/**
* SectionManager class - Manages the collection of sections
*/
class SectionManager {
constructor() {
this.sections = new Map();
this.listeners = new Map();
this.statusInterval = null;
this.lastStatusUpdate = new Date().toISOString();
}
on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event).push(callback);
}
emit(event, data) {
if (this.listeners.has(event)) {
this.listeners.get(event).forEach(callback => callback(data));
}
}
createSectionsFromMarkdown(markdownContent) {
const lines = markdownContent.split('\n');
const sections = [];
let currentSection = '';
let position = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const isHeading = /^#{1,6}\s/.test(line);
const isNewParagraph = line.trim() && i > 0 && !lines[i-1].trim();
const isNewSection = isHeading || isNewParagraph;
if (isNewSection && currentSection.trim()) {
const sectionId = Section.generateId(currentSection, position);
const sectionType = Section.detectType(currentSection);
const section = new Section(sectionId, currentSection.trim(), sectionType);
sections.push(section);
this.sections.set(sectionId, section);
position++;
currentSection = line;
} else {
if (currentSection) currentSection += '\n';
currentSection += line;
}
}
if (currentSection.trim()) {
const sectionId = Section.generateId(currentSection, position);
const sectionType = Section.detectType(currentSection);
const section = new Section(sectionId, currentSection.trim(), sectionType);
sections.push(section);
this.sections.set(sectionId, section);
}
this.emit('sections-created', { sections, count: sections.length });
return sections;
}
startEditing(sectionId) {
const section = this.sections.get(sectionId);
if (!section) {
throw new Error(`Section ${sectionId} not found`);
}
if (section.isEditing()) {
console.log('Section already in editing state:', sectionId);
return section.editingMarkdown;
}
const content = section.startEdit();
this.emit('edit-started', { sectionId, content, section: section.getStatus() });
return content;
}
updateContent(sectionId, markdown) {
const section = this.sections.get(sectionId);
if (!section) {
throw new Error(`Section ${sectionId} not found`);
}
section.updateContent(markdown);
this.emit('content-updated', { sectionId, markdown, section: section.getStatus() });
}
acceptChanges(sectionId) {
const section = this.sections.get(sectionId);
if (!section) {
throw new Error(`Section ${sectionId} not found`);
}
const content = section.acceptChanges();
this.emit('changes-accepted', { sectionId, content, section: section.getStatus() });
return content;
}
cancelChanges(sectionId) {
const section = this.sections.get(sectionId);
if (!section) {
throw new Error(`Section ${sectionId} not found`);
}
const content = section.cancelChanges();
this.emit('changes-cancelled', { sectionId, content, section: section.getStatus() });
return content;
}
resetSection(sectionId) {
const section = this.sections.get(sectionId);
if (!section) {
throw new Error(`Section ${sectionId} not found`);
}
const content = section.resetToOriginal();
this.emit('section-reset', { sectionId, content, section: section.getStatus() });
return content;
}
getDocumentMarkdown() {
const sortedSections = Array.from(this.sections.values())
.sort((a, b) => a.created - b.created);
return sortedSections.map(section => section.currentMarkdown).join('\n\n');
}
getAllSections() {
return Array.from(this.sections.values());
}
getSectionStatus() {
return Array.from(this.sections.values()).map(section => section.getStatus());
}
escapeRegex(str) {
return str.replace(/[.*+?^${}()|\[\]\\]/g, '\\\\$&');
}
/**
* Check if new content contains new headings that would require section splitting
* @param {string} newContent - The new content to check
* @param {string} originalContent - The original content to compare against
* @returns {boolean} True if section splitting is needed
*/
checkForSectionSplits(newContent, originalContent) {
const originalHeadings = this.extractHeadings(originalContent);
const newHeadings = this.extractHeadings(newContent);
// If new content has more headings than original, we need to split
return newHeadings.length > originalHeadings.length;
}
/**
* Extract heading lines from markdown content
* @param {string} content - Markdown content
* @returns {Array} Array of heading lines
*/
extractHeadings(content) {
if (!content) return [];
const lines = content.split('\n');
return lines.filter(line => /^#{1,6}\s/.test(line.trim()));
}
/**
* Handle splitting a section when new headings are detected
* @param {string} sectionId - ID of the section to split
* @param {string} newContent - New content with multiple headings
*/
handleSectionSplit(sectionId, newContent) {
const section = this.sections.get(sectionId);
if (!section) {
throw new Error(`Section ${sectionId} not found`);
}
// Remove the original section
this.sections.delete(sectionId);
// Create new sections from the content
const newSections = this.createSectionsFromContent(newContent);
// Emit section-split event
this.emit('section-split', {
originalSectionId: sectionId,
newSections: newSections,
count: newSections.length
});
return newSections;
}
/**
* Create sections from content (alias for createSectionsFromMarkdown)
* @param {string} content - Markdown content
* @returns {Array} Array of created sections
*/
createSectionsFromContent(content) {
return this.createSectionsFromMarkdown(content);
}
/**
* Get global status information about all sections
* @returns {Object} Status object with global information
*/
getGlobalStatus() {
const sections = this.getAllSections();
const editingSections = sections.filter(s => s.isEditing()).map(s => s.id);
const modifiedSections = sections.filter(s => s.hasChanges());
const hasModifications = modifiedSections.length > 0 || editingSections.length > 0;
let state = 'ready';
if (editingSections.length > 0) {
state = 'editing';
} else if (hasModifications) {
state = 'modified';
}
return {
totalSections: sections.length,
editingSections: editingSections,
modifiedSections: modifiedSections.length,
hasModifications: hasModifications,
state: state,
lastUpdate: this.lastStatusUpdate
};
}
/**
* Update global status and emit status-updated event
*/
updateGlobalStatus() {
this.lastStatusUpdate = new Date().toISOString();
const status = this.getGlobalStatus();
this.emit('status-updated', status);
return status;
}
/**
* Start periodic status tracking
* @param {number} intervalMs - Update interval in milliseconds (default: 2000)
*/
startStatusTracking(intervalMs = 2000) {
if (this.statusInterval) {
clearInterval(this.statusInterval);
}
this.statusInterval = setInterval(() => {
this.updateGlobalStatus();
}, intervalMs);
// Emit initial status
this.updateGlobalStatus();
}
/**
* Stop periodic status tracking
*/
stopStatusTracking() {
if (this.statusInterval) {
clearInterval(this.statusInterval);
this.statusInterval = null;
}
}
}
/**
* DOM Renderer - Handles DOM interactions
*/
class DOMRenderer {
constructor(sectionManager, container) {
this.sectionManager = sectionManager;
this.container = container;
this.editingSections = new Set();
this.handleSectionClick = this.handleSectionClick.bind(this);
this.handleAccept = this.handleAccept.bind(this);
this.handleCancel = this.handleCancel.bind(this);
this.handleReset = this.handleReset.bind(this);
this.handleKeydown = this.handleKeydown.bind(this);
this.setupEventListeners();
}
setupEventListeners() {
this.sectionManager.on('sections-created', (data) => {
this.renderAllSections(data.sections);
});
this.sectionManager.on('edit-started', (data) => {
this.showEditor(data.sectionId, data.content);
});
this.sectionManager.on('edit-stopped', (data) => {
this.hideEditor(data.sectionId);
});
this.sectionManager.on('changes-accepted', (data) => {
this.hideEditor(data.sectionId);
this.updateSectionContent(data.sectionId, data.content);
});
this.sectionManager.on('changes-cancelled', (data) => {
this.hideEditor(data.sectionId);
this.updateSectionContent(data.sectionId, data.content);
});
this.sectionManager.on('section-reset', (data) => {
this.updateTextareaContent(data.content, data.sectionId);
});
}
renderAllSections(sections) {
debug('21: renderAllSections called with ' + sections.length + ' sections', 'RENDER');
this.container.innerHTML = '';
debug('22: Container cleared', 'RENDER');
sections.forEach((section, index) => {
debug('23: Creating element for section ' + (index + 1) + ': ' + section.id, 'RENDER');
const element = this.createSectionElement(section);
section.domElement = element;
this.container.appendChild(element);
});
debug('24: All section elements added to container', 'RENDER');
this.container.addEventListener('click', this.handleSectionClick);
debug('25: Click listener attached - container content length: ' + this.container.innerHTML.length, 'RENDER');
}
createSectionElement(section) {
const element = document.createElement('div');
element.setAttribute('data-section-id', section.id);
debug('SECTION: Creating element for section ' + section.id + ' with content: ' + section.currentMarkdown.substring(0, 50) + '...', 'SECTION');
if (typeof marked !== 'undefined') {
const html = marked.parse(section.currentMarkdown);
const htmlWithTargetBlank = html.replace(/<a href="([^"]*)"([^>]*)>/g, '<a href="$1" target="_blank"$2>');
element.innerHTML = htmlWithTargetBlank;
} else {
element.innerHTML = `<p>${section.currentMarkdown}</p>`;
}
this.setupSectionElement(element);
debug('SECTION: Section element setup complete for ' + section.id, 'SECTION');
return element;
}
handleSectionClick(event) {
debug('CLICK: Click detected on target: ' + event.target.tagName + ' ' + (event.target.className || ''), 'CLICK');
// Don't handle clicks on form elements, buttons, or links
if (event.target.closest('textarea, button, input, a')) {
debug('CLICK: Ignoring click on form element', 'CLICK');
return;
}
const sectionElement = event.target.closest('.ui-edit-section');
debug('CLICK: Found section element: ' + (sectionElement ? sectionElement.getAttribute('data-section-id') : 'null'), 'CLICK');
if (!sectionElement) return;
const sectionId = sectionElement.getAttribute('data-section-id');
debug('CLICK: Section ID: ' + sectionId, 'CLICK');
if (!sectionId) return;
// Check if this section is already being edited
const section = this.sectionManager.sections.get(sectionId);
if (section && section.isEditing()) {
console.log('Section already being edited:', sectionId);
return;
}
try {
console.log('Starting edit for section:', sectionId);
this.sectionManager.startEditing(sectionId);
} catch (error) {
console.error('Failed to start editing:', error);
}
}
showEditor(sectionId, content) {
const element = this.findSectionElement(sectionId);
if (!element) return;
this.hideCurrentEditor();
const section = this.sectionManager.sections.get(sectionId);
const isImageSection = section && section.isImage();
if (isImageSection) {
this.showImageEditor(sectionId, section);
return;
}
const editorContainer = document.createElement('div');
editorContainer.className = 'ui-edit-editor-container';
const textarea = document.createElement('textarea');
textarea.className = 'ui-edit-textarea ui-edit-textarea-main';
textarea.value = content;
textarea.style.cssText = `
flex: 1;
min-height: 100px;
font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace;
font-size: 14px;
line-height: 1.5;
padding: 12px;
border: 2px solid #007bff;
border-radius: 6px;
resize: vertical;
outline: none;
background: white;
color: #333;
`;
// Setup auto-resize functionality
this.setupAutoResize(textarea);
const controls = document.createElement('div');
controls.className = 'ui-edit-controls';
controls.style.cssText = `
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 8px;
`;
const acceptBtn = this.createButton('Accept', 'ui-edit-button-accept', this.handleAccept);
const cancelBtn = this.createButton('Cancel', 'ui-edit-button-cancel', this.handleCancel);
controls.appendChild(acceptBtn);
controls.appendChild(cancelBtn);
editorContainer.appendChild(textarea);
editorContainer.appendChild(controls);
element.innerHTML = '';
element.appendChild(editorContainer);
textarea.focus();
this.editingSections.add(sectionId);
textarea.addEventListener('input', () => {
this.sectionManager.updateContent(sectionId, textarea.value);
});
// Add keyboard shortcuts
textarea.addEventListener('keydown', this.handleKeydown);
}
/**
* Show image editor with manipulation controls
* @param {string} sectionId - The section ID
* @param {Section} section - The section object
*/
showImageEditor(sectionId, section) {
const element = this.findSectionElement(sectionId);
if (!element) return;
this.hideCurrentEditor();
const editorContainer = document.createElement('div');
editorContainer.className = 'ui-edit-image-editor-container';
editorContainer.style.cssText = `
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 12px;
padding: 16px;
background: #f8f9fa;
border-radius: 8px;
border: 2px solid #007bff;
`;
// Image preview
const imagePreview = document.createElement('div');
imagePreview.className = 'ui-edit-image-preview';
imagePreview.style.cssText = `
max-width: 100%;
text-align: center;
background: white;
padding: 16px;
border-radius: 6px;
border: 1px solid #dee2e6;
`;
// Parse markdown to extract image info
const imageMatch = section.currentMarkdown.match(/!\[(.*?)\]\((.*?)\)/);
if (imageMatch) {
const [, altText, imageSrc] = imageMatch;
const img = document.createElement('img');
img.src = imageSrc;
img.alt = altText;
img.style.cssText = `
max-width: 100%;
max-height: 300px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
`;
imagePreview.appendChild(img);
}
// Image controls
const controlPanel = document.createElement('div');
controlPanel.className = 'ui-edit-image-controls';
controlPanel.style.cssText = `
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 8px;
`;
// Alt text editor
const altTextContainer = document.createElement('div');
altTextContainer.style.cssText = `grid-column: 1 / -1; margin-bottom: 8px;`;
const altTextLabel = document.createElement('label');
altTextLabel.textContent = 'Alt Text:';
altTextLabel.style.cssText = `display: block; margin-bottom: 4px; font-weight: bold;`;
const altTextInput = document.createElement('input');
altTextInput.type = 'text';
altTextInput.value = imageMatch ? imageMatch[1] : '';
altTextInput.style.cssText = `
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 14px;
`;
// Add keyboard shortcuts to alt text input
altTextInput.addEventListener('keydown', this.handleKeydown);
altTextContainer.appendChild(altTextLabel);
altTextContainer.appendChild(altTextInput);
// Image manipulation buttons
const buttons = [
{ text: 'Replace Image', action: () => this.replaceImage(sectionId) },
{ text: 'Resize', action: () => this.resizeImage(sectionId) },
{ text: 'Add Caption', action: () => this.addImageCaption(sectionId) },
{ text: 'Remove Image', action: () => this.removeImage(sectionId) }
];
buttons.forEach(({ text, action }) => {
const btn = this.createButton(text, 'ui-edit-image-btn', action);
btn.style.fontSize = '12px';
btn.style.padding = '6px 12px';
controlPanel.appendChild(btn);
});
// Standard editor controls
const editorControls = document.createElement('div');
editorControls.className = 'ui-edit-controls';
editorControls.style.cssText = `
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 12px;
`;
const acceptBtn = this.createButton('✓ Accept', 'ui-edit-accept', (e) => {
// Update alt text if changed
if (imageMatch && altTextInput.value !== imageMatch[1]) {
const newMarkdown = section.currentMarkdown.replace(
/!\[(.*?)\]/,
`![${altTextInput.value}]`
);
this.sectionManager.updateContent(sectionId, newMarkdown);
}
this.handleAccept(e);
});
const cancelBtn = this.createButton('✗ Cancel', 'ui-edit-cancel', this.handleCancel);
const resetBtn = this.createButton('↺ Reset', 'ui-edit-reset', this.handleReset);
acceptBtn.style.background = '#28a745';
cancelBtn.style.background = '#dc3545';
resetBtn.style.background = '#fd7e14';
editorControls.appendChild(acceptBtn);
editorControls.appendChild(cancelBtn);
editorControls.appendChild(resetBtn);
// Assemble the editor
editorContainer.appendChild(imagePreview);
editorContainer.appendChild(altTextContainer);
editorContainer.appendChild(controlPanel);
editorContainer.appendChild(editorControls);
element.appendChild(editorContainer);
altTextInput.focus();
this.editingSections.add(sectionId);
}
/**
* Image manipulation methods
*/
replaceImage(sectionId) {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
const section = this.sectionManager.sections.get(sectionId);
const imageMatch = section.currentMarkdown.match(/!\[(.*?)\]\((.*?)\)/);
if (imageMatch) {
const newMarkdown = section.currentMarkdown.replace(
/!\[(.*?)\]\((.*?)\)/,
`![${imageMatch[1]}](${event.target.result})`
);
this.sectionManager.updateContent(sectionId, newMarkdown);
this.hideEditor(sectionId);
setTimeout(() => this.showImageEditor(sectionId, this.sectionManager.sections.get(sectionId)), 100);
}
};
reader.readAsDataURL(file);
}
});
input.click();
}
resizeImage(sectionId) {
const section = this.sectionManager.sections.get(sectionId);
const size = prompt('Enter image width (e.g., 300px, 50%, or leave empty for auto):', '');
if (size !== null) {
const imageMatch = section.currentMarkdown.match(/!\[(.*?)\]\((.*?)\)/);
if (imageMatch) {
const style = size ? ` style="width: ${size};"` : '';
const newMarkdown = section.currentMarkdown.replace(
/!\[(.*?)\]\((.*?)\)/,
`<img src="${imageMatch[2]}" alt="${imageMatch[1]}"${style}>`
);
this.sectionManager.updateContent(sectionId, newMarkdown);
}
}
}
addImageCaption(sectionId) {
const section = this.sectionManager.sections.get(sectionId);
const caption = prompt('Enter image caption:', '');
if (caption) {
const newMarkdown = section.currentMarkdown + `\n\n*${caption}*`;
this.sectionManager.updateContent(sectionId, newMarkdown);
}
}
removeImage(sectionId) {
if (confirm('Are you sure you want to remove this image?')) {
this.sectionManager.updateContent(sectionId, '');
this.hideEditor(sectionId);
}
}
createButton(text, className, handler) {
const btn = document.createElement('button');
btn.textContent = text;
btn.className = className;
btn.style.cssText = `
background: #007bff;
color: white;
border: none;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
`;
btn.addEventListener('click', handler);
return btn;
}
handleAccept(event) {
const sectionId = this.getCurrentEditingSectionId(event.target);
if (sectionId) {
this.sectionManager.acceptChanges(sectionId);
}
}
handleCancel(event) {
const sectionId = this.getCurrentEditingSectionId(event.target);
if (sectionId) {
this.sectionManager.cancelChanges(sectionId);
}
}
handleReset(event) {
const sectionId = this.getCurrentEditingSectionId(event.target);
if (sectionId) {
this.sectionManager.resetSection(sectionId);
}
}
handleKeydown(event) {
// Enhanced keyboard shortcuts for section editing
const textarea = event.target.closest('textarea');
if (!textarea) return;
// Find the section being edited
const editorContainer = textarea.closest('.ui-edit-editor-container, .ui-edit-image-editor-container');
if (!editorContainer) return;
const sectionElement = editorContainer.parentElement;
const sectionId = sectionElement ? sectionElement.getAttribute('data-section-id') : null;
if (!sectionId) return;
// Handle keyboard shortcuts
if (event.ctrlKey || event.metaKey) {
switch (event.key) {
case 'Enter':
event.preventDefault();
this.sectionManager.acceptChanges(sectionId);
debug('Keyboard shortcut: Ctrl+Enter - accepted changes for section ' + sectionId, 'KEYBOARD');
break;
case 'Escape':
event.preventDefault();
this.sectionManager.cancelChanges(sectionId);
debug('Keyboard shortcut: Ctrl+Escape - cancelled changes for section ' + sectionId, 'KEYBOARD');
break;
}
}
// Handle plain Escape (without Ctrl)
if (event.key === 'Escape' && !event.ctrlKey && !event.metaKey) {
event.preventDefault();
this.sectionManager.cancelChanges(sectionId);
debug('Keyboard shortcut: Escape - cancelled changes for section ' + sectionId, 'KEYBOARD');
}
}
getCurrentEditingSectionId(button) {
const editorContainer = button.closest('.ui-edit-editor-container');
if (!editorContainer) return null;
const sectionElement = editorContainer.parentElement;
return sectionElement ? sectionElement.getAttribute('data-section-id') : null;
}
hideEditor(sectionId) {
const element = this.findSectionElement(sectionId);
if (!element) return;
const section = this.sectionManager.sections.get(sectionId);
if (section) {
this.updateSectionContent(sectionId, section.currentMarkdown);
}
this.editingSections.delete(sectionId);
}
hideCurrentEditor() {
this.editingSections.forEach(sectionId => {
const element = this.findSectionElement(sectionId);
if (element && element.querySelector('.ui-edit-editor-container')) {
this.hideEditor(sectionId);
}
});
}
updateSectionContent(sectionId, content) {
const element = this.findSectionElement(sectionId);
if (!element) return;
if (typeof marked !== 'undefined') {
const html = marked.parse(content);
const htmlWithTargetBlank = html.replace(/<a href="([^"]*)"([^>]*)>/g, '<a href="$1" target="_blank"$2>');
element.innerHTML = htmlWithTargetBlank;
} else {
element.innerHTML = `<p>${content}</p>`;
}
this.setupSectionElement(element);
}
findSectionElement(sectionId) {
return this.container.querySelector(`[data-section-id="${sectionId}"]`);
}
setupSectionElement(element) {
element.className = 'ui-edit-section';
element.style.cssText = `
margin: 16px 0;
padding: 12px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
border: 2px solid transparent;
`;
element.removeEventListener('mouseenter', element._mouseenterHandler);
element.removeEventListener('mouseleave', element._mouseleaveHandler);
element._mouseenterHandler = () => {
element.style.backgroundColor = 'rgba(0, 0, 0, 0.02)';
element.style.borderColor = 'rgba(0, 0, 0, 0.1)';
};
element._mouseleaveHandler = () => {
element.style.backgroundColor = '';
element.style.borderColor = 'transparent';
};
element.addEventListener('mouseenter', element._mouseenterHandler);
element.addEventListener('mouseleave', element._mouseleaveHandler);
}
/**
* Setup auto-resize for textarea
* @param {HTMLTextAreaElement} textarea - The textarea element
*/
setupAutoResize(textarea) {
const autoResize = () => {
const transition = textarea.style.transition;
textarea.style.transition = 'none';
textarea.style.height = 'auto';
const contentHeight = textarea.scrollHeight;
const padding = 24;
const lineCount = textarea.value.split('\n').length;
const minHeight = Math.max(60, lineCount * 24 + padding);
const maxHeight = 360;
const newHeight = Math.max(60, Math.min(maxHeight, Math.max(minHeight, contentHeight + 4)));
textarea.style.height = newHeight + 'px';
textarea.style.transition = transition;
};
textarea.addEventListener('input', autoResize);
textarea.addEventListener('paste', () => setTimeout(autoResize, 10));
// Initial sizing
setTimeout(autoResize, 20);
}
/**
* Create status panel for real-time status display
* @returns {HTMLElement} Status panel element
*/
createStatusPanel() {
const panel = document.createElement('div');
panel.className = 'ui-edit-status-panel';
panel.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 12px 16px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
z-index: 1000;
min-width: 180px;
`;
const statusText = document.createElement('div');
statusText.className = 'ui-edit-status-text';
statusText.textContent = 'Ready';
const detailsText = document.createElement('div');
detailsText.className = 'ui-edit-status-details';
detailsText.style.cssText = `
font-size: 12px;
color: #666;
margin-top: 4px;
`;
panel.appendChild(statusText);
panel.appendChild(detailsText);
return panel;
}
/**
* Update status display with current status information
* @param {Object} status - Status object from SectionManager
*/
updateStatusDisplay(status) {
let statusPanel = document.querySelector('.ui-edit-status-panel');
if (!statusPanel) {
statusPanel = this.createStatusPanel();
document.body.appendChild(statusPanel);
}
const statusText = statusPanel.querySelector('.ui-edit-status-text');
const detailsText = statusPanel.querySelector('.ui-edit-status-details');
// Update status text and color based on state
switch (status.state) {
case 'ready':
statusText.textContent = '✓ Ready';
statusText.style.color = '#28a745';
break;
case 'editing':
statusText.textContent = '✏️ Editing';
statusText.style.color = '#007bff';
break;
case 'modified':
statusText.textContent = '⚠️ Modified';
statusText.style.color = '#ffc107';
break;
default:
statusText.textContent = 'Unknown';
statusText.style.color = '#6c757d';
}
// Update details
const details = [];
details.push(`${status.totalSections} sections`);
if (status.editingSections.length > 0) {
details.push(`${status.editingSections.length} editing`);
}
if (status.modifiedSections > 0) {
details.push(`${status.modifiedSections} modified`);
}
detailsText.textContent = details.join(' • ');
// Update timestamp
const now = new Date().toLocaleTimeString();
statusPanel.setAttribute('title', `Last update: ${now}`);
}
}
/**
* Main Editor Integration
*/
class MarkitectCleanEditor {
constructor(markdownContent, containerElement, options = {}) {
debug('10: MarkitectCleanEditor constructor called', 'EDITOR');
this.options = {
theme: 'github',
keyboardShortcuts: true,
autosave: false,
originalFilename: null,
...options
};
debug('11: Creating SectionManager', 'EDITOR');
this.sectionManager = new SectionManager();
debug('12: Creating DOMRenderer', 'EDITOR');
this.domRenderer = new DOMRenderer(this.sectionManager, containerElement);
this.originalMarkdown = markdownContent;
this.cleanMarkdownContent = options.cleanMarkdownContent || markdownContent;
this.dogtagContent = options.dogtagContent || '';
debug('13: About to call initialize()', 'EDITOR');
this.initialize();
debug('14: initialize() completed', 'EDITOR');
}
initialize() {
try {
debug('15: Starting initialize() - markdown length: ' + this.originalMarkdown.length, 'INIT');
const sections = this.sectionManager.createSectionsFromMarkdown(this.originalMarkdown);
debug('16: Created ' + sections.length + ' sections', 'INIT');
// Mark the dogtag section as protected if we have a dogtag
if (this.dogtagContent) {
debug('17: Marking dogtag section', 'INIT');
this.markDogtagSection(sections);
}
// Mark base64 reference sections as protected
debug('18: Marking base64 reference sections', 'INIT');
this.markBase64ReferenceSections(sections);
console.log(`✓ Initialized clean editor with ${sections.length} sections`);
// Add global control panel
debug('19: Adding global controls', 'INIT');
this.addGlobalControls();
// Setup status tracking
debug('19.5: Setting up status tracking', 'INIT');
this.setupStatusTracking();
debug('20: Initialize completed successfully', 'INIT');
return true;
} catch (error) {
debug('ERROR in initialize: ' + error.message, 'ERROR');
console.error('Failed to initialize clean editor:', error);
return false;
}
}
markDogtagSection(sections) {
// Find the section that contains the dogtag content
const dogtagText = this.dogtagContent.trim();
if (!dogtagText) return;
for (const section of sections) {
if (section.currentMarkdown.includes(dogtagText)) {
section.isDogtagSection = true;
console.log('Marked dogtag section as protected:', section.id);
break;
}
}
}
markBase64ReferenceSections(sections) {
// Find sections that contain base64 image references
if (!window.markitectBase64References) return;
const refIds = Object.keys(window.markitectBase64References);
if (refIds.length === 0) return;
for (const section of sections) {
const markdown = section.currentMarkdown;
// Check if this section contains base64 reference syntax
for (const refId of refIds) {
if (markdown.includes(`[${refId}]:`)) {
section.isBase64RefSection = true;
console.log('Marked base64 reference section as protected:', section.id);
break;
}
}
}
}
addGlobalControls() {
// Create a floating control panel
const controlPanel = document.createElement('div');
controlPanel.id = 'markitect-global-controls';
controlPanel.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: rgba(248, 249, 250, 0.95);
border: 1px solid #dee2e6;
border-radius: 8px;
padding: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 1000;
backdrop-filter: blur(8px);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 14px;
min-width: 200px;
`;
const title = document.createElement('div');
title.style.cssText = `
font-weight: 600;
margin-bottom: 8px;
color: #495057;
border-bottom: 1px solid #dee2e6;
padding-bottom: 4px;
`;
title.textContent = 'Document Controls';
const buttonContainer = document.createElement('div');
buttonContainer.style.cssText = `
display: flex;
flex-direction: column;
gap: 6px;
`;
// Save Document button
const saveButton = document.createElement('button');
saveButton.id = 'save-document';
saveButton.textContent = '💾 Save Document';
saveButton.style.cssText = `
background: #28a745;
color: white;
border: none;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: background-color 0.2s;
`;
// Reset All button
const resetButton = document.createElement('button');
resetButton.id = 'reset-all';
resetButton.textContent = '🔄 Reset All';
resetButton.style.cssText = `
background: #ffc107;
color: #212529;
border: none;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: background-color 0.2s;
`;
// Show Status button
const statusButton = document.createElement('button');
statusButton.id = 'show-status';
statusButton.textContent = '📊 Show Status';
statusButton.style.cssText = `
background: #17a2b8;
color: white;
border: none;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
transition: background-color 0.2s;
`;
buttonContainer.appendChild(saveButton);
buttonContainer.appendChild(resetButton);
buttonContainer.appendChild(statusButton);
controlPanel.appendChild(title);
controlPanel.appendChild(buttonContainer);
document.body.appendChild(controlPanel);
// Add event listeners
document.getElementById('save-document').addEventListener('click', () => this.saveDocument());
document.getElementById('reset-all').addEventListener('click', () => this.resetAllSections());
document.getElementById('show-status').addEventListener('click', () => this.showStatus());
}
saveDocument() {
try {
const markdown = this.sectionManager.getDocumentMarkdown();
// Generate intelligent filename
const filename = this.generateSaveFilename();
// Create a download link
const blob = new Blob([markdown], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.showMessage(`Document saved as ${filename}!`, 'success');
console.log('Document saved:', filename, markdown.length, 'characters');
} catch (error) {
this.showMessage('Failed to save document: ' + error.message, 'error');
console.error('Save failed:', error);
}
}
resetAllSections() {
if (!confirm('Reset all sections to their original content? This will lose all changes and cannot be undone.')) {
return;
}
try {
const sections = this.sectionManager.getAllSections();
sections.forEach(section => {
this.sectionManager.resetSection(section.id);
});
this.showMessage('All sections reset to original content', 'info');
console.log('All sections reset');
} catch (error) {
this.showMessage('Failed to reset sections: ' + error.message, 'error');
console.error('Reset failed:', error);
}
}
showStatus() {
const sections = this.sectionManager.getSectionStatus();
const totalSections = sections.length;
const editedSections = sections.filter(s => s.hasChanges).length;
const currentlyEditing = sections.filter(s => s.isEditing).length;
const statusHtml = `
<h3>Document Status</h3>
<p><strong>Total Sections:</strong> ${totalSections}</p>
<p><strong>Modified Sections:</strong> ${editedSections}</p>
<p><strong>Currently Editing:</strong> ${currentlyEditing}</p>
<hr>
<h4>Section Details:</h4>
<ul>
${sections.map(s => `
<li>
<strong>${s.id}</strong> (${s.type})
- ${s.state}
${s.hasChanges ? ' ✏️' : ''}
${s.isEditing ? ' 🖊️' : ''}
</li>
`).join('')}
</ul>
`;
this.showModal('Document Status', statusHtml);
}
showMessage(message, type = 'info') {
const messageDiv = document.createElement('div');
messageDiv.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: ${type === 'success' ? '#d4edda' : type === 'error' ? '#f8d7da' : '#d1ecf1'};
color: ${type === 'success' ? '#155724' : type === 'error' ? '#721c24' : '#0c5460'};
border: 1px solid ${type === 'success' ? '#c3e6cb' : type === 'error' ? '#f5c6cb' : '#bee5eb'};
border-radius: 6px;
padding: 12px 20px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 14px;
z-index: 10001;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
`;
messageDiv.textContent = message;
document.body.appendChild(messageDiv);
setTimeout(() => {
if (messageDiv.parentNode) {
messageDiv.parentNode.removeChild(messageDiv);
}
}, 3000);
}
showModal(title, content) {
// Remove existing modal if present
const existingModal = document.getElementById('markitect-modal');
if (existingModal) {
existingModal.remove();
}
const modalOverlay = document.createElement('div');
modalOverlay.id = 'markitect-modal';
modalOverlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 10000;
`;
const modal = document.createElement('div');
modal.style.cssText = `
background: white;
border-radius: 8px;
padding: 24px;
max-width: 600px;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
`;
const closeBtn = document.createElement('button');
closeBtn.textContent = '×';
closeBtn.style.cssText = `
float: right;
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #6c757d;
margin: -8px -8px 0 0;
`;
const modalContent = document.createElement('div');
modalContent.innerHTML = `<h2>${title}</h2>${content}`;
function closeModal() {
modalOverlay.remove();
}
closeBtn.addEventListener('click', closeModal);
modalOverlay.addEventListener('click', (e) => {
if (e.target === modalOverlay) closeModal();
});
modal.appendChild(closeBtn);
modal.appendChild(modalContent);
modalOverlay.appendChild(modal);
document.body.appendChild(modalOverlay);
}
getDocumentMarkdown() {
return this.sectionManager.getDocumentMarkdown();
}
escapeRegex(str) {
return str.replace(/[.*+?^${}()|[]\]/g, '\\\\$&');
}
convertDataUrlToReference(markdown) {
if (!window.markitectBase64References) {
return markdown;
}
let convertedMarkdown = markdown;
Object.entries(window.markitectBase64References).forEach(([refId, refData]) => {
const dataUrl = refData.full_data_url;
const escapedDataUrl = this.escapeRegex(dataUrl);
const dataUrlPattern = new RegExp(`!\\[([^\\]]*)\\]\\(${escapedDataUrl}\\)`, 'g');
convertedMarkdown = convertedMarkdown.replace(dataUrlPattern, `![$1][${refId}]`);
});
return convertedMarkdown;
}
/**
* Setup real-time status tracking
*/
setupStatusTracking() {
// Listen for status updates from SectionManager
this.sectionManager.on('status-updated', (status) => {
this.domRenderer.updateStatusDisplay(status);
});
// Start periodic status tracking
this.sectionManager.startStatusTracking(2000); // Update every 2 seconds
// Initial status display
this.sectionManager.updateGlobalStatus();
console.log('✓ Real-time status tracking initialized');
}
/**
* Generate intelligent save filename using 4-method fallback system
* @returns {string} Generated filename with .md extension
*/
generateSaveFilename() {
// Method 1: Original filename from options
if (this.options.originalFilename) {
return this.sanitizeFilename(this.options.originalFilename);
}
// Method 2: Page title extraction
const titleFilename = this.extractFilenameFromTitle();
if (titleFilename) {
return this.sanitizeFilename(titleFilename + '.md');
}
// Method 3: URL pathname analysis
const urlFilename = this.extractFilenameFromUrl();
if (urlFilename) {
return this.sanitizeFilename(urlFilename + '.md');
}
// Method 4: First heading extraction
const headingFilename = this.extractFilenameFromHeading();
if (headingFilename) {
return this.sanitizeFilename(headingFilename + '.md');
}
// Method 5: Timestamp generation (fallback)
return this.generateTimestampFilename();
}
/**
* Sanitize filename to be filesystem-safe
* @param {string} filename - Raw filename
* @returns {string} Sanitized filename
*/
sanitizeFilename(filename) {
if (!filename) return 'document.md';
// Remove or replace filesystem-unsafe characters
let sanitized = filename
.replace(/[\/\\:*?"<>|]/g, '-') // Replace unsafe chars with dashes
.replace(/\s+/g, '-') // Replace spaces with dashes
.replace(/-+/g, '-') // Replace multiple dashes with single dash
.replace(/^-|-$/g, '') // Remove leading/trailing dashes
.trim();
// Ensure it ends with .md
if (!sanitized.endsWith('.md')) {
sanitized += '.md';
}
// Ensure it's not empty
if (sanitized === '.md') {
sanitized = 'document.md';
}
return sanitized;
}
/**
* Extract filename from page title
* @returns {string|null} Filename or null if not suitable
*/
extractFilenameFromTitle() {
if (typeof document === 'undefined' || !document.title) {
return null;
}
let title = document.title.trim();
// Remove common website suffixes
title = title
.split('|')[0] // Remove " | Website"
.split('-')[0] // Remove " - Website"
.split('•')[0] // Remove " • Website"
.trim();
if (title.length < 3 || title.length > 100) {
return null;
}
return title;
}
/**
* Extract filename from URL pathname
* @returns {string|null} Filename or null if not suitable
*/
extractFilenameFromUrl() {
if (typeof window === 'undefined' || !window.location) {
return null;
}
const pathname = window.location.pathname;
if (!pathname || pathname === '/') {
return null;
}
// Get the last segment of the path
const segments = pathname.split('/').filter(s => s.length > 0);
if (segments.length === 0) {
return null;
}
const lastSegment = segments[segments.length - 1];
// Remove file extensions
const filenameBase = lastSegment.replace(/\.[^.]*$/, '');
if (filenameBase.length < 3 || filenameBase.length > 100) {
return null;
}
return filenameBase;
}
/**
* Extract filename from first heading in markdown
* @returns {string|null} Filename or null if no heading found
*/
extractFilenameFromHeading() {
if (!this.originalMarkdown) {
return null;
}
const lines = this.originalMarkdown.split('\n');
// Find first heading line
for (const line of lines) {
const trimmed = line.trim();
if (/^#{1,6}\s/.test(trimmed)) {
// Extract heading text (remove # symbols and trim)
const headingText = trimmed.replace(/^#{1,6}\s*/, '').trim();
if (headingText.length >= 3 && headingText.length <= 100) {
return headingText;
}
}
}
return null;
}
/**
* Generate timestamp-based filename as final fallback
* @returns {string} Timestamp-based filename
*/
generateTimestampFilename() {
const now = new Date();
const timestamp = now.getFullYear().toString() +
(now.getMonth() + 1).toString().padStart(2, '0') +
now.getDate().toString().padStart(2, '0') + '-' +
now.getHours().toString().padStart(2, '0') +
now.getMinutes().toString().padStart(2, '0');
return `document-${timestamp}.md`;
}
/**
* Cleanup method to stop status tracking
*/
destroy() {
if (this.sectionManager) {
this.sectionManager.stopStatusTracking();
}
}
}
// Initialize the clean editor system
let markitectCleanEditor;
function initializeCleanEditor() {
debug('1: initializeCleanEditor called', 'INIT');
const container = document.getElementById('markdown-content');
if (!container) {
debug('2: FAILED - Markdown content container not found', 'ERROR');
return;
}
debug('3: Container found', 'INIT');
if (typeof window.MarkitectEditor === 'undefined') {
debug('4: FAILED - MarkitectEditor not found', 'ERROR');
return;
}
debug('5: MarkitectEditor found', 'INIT');
debug('6: Creating editor with content length: ' + markdownContentWithDogtag.length, 'INIT');
try {
markitectCleanEditor = new window.MarkitectEditor.MarkitectCleanEditor(markdownContentWithDogtag, container, {
cleanMarkdownContent: markdownContent,
dogtagContent: dogtagContent
});
debug('7: Editor instance created', 'INIT');
window.markitectCleanEditor = markitectCleanEditor; // Make globally available
debug('8: Editor made globally available', 'INIT');
const contentAfterInit = container.innerHTML;
debug('9: Container has content: ' + (contentAfterInit.length > 0 ? 'YES (' + contentAfterInit.length + ' chars)' : 'NO'), 'INIT');
console.log('✅ Clean section editor initialized successfully');
} catch (error) {
debug('ERROR in editor creation: ' + error.message, 'ERROR');
throw error;
}
}
// Document scroll indicators
function initializeScrollIndicators() {
console.log('✅ Document scroll indicators initialized');
}
// Export for module systems
if (typeof module !== 'undefined' && module.exports) {
module.exports = { EditState, SectionType, Section, SectionManager, DOMRenderer, MarkitectCleanEditor };
global.EditState = EditState;
global.SectionType = SectionType;
global.Section = Section;
} else {
window.MarkitectEditor = { EditState, SectionType, Section, SectionManager, DOMRenderer, MarkitectCleanEditor };
window.EditState = EditState;
window.SectionType = SectionType;
window.Section = Section;
}