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:
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user