chore: update project state and prepare for image support development
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

- 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>
This commit is contained in:
2025-10-26 08:06:22 +01:00
parent ff6b807f3b
commit d0abaab63a
2730 changed files with 218268 additions and 174 deletions

View File

@@ -0,0 +1,517 @@
/**
* Clean Editor Integration
*
* This file provides a complete, drop-in replacement for the existing
* section editing functionality using our clean object-oriented architecture.
*/
// Include the core classes (in real implementation, these would be imported)
// For now, assuming they're loaded separately
/**
* MarkitectCleanEditor - Main integration class
*
* This replaces the existing complex editor implementation with our
* clean, testable architecture.
*/
class MarkitectCleanEditor {
constructor(markdownContent, containerElement, options = {}) {
this.options = {
theme: 'github',
keyboardShortcuts: true,
autosave: false,
...options
};
// Initialize the core system
this.sectionManager = new MarkitectEditor.SectionManager();
this.domRenderer = new MarkitectEditor.DOMRenderer(this.sectionManager, containerElement);
// Store original content
this.originalMarkdown = markdownContent;
// Event handlers for external integration
this.onDocumentChange = null;
this.onSectionChange = null;
// Setup external event forwarding
this.setupEventForwarding();
// Initialize sections
this.initialize();
}
/**
* Initialize the editor with the markdown content
*/
initialize() {
try {
// Create sections from markdown
const sections = this.sectionManager.createSectionsFromMarkdown(this.originalMarkdown);
console.log(`✓ Initialized clean editor with ${sections.length} sections`);
// Set up global keyboard shortcuts
this.setupGlobalKeyboardShortcuts();
return true;
} catch (error) {
console.error('Failed to initialize clean editor:', error);
return false;
}
}
/**
* Setup event forwarding to external callbacks
*/
setupEventForwarding() {
// Forward document-level changes
this.sectionManager.on('changes-accepted', () => {
if (this.onDocumentChange) {
this.onDocumentChange(this.getDocumentStatus());
}
});
this.sectionManager.on('changes-cancelled', () => {
if (this.onDocumentChange) {
this.onDocumentChange(this.getDocumentStatus());
}
});
// Forward section-level changes
this.sectionManager.on('content-updated', (data) => {
if (this.onSectionChange) {
this.onSectionChange(data);
}
});
}
/**
* Setup global keyboard shortcuts
*/
setupGlobalKeyboardShortcuts() {
if (!this.options.keyboardShortcuts) return;
document.addEventListener('keydown', (event) => {
if (event.ctrlKey || event.metaKey) {
switch (event.key) {
case 's':
event.preventDefault();
this.saveDocument();
break;
case 'z':
if (event.shiftKey) {
event.preventDefault();
// Could implement redo
} else {
event.preventDefault();
// Could implement undo
}
break;
case 'r':
event.preventDefault();
this.resetAllSections();
break;
}
}
});
}
/**
* Get current document markdown
* @returns {string} The complete document markdown
*/
getDocumentMarkdown() {
return this.sectionManager.getDocumentMarkdown();
}
/**
* Get document status
* @returns {Object} Document status object
*/
getDocumentStatus() {
return this.sectionManager.getDocumentStatus();
}
/**
* Check if document has unsaved changes
* @returns {boolean} True if there are unsaved changes
*/
hasUnsavedChanges() {
const status = this.getDocumentStatus();
return status.hasUnsavedChanges;
}
/**
* Save document (triggers download)
*/
saveDocument() {
const markdown = this.getDocumentMarkdown();
const status = this.getDocumentStatus();
// Create download
const blob = new Blob([markdown], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'document.md';
a.style.display = 'none';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
console.log('📄 Document saved:', status);
this.showMessage('Document saved successfully!', 'success');
}
/**
* Reset all sections to original state
*/
resetAllSections() {
if (confirm('Reset all content to original markdown? This will lose all edits.')) {
this.domRenderer.resetAllSections();
this.showMessage('All sections reset to original content', 'info');
}
}
/**
* Show message to user
* @param {string} message - The message to show
* @param {string} type - Message type: 'success', 'error', 'info'
*/
showMessage(message, type = 'info') {
// Create message element
const messageDiv = document.createElement('div');
messageDiv.className = `markitect-message markitect-message-${type}`;
messageDiv.textContent = message;
// Style the message
const styles = {
'success': { bg: '#4caf50', color: 'white' },
'error': { bg: '#f44336', color: 'white' },
'info': { bg: '#2196f3', color: 'white' }
};
const style = styles[type] || styles.info;
messageDiv.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: ${style.bg};
color: ${style.color};
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;
animation: slideIn 0.3s ease;
`;
// Add animation
const styleSheet = document.createElement('style');
styleSheet.textContent = `
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
`;
document.head.appendChild(styleSheet);
document.body.appendChild(messageDiv);
// Remove after 3 seconds
setTimeout(() => {
if (messageDiv.parentNode) {
messageDiv.style.animation = 'slideIn 0.3s ease reverse';
setTimeout(() => {
if (messageDiv.parentNode) {
messageDiv.parentNode.removeChild(messageDiv);
}
}, 300);
}
}, 3000);
}
/**
* Add control panel to the page
*/
addControlPanel() {
const panel = document.createElement('div');
panel.className = 'markitect-control-panel-clean';
panel.innerHTML = `
<div class="control-header">
<h3>Document Editor</h3>
<div class="control-status" id="clean-status">Ready</div>
</div>
<div class="control-actions">
<button id="clean-save" class="control-btn primary">Save & Download</button>
<button id="clean-reset" class="control-btn secondary">Reset All</button>
<button id="clean-status-btn" class="control-btn">Show Status</button>
</div>
`;
// Style the panel
panel.style.cssText = `
position: fixed;
top: 20px;
left: 20px;
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
z-index: 1000;
font-family: system-ui, -apple-system, sans-serif;
min-width: 200px;
`;
// Add styles for buttons
const styles = document.createElement('style');
styles.textContent = `
.control-header h3 { margin: 0 0 8px 0; font-size: 16px; }
.control-status { font-size: 12px; color: #666; }
.control-actions { margin-top: 12px; }
.control-btn {
display: block;
width: 100%;
margin: 4px 0;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
cursor: pointer;
font-size: 14px;
}
.control-btn.primary { background: #2196f3; color: white; border-color: #2196f3; }
.control-btn.secondary { background: #ff9800; color: white; border-color: #ff9800; }
.control-btn:hover { opacity: 0.9; }
`;
document.head.appendChild(styles);
document.body.appendChild(panel);
// Add event listeners
panel.querySelector('#clean-save').addEventListener('click', () => this.saveDocument());
panel.querySelector('#clean-reset').addEventListener('click', () => this.resetAllSections());
panel.querySelector('#clean-status-btn').addEventListener('click', () => this.showStatusDialog());
// Update status periodically
setInterval(() => {
const status = this.getDocumentStatus();
const statusEl = panel.querySelector('#clean-status');
if (status.hasUnsavedChanges) {
statusEl.textContent = `${status.modifiedSections} sections modified`;
statusEl.style.color = '#ff9800';
} else {
statusEl.textContent = 'All changes saved';
statusEl.style.color = '#4caf50';
}
}, 1000);
}
/**
* Show status dialog
*/
showStatusDialog() {
const status = this.getDocumentStatus();
const message = `
Document Status:
• Total sections: ${status.totalSections}
• Modified sections: ${status.modifiedSections}
• Editing sections: ${status.editingSections}
• Saved sections: ${status.savedSections}
• Has unsaved changes: ${status.hasUnsavedChanges ? 'Yes' : 'No'}
`.trim();
alert(message);
}
/**
* Destroy the editor and clean up
*/
destroy() {
if (this.domRenderer) {
this.domRenderer.destroy();
}
// Remove control panel
const panel = document.querySelector('.markitect-control-panel-clean');
if (panel) {
panel.remove();
}
}
}
// CSS for the clean editor
const CLEAN_EDITOR_CSS = `
/* Clean Section Editor Styles */
.markitect-section-editable {
margin: 16px 0;
padding: 12px;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
border: 2px solid transparent;
}
.markitect-section-editable:hover {
background-color: rgba(33, 150, 243, 0.05);
border-color: rgba(33, 150, 243, 0.2);
}
.markitect-section-editable.section-editing {
background-color: rgba(33, 150, 243, 0.1);
border-color: #2196f3;
}
.markitect-section-editable.section-modified {
background-color: rgba(255, 235, 59, 0.1);
border-left: 4px solid #ffc107;
}
.markitect-section-editable.section-saved {
background-color: rgba(76, 175, 80, 0.1);
border-left: 4px solid #4caf50;
}
/* Edit container layout */
.markitect-edit-container {
display: flex;
gap: 12px;
align-items: flex-start;
width: 100%;
}
.markitect-textarea-wrapper {
flex: 1;
min-width: 0;
}
.edit-mode {
width: 100%;
min-height: 60px;
max-height: 360px;
font-family: 'SF Mono', 'Monaco', 'Cascadia Code', 'Roboto Mono', monospace;
border: 2px solid #007acc;
border-radius: 6px;
padding: 12px;
font-size: 14px;
line-height: 1.5;
resize: vertical;
overflow: auto;
box-sizing: border-box;
transition: height 0.15s ease;
background: white;
}
.edit-mode:focus {
outline: none;
border-color: #1976d2;
box-shadow: 0 0 0 3px rgba(25, 118, 210, 0.1);
}
/* Section controls */
.markitect-section-controls {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
background: rgba(255, 255, 255, 0.95);
border: 1px solid #e0e0e0;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-width: 80px;
flex-shrink: 0;
}
.markitect-section-btn {
padding: 8px 12px;
border: none;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
white-space: nowrap;
min-height: 32px;
}
.markitect-section-btn.accept {
background: #4caf50;
color: white;
}
.markitect-section-btn.accept:hover {
background: #45a049;
}
.markitect-section-btn.cancel {
background: #f44336;
color: white;
}
.markitect-section-btn.cancel:hover {
background: #da190b;
}
.markitect-section-btn.reset {
background: #ff9800;
color: white;
}
.markitect-section-btn.reset:hover {
background: #f57c00;
}
/* Responsive design */
@media (max-width: 768px) {
.markitect-edit-container {
flex-direction: column;
gap: 8px;
}
.markitect-section-controls {
flex-direction: row;
justify-content: center;
min-width: auto;
}
.edit-mode {
min-width: 100%;
}
}
@media (max-width: 480px) {
.markitect-section-controls {
flex-wrap: wrap;
gap: 4px;
}
.markitect-section-btn {
flex: 1;
min-width: 70px;
}
}
`;
// Auto-inject CSS when loaded
if (typeof document !== 'undefined') {
const style = document.createElement('style');
style.textContent = CLEAN_EDITOR_CSS;
document.head.appendChild(style);
}
// Export for use
if (typeof module !== 'undefined' && module.exports) {
module.exports = { MarkitectCleanEditor };
} else {
window.MarkitectEditor = window.MarkitectEditor || {};
window.MarkitectEditor.MarkitectCleanEditor = MarkitectCleanEditor;
}

523
src/dom_renderer.js Normal file
View File

@@ -0,0 +1,523 @@
/**
* 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;
}

514
src/section_editor.js Normal file
View File

@@ -0,0 +1,514 @@
/**
* Test-Driven Section Editor Implementation
*
* A clean, object-oriented approach to handling section editing
* that can be tested independently of the DOM.
*/
// Enums for clear state management
const EditState = Object.freeze({
ORIGINAL: 'original',
EDITING: 'editing',
MODIFIED: 'modified',
SAVED: 'saved'
});
const SectionType = Object.freeze({
HEADING: 'heading',
PARAGRAPH: 'paragraph',
LIST: 'list',
CODE: 'code',
BLOCKQUOTE: 'blockquote'
});
/**
* Section class - Core business logic for a single editable section
*
* Responsibilities:
* - Track original and current content
* - Manage edit state transitions
* - Validate content changes
* - Generate stable identifiers
*/
class Section {
constructor(id, originalMarkdown, sectionType = SectionType.PARAGRAPH) {
this.id = id;
this.originalMarkdown = originalMarkdown; // Never changes - always original from render
this.currentMarkdown = originalMarkdown; // Current "saved" state
this.editingMarkdown = null; // Content being edited (only during editing)
this.pendingMarkdown = null; // Unsaved pending changes (when not actively editing)
this.sectionType = sectionType;
this.state = EditState.ORIGINAL;
this.domElement = null; // Will be set by DOM renderer
this.lastSaved = null;
this.created = new Date();
}
/**
* Start editing this section
* @returns {string} The markdown content to populate the editor with
*/
startEdit() {
if (this.state === EditState.EDITING) {
throw new Error(`Section ${this.id} is already being edited`);
}
// Use pending changes if they exist, otherwise use current content
this.editingMarkdown = this.pendingMarkdown || this.currentMarkdown;
this.state = EditState.EDITING;
return this.editingMarkdown;
}
/**
* Update the content during editing
* @param {string} markdown - The new markdown content
*/
updateContent(markdown) {
if (this.state !== EditState.EDITING) {
throw new Error(`Section ${this.id} is not in editing state`);
}
this.editingMarkdown = markdown;
// State remains EDITING; we don't change it until an action is taken
}
/**
* Accept the current changes and save them
* @returns {string} The saved markdown content
*/
acceptChanges() {
if (this.state !== EditState.EDITING) {
throw new Error(`Section ${this.id} is not in editing state`);
}
// Make the edited content the new current content
this.currentMarkdown = this.editingMarkdown;
this.editingMarkdown = null;
this.pendingMarkdown = null; // Clear any pending changes
// Set to SAVED to indicate this section has been explicitly saved
this.state = EditState.SAVED;
this.lastSaved = new Date();
return this.currentMarkdown;
}
/**
* Cancel editing and revert to state before editing started
* @returns {string} The content that was current before editing
*/
cancelChanges() {
if (this.state !== EditState.EDITING) {
throw new Error(`Section ${this.id} is not in editing state`);
}
// Discard editing content and return to the state before editing started
this.editingMarkdown = null;
// Keep any existing pending changes from before this edit session
// Return to the appropriate state
if (this.pendingMarkdown !== null) {
this.state = EditState.MODIFIED; // Has pending changes
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;
}
}
/**
* Reset content to original state from render time
* Can be called in any state; returns section to completely original state
* @returns {string} The original markdown content
*/
resetToOriginal() {
// Reset everything to original state from render time
this.currentMarkdown = this.originalMarkdown;
this.editingMarkdown = null;
this.pendingMarkdown = null; // Clear pending changes
this.lastSaved = null; // Clear save timestamp
this.state = EditState.ORIGINAL;
return this.originalMarkdown;
}
/**
* Stop editing without saving (preserves pending changes as modified)
* @returns {EditState} The new state
*/
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;
}
/**
* Check if the section has unsaved changes
* @returns {boolean}
*/
hasChanges() {
return this.currentMarkdown !== this.originalMarkdown;
}
/**
* Check if the section is currently being edited
* @returns {boolean}
*/
isEditing() {
return this.state === EditState.EDITING;
}
/**
* Check if the section has been modified but not saved
* @returns {boolean}
*/
isModified() {
return this.state === EditState.MODIFIED;
}
/**
* Get a summary of the section's current state
* @returns {Object}
*/
getStatus() {
return {
id: this.id,
state: this.state,
hasChanges: this.hasChanges(),
isEditing: this.isEditing(),
isModified: this.isModified(),
contentLength: this.currentMarkdown.length,
lastSaved: this.lastSaved,
sectionType: this.sectionType
};
}
/**
* Generate a stable ID from content and position
* @param {string} content - The section content
* @param {number} position - The section position
* @returns {string} A stable section ID
*/
static generateId(content, position) {
// Create a simple hash from content + position
const str = content.substring(0, 100) + position.toString();
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32-bit integer
}
return `section_${Math.abs(hash)}_${position}`;
}
/**
* Determine section type from markdown content
* @param {string} markdown - The markdown content
* @returns {SectionType}
*/
static detectType(markdown) {
const trimmed = markdown.trim();
if (trimmed.startsWith('#')) return SectionType.HEADING;
if (trimmed.startsWith('```')) return SectionType.CODE;
if (trimmed.startsWith('>')) return SectionType.BLOCKQUOTE;
if (trimmed.startsWith('-') || trimmed.startsWith('*') || /^\d+\./.test(trimmed)) {
return SectionType.LIST;
}
return SectionType.PARAGRAPH;
}
}
/**
* SectionManager class - Manages the collection of sections
*
* Responsibilities:
* - Track all sections in the document
* - Handle section lifecycle (create, edit, save, delete)
* - Manage edit state transitions between sections
* - Provide document-level operations
*/
class SectionManager {
constructor() {
this.sections = new Map(); // id -> Section
this.editingSection = null; // Currently editing section ID
this.listeners = new Map(); // event -> [callbacks]
}
/**
* Add event listener
* @param {string} event - Event name
* @param {Function} callback - Callback function
*/
on(event, callback) {
if (!this.listeners.has(event)) {
this.listeners.set(event, []);
}
this.listeners.get(event).push(callback);
}
/**
* Emit event to listeners
* @param {string} event - Event name
* @param {*} data - Event data
*/
emit(event, data) {
if (this.listeners.has(event)) {
this.listeners.get(event).forEach(callback => callback(data));
}
}
/**
* Create sections from markdown content
* @param {string} markdownContent - The full markdown document
* @returns {Array<Section>} Created sections
*/
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];
// Check if this line starts a new section
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()) {
// Complete 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;
}
}
// Add the last section
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;
}
/**
* Start editing a section
* @param {string} sectionId - The section ID to edit
* @returns {string} The content to populate the editor with
*/
startEditing(sectionId) {
const section = this.sections.get(sectionId);
if (!section) {
throw new Error(`Section ${sectionId} not found`);
}
// Stop editing any other section first
if (this.editingSection && this.editingSection !== sectionId) {
this.stopEditing(this.editingSection);
}
const content = section.startEdit();
this.editingSection = sectionId;
this.emit('edit-started', { sectionId, content, section: section.getStatus() });
return content;
}
/**
* Update content for the currently editing section
* @param {string} sectionId - The section ID
* @param {string} markdown - The new 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() });
}
/**
* Accept changes for a section
* @param {string} sectionId - The section ID
* @returns {string} The saved content
*/
acceptChanges(sectionId) {
const section = this.sections.get(sectionId);
if (!section) {
throw new Error(`Section ${sectionId} not found`);
}
const content = section.acceptChanges();
if (this.editingSection === sectionId) {
this.editingSection = null;
}
this.emit('changes-accepted', { sectionId, content, section: section.getStatus() });
return content;
}
/**
* Cancel changes for a section
* @param {string} sectionId - The section ID
* @returns {string} The original content
*/
cancelChanges(sectionId) {
const section = this.sections.get(sectionId);
if (!section) {
throw new Error(`Section ${sectionId} not found`);
}
const content = section.cancelChanges();
if (this.editingSection === sectionId) {
this.editingSection = null;
}
this.emit('changes-cancelled', { sectionId, content, section: section.getStatus() });
return content;
}
/**
* Reset section to original content
* @param {string} sectionId - The section ID
* @returns {string} The original content
*/
resetToOriginal(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;
}
/**
* Stop editing a section (preserves changes as modified)
* @param {string} sectionId - The section ID
* @returns {EditState} The new state
*/
stopEditing(sectionId) {
const section = this.sections.get(sectionId);
if (!section) {
throw new Error(`Section ${sectionId} not found`);
}
const newState = section.stopEditing();
if (this.editingSection === sectionId) {
this.editingSection = null;
}
this.emit('edit-stopped', { sectionId, newState, section: section.getStatus() });
return newState;
}
/**
* Get the currently editing section
* @returns {Section|null}
*/
getCurrentlyEditing() {
return this.editingSection ? this.sections.get(this.editingSection) : null;
}
/**
* Get all sections
* @returns {Array<Section>}
*/
getAllSections() {
return Array.from(this.sections.values());
}
/**
* Get sections by state
* @param {EditState} state - The state to filter by
* @returns {Array<Section>}
*/
getSectionsByState(state) {
return this.getAllSections().filter(section => section.state === state);
}
/**
* Get document status
* @returns {Object}
*/
getDocumentStatus() {
const sections = this.getAllSections();
const modified = sections.filter(s => s.isModified()).length;
const editing = sections.filter(s => s.isEditing()).length;
const saved = sections.filter(s => s.state === EditState.SAVED).length;
return {
totalSections: sections.length,
modifiedSections: modified,
editingSections: editing,
savedSections: saved,
hasUnsavedChanges: modified > 0 || editing > 0,
currentlyEditing: this.editingSection
};
}
/**
* Reset all sections to original state
*/
resetAllToOriginal() {
for (const section of this.sections.values()) {
section.resetToOriginal();
}
this.editingSection = null;
this.emit('all-sections-reset', { status: this.getDocumentStatus() });
}
/**
* Get the complete document markdown
* @returns {string}
*/
getDocumentMarkdown() {
return this.getAllSections()
.map(section => section.currentMarkdown)
.join('\n\n');
}
}
// Export for testing and usage
if (typeof module !== 'undefined' && module.exports) {
module.exports = { Section, SectionManager, EditState, SectionType };
} else {
window.MarkitectEditor = { Section, SectionManager, EditState, SectionType };
}

