Added comprehensive plugin system for independent JavaScript UI development: **Plugin Infrastructure:** - Extended existing MarkiTect plugin system with RenderingEnginePlugin base class - Added RENDERING plugin type to PluginType enum - Created RenderingConfig for asset management and deployment - Implemented RenderingEngineManager for plugin discovery and lifecycle **TestDrive JSUI Plugin:** - Extracted JavaScript UI components to independent testdrive-jsui plugin - Created standalone development environment (no Python required) - Implemented compass-positioned control panels (NW, NE, E, SE) - Added clean JSON configuration interface for Python↔JavaScript data transfer **Asset Management:** - Development mode: serve assets directly from plugin source directory - Production mode: deploy to _markitect/plugins/[plugin-name]/ structure - Configurable asset URLs and deployment strategies - Support for external dependencies (CDN resources) **Standalone Development:** - testdrive-jsui/test.html for browser-based development - Package.json with npm scripts for development server - Complete separation of JavaScript development from Python environment - Hot reload and standard web development workflow **Integration Demo:** - demo_plugin_integration.py showcasing all plugin capabilities - Standalone, plugin discovery, production deployment examples - Asset URL generation for different deployment modes This enables JavaScript-first development while maintaining clean integration with the MarkiTect Python ecosystem. Developers can now work on UI components independently using standard web development tools and workflows. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
544 lines
17 KiB
JavaScript
544 lines
17 KiB
JavaScript
/**
|
|
* SectionManager Component
|
|
*
|
|
* Extracted from monolithic editor.js as part of architecture refactoring.
|
|
* Manages the collection of sections and their state transitions.
|
|
*
|
|
* Dependencies:
|
|
* - EditState enum (imported)
|
|
* - SectionType enum (imported)
|
|
* - Section class (imported)
|
|
* - debug function (imported)
|
|
*/
|
|
|
|
// Import dependencies - these will be separate modules
|
|
const EditState = Object.freeze({
|
|
ORIGINAL: 'original',
|
|
EDITING: 'editing',
|
|
MODIFIED: 'modified',
|
|
SAVED: 'saved'
|
|
});
|
|
|
|
const SectionType = Object.freeze({
|
|
HEADING: 'heading',
|
|
PARAGRAPH: 'paragraph',
|
|
LIST: 'list',
|
|
CODE: 'code',
|
|
QUOTE: 'quote',
|
|
TABLE: 'table',
|
|
HR: 'hr',
|
|
IMAGE: 'image'
|
|
});
|
|
|
|
// Debug function (will be extracted to utils)
|
|
function debug(message, category = 'INFO') {
|
|
// Simple console debug for now - will be enhanced later
|
|
console.log(`DEBUG ${category}: ${message}`);
|
|
}
|
|
|
|
/**
|
|
* Section Class - manages individual section state and content
|
|
*/
|
|
class Section {
|
|
constructor(id, markdown, type) {
|
|
this.id = id;
|
|
this.originalMarkdown = markdown;
|
|
this.currentMarkdown = markdown;
|
|
this.editingMarkdown = markdown;
|
|
this.pendingMarkdown = null;
|
|
this.type = type;
|
|
this.state = EditState.ORIGINAL;
|
|
this.domElement = null;
|
|
this.lastSaved = null;
|
|
this.created = new Date();
|
|
}
|
|
|
|
static generateId(markdown, position, strategy = 'hash', parentId = null) {
|
|
return this.generateIdWithStrategy(markdown, position, strategy, parentId);
|
|
}
|
|
|
|
static generateIdWithStrategy(markdown, position, strategy = 'hash', parentId = null) {
|
|
const sanitizedContent = this.sanitizeContentForId(markdown);
|
|
const normalizedContent = this.normalizeContentForHashing(sanitizedContent);
|
|
const sectionType = this.detectType(markdown);
|
|
|
|
switch (strategy) {
|
|
case 'timestamp':
|
|
return this.generateTimestampId(normalizedContent, position, sectionType);
|
|
case 'sequential':
|
|
return this.generateSequentialId(normalizedContent, position, sectionType);
|
|
case 'hierarchical':
|
|
return this.generateHierarchicalId(normalizedContent, position, parentId);
|
|
case 'hash':
|
|
default:
|
|
return this.generateAdvancedId(normalizedContent, position, sectionType);
|
|
}
|
|
}
|
|
|
|
static generateAdvancedId(content, position, sectionType) {
|
|
const contentHash = this.generateCryptoHash(content);
|
|
const safeType = sectionType || 'paragraph';
|
|
const typePrefix = safeType.substring(0, 3);
|
|
const positionHex = position.toString(16).padStart(2, '0');
|
|
|
|
return `section-${typePrefix}-${contentHash}-${positionHex}`;
|
|
}
|
|
|
|
static generateCryptoHash(content) {
|
|
let hash = 0;
|
|
if (content.length === 0) return '00000000';
|
|
|
|
for (let i = 0; i < content.length; i++) {
|
|
const char = content.charCodeAt(i);
|
|
hash = ((hash << 5) - hash) + char;
|
|
hash = hash & hash;
|
|
}
|
|
|
|
const hexHash = Math.abs(hash).toString(16).padStart(8, '0');
|
|
return hexHash.substring(0, 8);
|
|
}
|
|
|
|
static normalizeContentForHashing(content) {
|
|
if (!content || typeof content !== 'string') {
|
|
return '';
|
|
}
|
|
|
|
return content
|
|
.trim()
|
|
.replace(/\s+/g, ' ')
|
|
.replace(/\r\n/g, '\n')
|
|
.toLowerCase();
|
|
}
|
|
|
|
static sanitizeContentForId(content) {
|
|
if (!content || typeof content !== 'string') {
|
|
return '';
|
|
}
|
|
|
|
return content
|
|
.replace(/<[^>]*>/g, '')
|
|
.replace(/javascript:/gi, '')
|
|
.replace(/[^\w\s\-_.#]/g, '')
|
|
.trim();
|
|
}
|
|
|
|
static generateTimestampId(content, position = 0, sectionType = 'paragraph') {
|
|
const timestamp = Date.now().toString(36);
|
|
const contentSnippet = this.generateCryptoHash(content || '').substring(0, 4);
|
|
const safeType = sectionType || 'paragraph';
|
|
const typePrefix = safeType.substring(0, 3);
|
|
|
|
return `section-${typePrefix}-${contentSnippet}-${timestamp}`;
|
|
}
|
|
|
|
static generateSequentialId(content, position, sectionType = 'paragraph') {
|
|
const safeType = sectionType || 'paragraph';
|
|
const typePrefix = safeType.substring(0, 3);
|
|
const seqNumber = (position || 0).toString().padStart(3, '0');
|
|
const contentHash = this.generateCryptoHash(content || '').substring(0, 4);
|
|
|
|
return `section-${typePrefix}-seq${seqNumber}-${contentHash}`;
|
|
}
|
|
|
|
static generateHierarchicalId(content, position, parentId = null) {
|
|
const contentHash = this.generateCryptoHash(content || '').substring(0, 6);
|
|
|
|
if (parentId) {
|
|
const childIndex = (position || 0).toString().padStart(2, '0');
|
|
return `${parentId}-child-${childIndex}-${contentHash}`;
|
|
} else {
|
|
return `section-root-${position || 0}-${contentHash}`;
|
|
}
|
|
}
|
|
|
|
static detectType(markdown) {
|
|
if (!markdown || typeof markdown !== 'string') {
|
|
return SectionType.PARAGRAPH;
|
|
}
|
|
|
|
const content = markdown.replace(/^\n+|\n+$/g, '');
|
|
if (!content) {
|
|
return SectionType.PARAGRAPH;
|
|
}
|
|
|
|
const trimmed = content.trim();
|
|
|
|
// Detection order matters - most specific first
|
|
if (this.isHeading(trimmed)) {
|
|
return SectionType.HEADING;
|
|
}
|
|
|
|
if (this.isImage(trimmed)) {
|
|
return SectionType.IMAGE;
|
|
}
|
|
|
|
if (this.isCodeBlock(trimmed)) {
|
|
return SectionType.CODE;
|
|
}
|
|
|
|
return SectionType.PARAGRAPH;
|
|
}
|
|
|
|
static isHeading(trimmed) {
|
|
const headingPattern = /^#{1,6}\s+.+/;
|
|
return headingPattern.test(trimmed);
|
|
}
|
|
|
|
static isImage(trimmed) {
|
|
const imagePattern = /!\[.*?\]\([^)]+\)/;
|
|
return imagePattern.test(trimmed);
|
|
}
|
|
|
|
static isCodeBlock(trimmed) {
|
|
if (trimmed.startsWith('```') || trimmed.startsWith('~~~')) {
|
|
return true;
|
|
}
|
|
if (trimmed.includes('```') || trimmed.includes('~~~')) {
|
|
const codeBlockPattern = /```[\s\S]*?```|~~~[\s\S]*?~~~/;
|
|
if (codeBlockPattern.test(trimmed)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
startEdit() {
|
|
if (this.state === EditState.EDITING) {
|
|
throw new Error(`Section ${this.id} is already being edited`);
|
|
}
|
|
this.editingMarkdown = this.pendingMarkdown || this.currentMarkdown;
|
|
this.state = EditState.EDITING;
|
|
return this.editingMarkdown;
|
|
}
|
|
|
|
updateContent(markdown) {
|
|
if (this.state !== EditState.EDITING) {
|
|
throw new Error(`Section ${this.id} is not in editing state`);
|
|
}
|
|
this.editingMarkdown = markdown;
|
|
}
|
|
|
|
acceptChanges() {
|
|
if (this.state !== EditState.EDITING) {
|
|
throw new Error(`Section ${this.id} is not in editing state`);
|
|
}
|
|
this.currentMarkdown = this.editingMarkdown;
|
|
this.editingMarkdown = null;
|
|
this.pendingMarkdown = null;
|
|
this.state = EditState.SAVED;
|
|
this.lastSaved = new Date();
|
|
return this.currentMarkdown;
|
|
}
|
|
|
|
cancelChanges() {
|
|
if (this.state !== EditState.EDITING) {
|
|
throw new Error(`Section ${this.id} is not in editing state`);
|
|
}
|
|
this.editingMarkdown = null;
|
|
if (this.pendingMarkdown !== null) {
|
|
this.state = EditState.MODIFIED;
|
|
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;
|
|
}
|
|
}
|
|
|
|
stopEditing() {
|
|
if (this.state !== EditState.EDITING) {
|
|
return this.state;
|
|
}
|
|
|
|
if (this.editingMarkdown && this.editingMarkdown !== this.currentMarkdown) {
|
|
this.pendingMarkdown = this.editingMarkdown;
|
|
this.state = EditState.MODIFIED;
|
|
} else {
|
|
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;
|
|
}
|
|
|
|
resetToOriginal() {
|
|
this.currentMarkdown = this.originalMarkdown;
|
|
this.editingMarkdown = this.originalMarkdown;
|
|
this.pendingMarkdown = null;
|
|
this.state = EditState.ORIGINAL;
|
|
return this.originalMarkdown;
|
|
}
|
|
|
|
isEditing() {
|
|
return this.state === EditState.EDITING;
|
|
}
|
|
|
|
hasChanges() {
|
|
return this.currentMarkdown !== this.originalMarkdown;
|
|
}
|
|
|
|
getStatus() {
|
|
return {
|
|
id: this.id,
|
|
state: this.state,
|
|
hasChanges: this.hasChanges(),
|
|
isEditing: this.isEditing(),
|
|
contentLength: this.currentMarkdown.length,
|
|
lastSaved: this.lastSaved,
|
|
type: this.type,
|
|
originalLength: this.originalMarkdown.length,
|
|
currentLength: this.currentMarkdown.length
|
|
};
|
|
}
|
|
|
|
isImage() {
|
|
return this.type === SectionType.IMAGE;
|
|
}
|
|
|
|
redetectType(content = null) {
|
|
const markdown = content || this.currentMarkdown;
|
|
const oldType = this.type;
|
|
this.type = Section.detectType(markdown);
|
|
|
|
if (oldType !== this.type) {
|
|
debug(`Section ${this.id} type changed from ${oldType} to ${this.type}`, 'TYPE_DETECTION');
|
|
}
|
|
|
|
return this.type;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* SectionManager - Manages the collection of sections
|
|
*/
|
|
class SectionManager {
|
|
constructor() {
|
|
this.sections = new Map();
|
|
this.listeners = new Map();
|
|
this.statusInterval = null;
|
|
this.lastStatusUpdate = new Date().toISOString();
|
|
}
|
|
|
|
on(event, callback) {
|
|
if (!this.listeners.has(event)) {
|
|
this.listeners.set(event, []);
|
|
}
|
|
this.listeners.get(event).push(callback);
|
|
}
|
|
|
|
emit(event, data) {
|
|
if (this.listeners.has(event)) {
|
|
this.listeners.get(event).forEach(callback => callback(data));
|
|
}
|
|
}
|
|
|
|
createSectionsFromMarkdown(markdownContent) {
|
|
// Split content into blocks separated by double newlines
|
|
const blocks = markdownContent.split(/\n\s*\n/);
|
|
const sections = [];
|
|
let position = 0;
|
|
|
|
for (const block of blocks) {
|
|
const trimmedBlock = block.trim();
|
|
if (!trimmedBlock) continue;
|
|
|
|
// Check if this block should be split further
|
|
const lines = trimmedBlock.split('\n');
|
|
let currentSection = '';
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
const isHeading = /^#{1,6}\s/.test(line.trim());
|
|
const isImage = /^\s*!\[.*?\]\(.*?\)\s*$/.test(line);
|
|
|
|
// Each heading or image starts a new section
|
|
if ((isHeading || isImage) && currentSection.trim()) {
|
|
// Save 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;
|
|
}
|
|
}
|
|
|
|
// Save the final section from this block
|
|
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);
|
|
position++;
|
|
}
|
|
}
|
|
|
|
this.emit('sections-created', { sections, count: sections.length });
|
|
return sections;
|
|
}
|
|
|
|
startEditing(sectionId) {
|
|
debug('MANAGER: startEditing called for: ' + sectionId, 'MANAGER');
|
|
|
|
const section = this.sections.get(sectionId);
|
|
if (!section) {
|
|
throw new Error(`Section ${sectionId} not found`);
|
|
}
|
|
|
|
if (section.isEditing()) {
|
|
debug('MANAGER: Section already in editing state: ' + sectionId, 'MANAGER');
|
|
return section.editingMarkdown;
|
|
}
|
|
|
|
debug('MANAGER: Starting edit for section: ' + sectionId, 'MANAGER');
|
|
const content = section.startEdit();
|
|
|
|
debug('MANAGER: About to emit edit-started event for: ' + sectionId, 'MANAGER');
|
|
this.emit('edit-started', { sectionId, content, section: section.getStatus() });
|
|
debug('MANAGER: Emitted edit-started event for: ' + sectionId, 'MANAGER');
|
|
|
|
return content;
|
|
}
|
|
|
|
updateContent(sectionId, markdown) {
|
|
const section = this.sections.get(sectionId);
|
|
if (!section) {
|
|
throw new Error(`Section ${sectionId} not found`);
|
|
}
|
|
|
|
const oldType = section.type;
|
|
section.updateContent(markdown);
|
|
const newType = section.redetectType(markdown);
|
|
|
|
const eventData = {
|
|
sectionId,
|
|
markdown,
|
|
section: section.getStatus(),
|
|
typeChanged: oldType !== newType,
|
|
oldType,
|
|
newType
|
|
};
|
|
|
|
this.emit('content-updated', eventData);
|
|
|
|
if (oldType !== newType) {
|
|
this.emit('section-type-changed', {
|
|
sectionId,
|
|
oldType,
|
|
newType,
|
|
section: section.getStatus()
|
|
});
|
|
}
|
|
}
|
|
|
|
acceptChanges(sectionId) {
|
|
const section = this.sections.get(sectionId);
|
|
if (!section) {
|
|
throw new Error(`Section ${sectionId} not found`);
|
|
}
|
|
|
|
const content = section.acceptChanges();
|
|
this.emit('changes-accepted', { sectionId, content, section: section.getStatus() });
|
|
return content;
|
|
}
|
|
|
|
cancelChanges(sectionId) {
|
|
const section = this.sections.get(sectionId);
|
|
if (!section) {
|
|
throw new Error(`Section ${sectionId} not found`);
|
|
}
|
|
|
|
const content = section.cancelChanges();
|
|
this.emit('changes-cancelled', { sectionId, content, section: section.getStatus() });
|
|
return content;
|
|
}
|
|
|
|
resetSection(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;
|
|
}
|
|
|
|
getDocumentMarkdown() {
|
|
const sortedSections = Array.from(this.sections.values())
|
|
.sort((a, b) => a.created - b.created);
|
|
|
|
return sortedSections.map(section => section.currentMarkdown).join('\n\n');
|
|
}
|
|
|
|
getAllSections() {
|
|
return Array.from(this.sections.values());
|
|
}
|
|
|
|
getDocumentStatus() {
|
|
const sections = Array.from(this.sections.values());
|
|
const editingSections = sections.filter(section => section.isEditing).length;
|
|
|
|
return {
|
|
totalSections: sections.length,
|
|
editingSections: editingSections
|
|
};
|
|
}
|
|
|
|
extractHeadings(content) {
|
|
if (!content) return [];
|
|
const lines = content.split('\n');
|
|
return lines.filter(line => /^#{1,6}\s/.test(line.trim()));
|
|
}
|
|
|
|
handleSectionSplit(sectionId, newContent) {
|
|
const section = this.sections.get(sectionId);
|
|
if (!section) {
|
|
throw new Error(`Section ${sectionId} not found`);
|
|
}
|
|
|
|
// Remove the original section
|
|
this.sections.delete(sectionId);
|
|
|
|
// Create new sections from the content
|
|
const newSections = this.createSectionsFromMarkdown(newContent);
|
|
|
|
// Emit section-split event
|
|
this.emit('section-split', {
|
|
originalSectionId: sectionId,
|
|
newSections: newSections,
|
|
count: newSections.length
|
|
});
|
|
|
|
return newSections;
|
|
}
|
|
|
|
createSectionsFromContent(content) {
|
|
return this.createSectionsFromMarkdown(content);
|
|
}
|
|
}
|
|
|
|
// Export for use in tests and other modules
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = { SectionManager, Section, EditState, SectionType };
|
|
}
|
|
|
|
// Export for browser use
|
|
if (typeof window !== 'undefined') {
|
|
window.SectionManager = SectionManager;
|
|
window.Section = Section;
|
|
window.EditState = EditState;
|
|
window.SectionType = SectionType;
|
|
} |