Files
markitect-main/src/dom_renderer.js
tegwick d0abaab63a
Some checks failed
Test Suite / unit-tests (3.11) (push) Has been cancelled
Test Suite / unit-tests (3.12) (push) Has been cancelled
Test Suite / code-quality (push) Has been cancelled
Test Suite / security-scan (push) Has been cancelled
Test Suite / integration-tests (push) Has been cancelled
Test Suite / e2e-tests (push) Has been cancelled
Test Suite / performance-tests (push) Has been cancelled
Test Suite / test-summary (push) Has been cancelled
chore: update project state and prepare for image support development
- Add comprehensive image test document with various image types
- Update project structure with development artifacts
- Prepare foundation for image support enhancement phase
- Include test files for validating image editing workflows

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-26 08:06:22 +01:00

523 lines
17 KiB
JavaScript

/**
* DOM Renderer - Clean separation between business logic and DOM manipulation
*
* This class handles all DOM operations and UI events, while delegating
* business logic to the SectionManager.
*/
// Import EditState if available, otherwise define locally
const EditState = (typeof window !== 'undefined' && window.MarkitectEditor?.EditState)
? window.MarkitectEditor.EditState
: {
ORIGINAL: 'original',
EDITING: 'editing',
MODIFIED: 'modified',
SAVED: 'saved'
};
/**
* DOMRenderer class - Handles DOM rendering and UI interactions
*
* Responsibilities:
* - Render sections to DOM elements
* - Handle UI events (clicks, keyboard)
* - Manage textarea creation and styling
* - Sync DOM state with Section objects
* - Provide visual feedback for different states
*/
class DOMRenderer {
constructor(sectionManager, containerElement) {
this.sectionManager = sectionManager;
this.container = containerElement;
this.currentTextarea = null;
this.currentSection = null;
// Bind event handlers
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);
// Listen to section manager events
this.setupEventListeners();
}
/**
* Setup event listeners for section manager events
*/
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.updateSectionVisual(data.sectionId, data.section);
});
this.sectionManager.on('changes-accepted', (data) => {
this.hideEditor(data.sectionId);
this.updateSectionContent(data.sectionId, data.content);
this.updateSectionVisual(data.sectionId, data.section);
});
this.sectionManager.on('changes-cancelled', (data) => {
this.hideEditor(data.sectionId);
this.updateSectionContent(data.sectionId, data.content);
this.updateSectionVisual(data.sectionId, data.section);
});
this.sectionManager.on('section-reset', (data) => {
this.updateTextareaContent(data.content);
});
this.sectionManager.on('content-updated', (data) => {
this.updateSectionVisual(data.sectionId, data.section);
});
}
/**
* Render all sections to the DOM
* @param {Array<Section>} sections - Sections to render
*/
renderAllSections(sections) {
this.container.innerHTML = '';
sections.forEach(section => {
const element = this.createSectionElement(section);
section.domElement = element;
this.container.appendChild(element);
});
// Add click handlers to all sections
this.container.addEventListener('click', this.handleSectionClick);
}
/**
* Create a DOM element for a section
* @param {Section} section - The section to create element for
* @returns {HTMLElement} The created DOM element
*/
createSectionElement(section) {
const element = document.createElement('div');
element.className = 'markitect-section-editable';
element.setAttribute('data-section-id', section.id);
element.setAttribute('data-section-type', section.sectionType);
// Parse markdown to HTML - use pending changes if they exist and section isn't being edited
const contentToRender = (section.state === EditState.MODIFIED && section.pendingMarkdown)
? section.pendingMarkdown
: section.currentMarkdown;
if (typeof marked !== 'undefined') {
element.innerHTML = marked.parse(contentToRender);
} else {
// Fallback for testing environment
element.innerHTML = `<p>${contentToRender}</p>`;
}
this.updateSectionVisual(section.id, section.getStatus());
return element;
}
/**
* Update section visual state based on its status
* @param {string} sectionId - The section ID
* @param {Object} status - The section status object
*/
updateSectionVisual(sectionId, status) {
const element = this.findSectionElement(sectionId);
if (!element) return;
// Remove all state classes
element.classList.remove('section-original', 'section-editing', 'section-modified', 'section-saved');
// Add appropriate state class
element.classList.add(`section-${status.state}`);
// Add visual indicators for modified sections
if (status.isModified) {
element.style.backgroundColor = 'rgba(255, 235, 59, 0.1)'; // Light yellow
element.style.borderLeft = '3px solid #ffc107'; // Orange border
} else if (status.state === 'saved') {
element.style.backgroundColor = 'rgba(76, 175, 80, 0.1)'; // Light green
element.style.borderLeft = '3px solid #4caf50'; // Green border
} else {
element.style.backgroundColor = '';
element.style.borderLeft = '';
}
}
/**
* Find DOM element for a section
* @param {string} sectionId - The section ID
* @returns {HTMLElement|null} The DOM element or null
*/
findSectionElement(sectionId) {
return this.container.querySelector(`[data-section-id="${sectionId}"]`);
}
/**
* Handle section click events
* @param {Event} event - The click event
*/
handleSectionClick(event) {
const sectionElement = event.target.closest('.markitect-section-editable');
if (!sectionElement) return;
const sectionId = sectionElement.getAttribute('data-section-id');
if (!sectionId) return;
// Don't start editing if already editing this section
if (this.currentSection === sectionId) return;
try {
this.sectionManager.startEditing(sectionId);
} catch (error) {
console.error('Failed to start editing:', error);
this.showError(error.message);
}
}
/**
* Show editor for a section
* @param {string} sectionId - The section ID
* @param {string} content - The content to edit
*/
showEditor(sectionId, content) {
const element = this.findSectionElement(sectionId);
if (!element) return;
// Hide any existing editor first
this.hideCurrentEditor();
// Create editor container
const editorContainer = this.createEditorContainer(content, sectionId);
// Replace section content with editor
element.innerHTML = '';
element.appendChild(editorContainer);
// Focus the textarea
const textarea = editorContainer.querySelector('textarea');
if (textarea) {
textarea.focus();
textarea.setSelectionRange(0, 0);
this.currentTextarea = textarea;
}
this.currentSection = sectionId;
}
/**
* Create editor container with textarea and controls
* @param {string} content - The initial content
* @param {string} sectionId - The section ID
* @returns {HTMLElement} The editor container
*/
createEditorContainer(content, sectionId) {
const container = document.createElement('div');
container.className = 'markitect-edit-container';
// Create textarea wrapper
const textareaWrapper = document.createElement('div');
textareaWrapper.className = 'markitect-textarea-wrapper';
// Create textarea
const textarea = document.createElement('textarea');
textarea.className = 'edit-mode';
textarea.value = content;
textarea.addEventListener('input', () => {
try {
this.sectionManager.updateContent(sectionId, textarea.value);
} catch (error) {
console.error('Failed to update content:', error);
}
});
// Add keyboard shortcuts
textarea.addEventListener('keydown', this.handleKeydown);
// Auto-resize functionality
this.setupAutoResize(textarea);
textareaWrapper.appendChild(textarea);
// Create controls
const controls = this.createControlButtons(sectionId);
container.appendChild(textareaWrapper);
container.appendChild(controls);
return container;
}
/**
* Create control buttons for section
* @param {string} sectionId - The section ID
* @returns {HTMLElement} The controls container
*/
createControlButtons(sectionId) {
const controls = document.createElement('div');
controls.className = 'markitect-section-controls';
// Accept button
const acceptBtn = document.createElement('button');
acceptBtn.className = 'markitect-section-btn accept';
acceptBtn.innerHTML = '<span>✓</span> Accept';
acceptBtn.title = 'Accept changes and save this section';
acceptBtn.addEventListener('click', () => this.handleAccept(sectionId));
// Cancel button
const cancelBtn = document.createElement('button');
cancelBtn.className = 'markitect-section-btn cancel';
cancelBtn.innerHTML = '<span>✗</span> Cancel';
cancelBtn.title = 'Cancel editing and revert to state before editing started';
cancelBtn.addEventListener('click', () => this.handleCancel(sectionId));
// Reset button
const resetBtn = document.createElement('button');
resetBtn.className = 'markitect-section-btn reset';
resetBtn.innerHTML = '<span>🔄</span> Reset';
resetBtn.title = 'Reset to original content from render time (discards all changes)';
resetBtn.addEventListener('click', () => this.handleReset(sectionId));
controls.appendChild(acceptBtn);
controls.appendChild(cancelBtn);
controls.appendChild(resetBtn);
return controls;
}
/**
* 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);
}
/**
* Hide current editor
*/
hideCurrentEditor() {
if (this.currentSection) {
this.hideEditor(this.currentSection);
}
}
/**
* Hide editor for specific section
* @param {string} sectionId - The section ID
*/
hideEditor(sectionId) {
// Section manager will trigger re-render through events
this.currentTextarea = null;
this.currentSection = null;
}
/**
* Update section content in DOM
* @param {string} sectionId - The section ID
* @param {string} content - The new content
*/
updateSectionContent(sectionId, content) {
const element = this.findSectionElement(sectionId);
if (!element) return;
// Parse markdown to HTML
if (typeof marked !== 'undefined') {
element.innerHTML = marked.parse(content);
} else {
element.innerHTML = `<p>${content}</p>`;
}
}
/**
* Update textarea content
* @param {string} content - The new content
*/
updateTextareaContent(content) {
if (this.currentTextarea) {
this.currentTextarea.value = content;
}
}
/**
* Handle accept button click
* @param {string} sectionId - The section ID
*/
handleAccept(sectionId) {
try {
this.sectionManager.acceptChanges(sectionId);
} catch (error) {
console.error('Failed to accept changes:', error);
this.showError(error.message);
}
}
/**
* Handle cancel button click
* @param {string} sectionId - The section ID
*/
handleCancel(sectionId) {
try {
this.sectionManager.cancelChanges(sectionId);
} catch (error) {
console.error('Failed to cancel changes:', error);
this.showError(error.message);
}
}
/**
* Handle reset button click
* @param {string} sectionId - The section ID
*/
handleReset(sectionId) {
try {
this.sectionManager.resetToOriginal(sectionId);
} catch (error) {
console.error('Failed to reset section:', error);
this.showError(error.message);
}
}
/**
* Handle keyboard shortcuts
* @param {KeyboardEvent} event - The keyboard event
*/
handleKeydown(event) {
if (!this.currentSection) return;
if (event.ctrlKey || event.metaKey) {
switch (event.key) {
case 'Enter':
event.preventDefault();
this.handleAccept(this.currentSection);
break;
case 'Escape':
event.preventDefault();
this.handleCancel(this.currentSection);
break;
}
}
if (event.key === 'Escape') {
event.preventDefault();
this.handleCancel(this.currentSection);
}
}
/**
* Show error message to user
* @param {string} message - The error message
*/
showError(message) {
// Simple error display - could be enhanced with better UI
console.error('Section Editor Error:', message);
// Create temporary error message
const errorDiv = document.createElement('div');
errorDiv.className = 'markitect-error';
errorDiv.textContent = message;
errorDiv.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: #f44336;
color: white;
padding: 12px 20px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
z-index: 10000;
font-size: 14px;
max-width: 300px;
`;
document.body.appendChild(errorDiv);
// Remove after 5 seconds
setTimeout(() => {
if (errorDiv.parentNode) {
errorDiv.parentNode.removeChild(errorDiv);
}
}, 5000);
}
/**
* Reset all sections to original state
*/
resetAllSections() {
try {
this.sectionManager.resetAllToOriginal();
this.hideCurrentEditor();
// Re-render all sections
const sections = this.sectionManager.getAllSections();
this.renderAllSections(sections);
} catch (error) {
console.error('Failed to reset all sections:', error);
this.showError(error.message);
}
}
/**
* Get document status for UI display
* @returns {Object} Document status
*/
getDocumentStatus() {
return this.sectionManager.getDocumentStatus();
}
/**
* Get complete document markdown
* @returns {string} The markdown document
*/
getDocumentMarkdown() {
return this.sectionManager.getDocumentMarkdown();
}
/**
* Destroy the renderer and clean up event listeners
*/
destroy() {
this.container.removeEventListener('click', this.handleSectionClick);
this.hideCurrentEditor();
this.container.innerHTML = '';
}
}
// Export for testing and usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = { DOMRenderer };
} else {
window.MarkitectEditor = window.MarkitectEditor || {};
window.MarkitectEditor.DOMRenderer = DOMRenderer;
}