597
src/section_editor.test.js Normal file
View File

@@ -0,0 +1,597 @@
/**
* Test suite for the Section Editor implementation
*
* These tests verify the core business logic independently of the DOM,
* ensuring reliability and consistency.
*/
const { Section, SectionManager, EditState, SectionType } = require('./section_editor.js');
describe('Section Class', () => {
let section;
beforeEach(() => {
section = new Section('test-1', '# Test Header\n\nTest content', SectionType.HEADING);
});
describe('Constructor', () => {
test('should initialize with correct properties', () => {
expect(section.id).toBe('test-1');
expect(section.originalMarkdown).toBe('# Test Header\n\nTest content');
expect(section.currentMarkdown).toBe('# Test Header\n\nTest content');
expect(section.sectionType).toBe(SectionType.HEADING);
expect(section.state).toBe(EditState.ORIGINAL);
expect(section.hasChanges()).toBe(false);
});
});
describe('Edit State Management', () => {
test('should start editing correctly', () => {
const content = section.startEdit();
expect(content).toBe('# Test Header\n\nTest content');
expect(section.state).toBe(EditState.EDITING);
expect(section.isEditing()).toBe(true);
});
test('should throw error when starting edit on already editing section', () => {
section.startEdit();
expect(() => section.startEdit()).toThrow('Section test-1 is already being edited');
});
test('should update content during editing', () => {
section.startEdit();
section.updateContent('# Modified Header\n\nModified content');
// With new behavior: currentMarkdown stays unchanged until accept
expect(section.currentMarkdown).toBe('# Test Header\n\nTest content');
expect(section.editingMarkdown).toBe('# Modified Header\n\nModified content');
expect(section.state).toBe(EditState.EDITING);
expect(section.hasChanges()).toBe(false); // Because currentMarkdown != originalMarkdown check
});
test('should detect no changes when content is same as original', () => {
section.startEdit();
section.updateContent('# Test Header\n\nTest content');
// With new behavior: state remains EDITING during edit session
expect(section.state).toBe(EditState.EDITING);
expect(section.hasChanges()).toBe(false); // currentMarkdown == originalMarkdown
});
test('should throw error when updating content on non-editing section', () => {
expect(() => section.updateContent('new content')).toThrow('Section test-1 is not in editing state');
});
});
describe('Accept Changes', () => {
test('should accept changes correctly', () => {
section.startEdit();
section.updateContent('# Modified Header');
const savedContent = section.acceptChanges();
expect(savedContent).toBe('# Modified Header');
// State becomes SAVED after accepting changes
expect(section.state).toBe(EditState.SAVED);
expect(section.lastSaved).toBeInstanceOf(Date);
});
test('should throw error when accepting changes on non-editing section', () => {
expect(() => section.acceptChanges()).toThrow('Section test-1 is not in editing state');
});
});
describe('Cancel Changes', () => {
test('should cancel changes and revert to original', () => {
section.startEdit();
section.updateContent('# Modified Header');
const originalContent = section.cancelChanges();
expect(originalContent).toBe('# Test Header\n\nTest content');
expect(section.currentMarkdown).toBe('# Test Header\n\nTest content');
expect(section.state).toBe(EditState.ORIGINAL);
expect(section.hasChanges()).toBe(false);
});
});
describe('Reset to Original', () => {
test('should reset to original and exit editing mode', () => {
section.startEdit();
section.updateContent('# Modified Header');
const originalContent = section.resetToOriginal();
expect(originalContent).toBe('# Test Header\n\nTest content');
expect(section.currentMarkdown).toBe('# Test Header\n\nTest content');
expect(section.state).toBe(EditState.ORIGINAL);
expect(section.isEditing()).toBe(false);
});
test('should reset from any state', () => {
// Reset can be called from any state, not just editing
const result = section.resetToOriginal();
expect(result).toBe('# Test Header\n\nTest content');
expect(section.state).toBe(EditState.ORIGINAL);
});
});
describe('Stop Editing', () => {
test('should stop editing and preserve changes as modified', () => {
section.startEdit();
section.updateContent('# Modified Header');
const newState = section.stopEditing();
expect(newState).toBe(EditState.MODIFIED);
expect(section.state).toBe(EditState.MODIFIED);
expect(section.isEditing()).toBe(false);
expect(section.isModified()).toBe(true);
});
test('should stop editing and revert to original if no changes', () => {
section.startEdit();
const newState = section.stopEditing();
expect(newState).toBe(EditState.ORIGINAL);
expect(section.state).toBe(EditState.ORIGINAL);
});
});
describe('Static Methods', () => {
test('should generate stable IDs', () => {
const id1 = Section.generateId('Test content', 0);
const id2 = Section.generateId('Test content', 0);
const id3 = Section.generateId('Test content', 1);
expect(id1).toBe(id2); // Same content, same position
expect(id1).not.toBe(id3); // Same content, different position
expect(id1).toMatch(/^section_\d+_0$/);
});
test('should detect section types correctly', () => {
expect(Section.detectType('# Heading')).toBe(SectionType.HEADING);
expect(Section.detectType('```code```')).toBe(SectionType.CODE);
expect(Section.detectType('> Quote')).toBe(SectionType.BLOCKQUOTE);
expect(Section.detectType('- List item')).toBe(SectionType.LIST);
expect(Section.detectType('1. Numbered list')).toBe(SectionType.LIST);
expect(Section.detectType('Regular paragraph')).toBe(SectionType.PARAGRAPH);
});
});
describe('Status Methods', () => {
test('should return correct status', () => {
const status = section.getStatus();
expect(status).toEqual({
id: 'test-1',
state: EditState.ORIGINAL,
hasChanges: false,
isEditing: false,
isModified: false,
contentLength: section.originalMarkdown.length, // Dynamic length
lastSaved: null,
sectionType: SectionType.HEADING
});
});
});
});
describe('SectionManager Class', () => {
let manager;
beforeEach(() => {
manager = new SectionManager();
});
describe('Section Creation', () => {
test('should create sections from markdown', () => {
const markdown = `# First Header
First paragraph content.
## Second Header
Second paragraph content.
### Third Header
Third paragraph content.`;
const sections = manager.createSectionsFromMarkdown(markdown);
expect(sections).toHaveLength(6); // Each heading and paragraph is a section
expect(sections[0].sectionType).toBe(SectionType.HEADING);
expect(sections[1].sectionType).toBe(SectionType.PARAGRAPH);
expect(sections[2].sectionType).toBe(SectionType.HEADING);
expect(sections[3].sectionType).toBe(SectionType.PARAGRAPH);
expect(sections[4].sectionType).toBe(SectionType.HEADING);
expect(sections[5].sectionType).toBe(SectionType.PARAGRAPH);
});
test('should emit sections-created event', () => {
const eventCallback = jest.fn();
manager.on('sections-created', eventCallback);
const markdown = `# Header\n\nParagraph.`;
manager.createSectionsFromMarkdown(markdown);
expect(eventCallback).toHaveBeenCalledWith({
sections: expect.any(Array),
count: 2
});
});
});
describe('Edit Management', () => {
beforeEach(() => {
const markdown = `# Header One\n\nFirst paragraph.\n\n## Header Two\n\nSecond paragraph.`;
manager.createSectionsFromMarkdown(markdown);
});
test('should start editing a section', () => {
const sections = manager.getAllSections();
const sectionId = sections[0].id;
const content = manager.startEditing(sectionId);
expect(content).toBe('# Header One');
expect(manager.editingSection).toBe(sectionId);
expect(sections[0].isEditing()).toBe(true);
});
test('should emit edit-started event', () => {
const eventCallback = jest.fn();
manager.on('edit-started', eventCallback);
const sections = manager.getAllSections();
const sectionId = sections[0].id;
manager.startEditing(sectionId);
expect(eventCallback).toHaveBeenCalledWith({
sectionId,
content: '# Header One',
section: expect.any(Object)
});
});
test('should stop editing previous section when starting new one', () => {
const sections = manager.getAllSections();
const section1Id = sections[0].id;
const section2Id = sections[1].id;
manager.startEditing(section1Id);
manager.updateContent(section1Id, '# Modified Header');
// Start editing second section
manager.startEditing(section2Id);
expect(manager.editingSection).toBe(section2Id);
expect(sections[0].isModified()).toBe(true);
expect(sections[0].isEditing()).toBe(false);
expect(sections[1].isEditing()).toBe(true);
});
test('should update content correctly', () => {
const sections = manager.getAllSections();
const sectionId = sections[0].id;
manager.startEditing(sectionId);
manager.updateContent(sectionId, '# Modified Header');
// With new behavior: currentMarkdown stays unchanged until accept
expect(sections[0].editingMarkdown).toBe('# Modified Header');
expect(sections[0].isEditing()).toBe(true);
});
test('should accept changes correctly', () => {
const sections = manager.getAllSections();
const sectionId = sections[0].id;
manager.startEditing(sectionId);
manager.updateContent(sectionId, '# Modified Header');
const savedContent = manager.acceptChanges(sectionId);
expect(savedContent).toBe('# Modified Header');
expect(sections[0].state).toBe(EditState.SAVED);
expect(manager.editingSection).toBeNull();
});
test('should cancel changes correctly', () => {
const sections = manager.getAllSections();
const sectionId = sections[0].id;
manager.startEditing(sectionId);
manager.updateContent(sectionId, '# Modified Header');
const originalContent = manager.cancelChanges(sectionId);
expect(originalContent).toBe('# Header One');
expect(sections[0].state).toBe(EditState.ORIGINAL);
expect(manager.editingSection).toBeNull();
});
test('should reset to original correctly', () => {
const sections = manager.getAllSections();
const sectionId = sections[0].id;
manager.startEditing(sectionId);
manager.updateContent(sectionId, '# Modified Header');
const originalContent = manager.resetToOriginal(sectionId);
expect(originalContent).toBe('# Header One');
expect(sections[0].currentMarkdown).toBe('# Header One');
expect(sections[0].isEditing()).toBe(false); // Reset exits editing mode
expect(sections[0].state).toBe(EditState.ORIGINAL);
});
});
describe('Document Status', () => {
beforeEach(() => {
const markdown = `# Header\n\nParagraph one.\n\n## Second\n\nParagraph two.`;
manager.createSectionsFromMarkdown(markdown);
});
test('should return correct document status', () => {
const sections = manager.getAllSections();
// Start editing and modify one section
manager.startEditing(sections[0].id);
manager.updateContent(sections[0].id, '# Modified');
// Accept changes on another section
manager.startEditing(sections[1].id);
manager.acceptChanges(sections[1].id);
const status = manager.getDocumentStatus();
expect(status.totalSections).toBe(4);
expect(status.modifiedSections).toBe(1);
expect(status.editingSections).toBe(0);
expect(status.savedSections).toBe(1);
expect(status.hasUnsavedChanges).toBe(true);
});
test('should get sections by state', () => {
const sections = manager.getAllSections();
manager.startEditing(sections[0].id);
manager.updateContent(sections[0].id, '# Modified');
manager.stopEditing(sections[0].id);
const modifiedSections = manager.getSectionsByState(EditState.MODIFIED);
const originalSections = manager.getSectionsByState(EditState.ORIGINAL);
expect(modifiedSections).toHaveLength(1);
expect(originalSections).toHaveLength(3);
});
test('should reset all sections to original', () => {
const sections = manager.getAllSections();
// Modify multiple sections
manager.startEditing(sections[0].id);
manager.updateContent(sections[0].id, '# Modified');
manager.stopEditing(sections[0].id);
manager.startEditing(sections[1].id);
manager.updateContent(sections[1].id, 'Modified paragraph');
manager.resetAllToOriginal();
const status = manager.getDocumentStatus();
expect(status.hasUnsavedChanges).toBe(false);
expect(status.modifiedSections).toBe(0);
expect(status.editingSections).toBe(0);
});
test('should generate complete document markdown', () => {
const markdown = manager.getDocumentMarkdown();
expect(markdown).toContain('# Header');
expect(markdown).toContain('Paragraph one.');
expect(markdown).toContain('## Second');
expect(markdown).toContain('Paragraph two.');
});
});
describe('Error Handling', () => {
test('should throw error for non-existent section operations', () => {
expect(() => manager.startEditing('non-existent')).toThrow('Section non-existent not found');
expect(() => manager.updateContent('non-existent', 'content')).toThrow('Section non-existent not found');
expect(() => manager.acceptChanges('non-existent')).toThrow('Section non-existent not found');
expect(() => manager.cancelChanges('non-existent')).toThrow('Section non-existent not found');
});
});
describe('Event System', () => {
test('should handle multiple listeners for same event', () => {
const callback1 = jest.fn();
const callback2 = jest.fn();
manager.on('test-event', callback1);
manager.on('test-event', callback2);
manager.emit('test-event', { data: 'test' });
expect(callback1).toHaveBeenCalledWith({ data: 'test' });
expect(callback2).toHaveBeenCalledWith({ data: 'test' });
});
});
});
describe('Integration Tests', () => {
test('should handle complex editing workflow', () => {
const manager = new SectionManager();
const markdown = `# Document Title
Introduction paragraph with some content.
## First Section
Content of the first section.
## Second Section
Content of the second section.
### Subsection
Nested content here.`;
// Create sections
const sections = manager.createSectionsFromMarkdown(markdown);
expect(sections).toHaveLength(8); // Title + intro + 2*(section header + content) + subsection header + content
// Edit multiple sections
const section1Id = sections[1].id; // Introduction paragraph
const section3Id = sections[3].id; // First section content
// Start editing first section
manager.startEditing(section1Id);
manager.updateContent(section1Id, 'Modified introduction with new content.');
// Switch to editing second section (should preserve first as modified)
manager.startEditing(section3Id);
manager.updateContent(section3Id, 'Modified first section content.');
// Check states - section 1 should have pending changes after being stopped
expect(sections[1].isEditing()).toBe(false); // Not currently editing (was stopped when we started editing section 3)
expect(sections[1].isModified()).toBe(true); // Should have pending changes preserved
expect(sections[3].isEditing()).toBe(true); // Currently editing
// Check that they have the right content
expect(sections[3].editingMarkdown).toBe('Modified first section content.');
expect(sections[1].pendingMarkdown).toBe('Modified introduction with new content.'); // Pending changes preserved
// Accept changes on second section
manager.acceptChanges(section3Id);
expect(sections[3].state).toBe(EditState.SAVED);
// Reset first section to discard its pending changes
manager.resetToOriginal(section1Id);
expect(sections[1].state).toBe(EditState.ORIGINAL);
// Verify document status
const status = manager.getDocumentStatus();
expect(status.savedSections).toBe(1);
expect(status.modifiedSections).toBe(0);
expect(status.hasUnsavedChanges).toBe(false);
});
});
describe('Action Semantics - Core Behavior', () => {
describe('Section Action Behavior', () => {
let section;
const ORIGINAL = '# Original Header\n\nOriginal content';
const MODIFIED_1 = '# Modified Header 1\n\nModified content 1';
const MODIFIED_2 = '# Modified Header 2\n\nModified content 2';
beforeEach(() => {
section = new Section('test', ORIGINAL, SectionType.HEADING);
});
test('original content is never modified', () => {
// Start editing
section.startEdit();
section.updateContent(MODIFIED_1);
expect(section.originalMarkdown).toBe(ORIGINAL);
// Accept changes
section.acceptChanges();
expect(section.originalMarkdown).toBe(ORIGINAL);
// Reset should return to original
section.resetToOriginal();
expect(section.originalMarkdown).toBe(ORIGINAL);
expect(section.currentMarkdown).toBe(ORIGINAL);
});
test('reset returns to original state from render time', () => {
// Make some changes and accept them
section.startEdit();
section.updateContent(MODIFIED_1);
section.acceptChanges();
expect(section.currentMarkdown).toBe(MODIFIED_1);
// Reset should go back to original, not the accepted changes
section.resetToOriginal();
expect(section.currentMarkdown).toBe(ORIGINAL);
expect(section.state).toBe(EditState.ORIGINAL);
expect(section.hasChanges()).toBe(false);
});
test('cancel returns to state before editing started', () => {
// Accept some changes first
section.startEdit();
section.updateContent(MODIFIED_1);
section.acceptChanges();
expect(section.currentMarkdown).toBe(MODIFIED_1);
expect(section.state).toBe(EditState.SAVED);
// Start editing again and make different changes
section.startEdit();
section.updateContent(MODIFIED_2);
// Cancel should return to the state before this edit session (SAVED)
const result = section.cancelChanges();
expect(result).toBe(MODIFIED_1);
expect(section.currentMarkdown).toBe(MODIFIED_1);
expect(section.state).toBe(EditState.SAVED); // Back to saved state
});
test('accept makes current changes the new current content', () => {
// Start editing and make changes
section.startEdit();
section.updateContent(MODIFIED_1);
// Accept should make MODIFIED_1 the new current content
const result = section.acceptChanges();
expect(result).toBe(MODIFIED_1);
expect(section.currentMarkdown).toBe(MODIFIED_1);
expect(section.state).toBe(EditState.SAVED); // Explicitly saved
// Now start editing again
section.startEdit();
section.updateContent(MODIFIED_2);
// Cancel should return to MODIFIED_1 (the last accepted state)
section.cancelChanges();
expect(section.currentMarkdown).toBe(MODIFIED_1);
expect(section.state).toBe(EditState.SAVED); // Back to saved state
});
test('multiple edit sessions preserve state correctly', () => {
// Session 1: Edit and accept
section.startEdit();
section.updateContent(MODIFIED_1);
section.acceptChanges();
expect(section.currentMarkdown).toBe(MODIFIED_1);
// Session 2: Edit and cancel
section.startEdit();
section.updateContent(MODIFIED_2);
section.cancelChanges();
expect(section.currentMarkdown).toBe(MODIFIED_1); // Back to accepted state
// Session 3: Edit and accept again
section.startEdit();
section.updateContent(MODIFIED_2);
section.acceptChanges();
expect(section.currentMarkdown).toBe(MODIFIED_2);
// Reset should still go to original
section.resetToOriginal();
expect(section.currentMarkdown).toBe(ORIGINAL);
});
test('editing state tracks editingMarkdown separately', () => {
section.startEdit();
expect(section.editingMarkdown).toBe(ORIGINAL); // Baseline for editing
section.updateContent(MODIFIED_1);
expect(section.editingMarkdown).toBe(MODIFIED_1);
expect(section.currentMarkdown).toBe(ORIGINAL); // Unchanged until accept
section.acceptChanges();
expect(section.editingMarkdown).toBe(null); // Cleared after accept
expect(section.currentMarkdown).toBe(MODIFIED_1); // Now updated
});
});
});