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>
514 lines
17 KiB
JavaScript
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 };
|
|
} |