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

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

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

514 lines
17 KiB
JavaScript

/**
* 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 };
}