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
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:
517
src/clean_editor_integration.js
Normal file
517
src/clean_editor_integration.js
Normal 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
523
src/dom_renderer.js
Normal 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
514
src/section_editor.js
Normal 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
597
src/section_editor.test.js
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user