generated from coulomb/repo-seed
Completed Phase 1 refactoring to JavaScript-first architecture: Core Library Implementation: - Created js/testdrive-jsui.js main library class - Integrated all existing components (SectionManager, DOMRenderer, DocumentControls) - Added marked.js integration for markdown rendering - Implemented event-driven API (on/off/emit) - Support for edit/view modes and themes - LocalStorage save/load functionality - Download as markdown file - Keyboard shortcuts (Ctrl+S, Escape) - Auto-save capability (optional) Examples: - examples/full-editor.html - Complete demo with all features - Updated examples/README.md with full documentation Documentation: - Updated README.md with JavaScript-first architecture section - Added complete API reference (constructor, methods, events) - Updated CLAUDE.md with library quick start and API - Emphasized JavaScript-first design principles Architecture: - JavaScript provides ALL functionality - Language adapters are optional integration helpers - Works standalone in browser (no backend required) - Clean separation: JS (functionality) vs Adapters (integration) This completes the architectural shift documented in ARCHITECTURE.md and JS_FIRST_REFACTORING.md Phase 1. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
548 lines
15 KiB
JavaScript
548 lines
15 KiB
JavaScript
/**
|
|
* TestDrive-JSUI - JavaScript-First Markdown Editor
|
|
*
|
|
* Main entry point for the TestDrive-JSUI library.
|
|
* This is a pure JavaScript library for interactive markdown editing.
|
|
* Language adapters (Python, Ruby, Java) are optional integration helpers.
|
|
*
|
|
* @version 1.0.0
|
|
* @license MIT
|
|
*/
|
|
|
|
/**
|
|
* TestDriveJSUI - Main library class
|
|
*
|
|
* Usage:
|
|
* ```javascript
|
|
* const editor = new TestDriveJSUI({
|
|
* container: '#editor',
|
|
* markdown: '# Hello World\n\nEdit me!',
|
|
* mode: 'edit',
|
|
* theme: 'github',
|
|
* autosave: false
|
|
* });
|
|
* ```
|
|
*/
|
|
class TestDriveJSUI {
|
|
constructor(options = {}) {
|
|
// Validate required options
|
|
if (!options.container) {
|
|
throw new Error('TestDriveJSUI: container option is required');
|
|
}
|
|
|
|
// Configuration
|
|
this.config = {
|
|
container: options.container,
|
|
markdown: options.markdown || '# Welcome\n\nStart editing...',
|
|
mode: options.mode || 'edit', // 'edit' or 'view'
|
|
theme: options.theme || 'github',
|
|
autosave: options.autosave || false,
|
|
shortcuts: options.shortcuts !== false, // default true
|
|
sections: options.sections !== false, // default true
|
|
debug: options.debug || false,
|
|
...options
|
|
};
|
|
|
|
// Internal state
|
|
this.container = null;
|
|
this.sectionManager = null;
|
|
this.domRenderer = null;
|
|
this.documentControls = null;
|
|
this.isInitialized = false;
|
|
this.listeners = new Map();
|
|
|
|
// Initialize
|
|
this.init();
|
|
}
|
|
|
|
/**
|
|
* Initialize the editor
|
|
*/
|
|
init() {
|
|
if (this.isInitialized) {
|
|
console.warn('TestDriveJSUI: Already initialized');
|
|
return;
|
|
}
|
|
|
|
// Resolve container
|
|
this.container = this.resolveContainer(this.config.container);
|
|
if (!this.container) {
|
|
throw new Error('TestDriveJSUI: Could not resolve container');
|
|
}
|
|
|
|
// Check for marked.js dependency
|
|
if (typeof marked === 'undefined') {
|
|
console.warn('TestDriveJSUI: marked.js not loaded. Markdown rendering will be limited.');
|
|
}
|
|
|
|
// Apply theme
|
|
this.applyTheme(this.config.theme);
|
|
|
|
// Initialize components based on mode
|
|
if (this.config.mode === 'edit') {
|
|
this.initEditMode();
|
|
} else {
|
|
this.initViewMode();
|
|
}
|
|
|
|
this.isInitialized = true;
|
|
|
|
// Emit initialized event
|
|
this.emit('initialized', {
|
|
mode: this.config.mode,
|
|
theme: this.config.theme,
|
|
sections: this.sectionManager ? this.sectionManager.sections.size : 0
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Initialize edit mode with full interactivity
|
|
*/
|
|
initEditMode() {
|
|
// Load required components
|
|
if (typeof SectionManager === 'undefined') {
|
|
console.error('TestDriveJSUI: SectionManager not loaded');
|
|
return;
|
|
}
|
|
if (typeof DOMRenderer === 'undefined') {
|
|
console.error('TestDriveJSUI: DOMRenderer not loaded');
|
|
return;
|
|
}
|
|
if (typeof DocumentControls === 'undefined') {
|
|
console.error('TestDriveJSUI: DocumentControls not loaded');
|
|
return;
|
|
}
|
|
|
|
// Create section manager
|
|
this.sectionManager = new SectionManager();
|
|
|
|
// Create DOM renderer
|
|
this.domRenderer = new DOMRenderer(this.sectionManager, this.container);
|
|
|
|
// Create document controls
|
|
this.documentControls = new DocumentControls();
|
|
this.documentControls.create();
|
|
|
|
// Set up event handlers for controls
|
|
this.setupControlHandlers();
|
|
|
|
// Parse markdown into sections and render
|
|
this.sectionManager.createSectionsFromMarkdown(this.config.markdown);
|
|
|
|
// Setup keyboard shortcuts if enabled
|
|
if (this.config.shortcuts) {
|
|
this.setupKeyboardShortcuts();
|
|
}
|
|
|
|
// Setup autosave if enabled
|
|
if (this.config.autosave) {
|
|
this.setupAutosave();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initialize view mode (read-only rendering)
|
|
*/
|
|
initViewMode() {
|
|
// Clear container
|
|
this.container.innerHTML = '';
|
|
|
|
// Render markdown directly using marked.js or simple renderer
|
|
const html = this.renderMarkdown(this.config.markdown);
|
|
|
|
// Create content wrapper
|
|
const contentWrapper = document.createElement('div');
|
|
contentWrapper.className = 'testdrive-view-content';
|
|
contentWrapper.innerHTML = html;
|
|
|
|
this.container.appendChild(contentWrapper);
|
|
|
|
// Apply view mode styling
|
|
contentWrapper.style.cssText = `
|
|
padding: 20px;
|
|
max-width: 800px;
|
|
margin: 0 auto;
|
|
line-height: 1.6;
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Setup control panel event handlers
|
|
*/
|
|
setupControlHandlers() {
|
|
this.documentControls.setEventHandlers({
|
|
'save-document': () => {
|
|
const markdown = this.getMarkdown();
|
|
this.emit('save', { markdown });
|
|
|
|
// If autosave is enabled and we have a save handler, call it
|
|
if (this.listeners.has('save')) {
|
|
// User has registered a save handler
|
|
} else {
|
|
// Default: save to localStorage
|
|
this.saveToLocalStorage(markdown);
|
|
alert('Document saved to browser localStorage');
|
|
}
|
|
},
|
|
'reset-all': () => {
|
|
if (confirm('Reset all sections to original content?')) {
|
|
this.resetAll();
|
|
}
|
|
},
|
|
'show-status': () => {
|
|
const status = this.getStatus();
|
|
this.showStatus(status);
|
|
},
|
|
'toggle-debug': () => {
|
|
this.toggleDebug();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Setup keyboard shortcuts
|
|
*/
|
|
setupKeyboardShortcuts() {
|
|
document.addEventListener('keydown', (event) => {
|
|
// Ctrl+S or Cmd+S - Save
|
|
if ((event.ctrlKey || event.metaKey) && event.key === 's') {
|
|
event.preventDefault();
|
|
this.save();
|
|
}
|
|
|
|
// Escape - Close editor
|
|
if (event.key === 'Escape') {
|
|
if (this.domRenderer && this.domRenderer.currentFloatingMenu) {
|
|
this.domRenderer.hideCurrentEditor();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Setup autosave functionality
|
|
*/
|
|
setupAutosave() {
|
|
// Save every 30 seconds
|
|
this.autosaveInterval = setInterval(() => {
|
|
const markdown = this.getMarkdown();
|
|
this.saveToLocalStorage(markdown);
|
|
this.emit('autosave', { markdown });
|
|
}, 30000);
|
|
}
|
|
|
|
/**
|
|
* Resolve container from selector or element
|
|
*/
|
|
resolveContainer(container) {
|
|
if (typeof container === 'string') {
|
|
return document.querySelector(container);
|
|
} else if (container instanceof HTMLElement) {
|
|
return container;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Render markdown to HTML
|
|
*/
|
|
renderMarkdown(markdown) {
|
|
if (typeof marked !== 'undefined') {
|
|
// Use marked.js for full markdown support
|
|
marked.setOptions({
|
|
gfm: true,
|
|
breaks: true,
|
|
headerIds: true,
|
|
mangle: false,
|
|
sanitize: false
|
|
});
|
|
return marked.parse(markdown);
|
|
} else {
|
|
// Fallback to simple rendering
|
|
return this.simpleMarkdownRender(markdown);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Simple markdown renderer (fallback)
|
|
*/
|
|
simpleMarkdownRender(markdown) {
|
|
return markdown
|
|
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
|
|
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
|
|
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
|
|
.replace(/!\[(.*?)\]\((.*?)\)/gim, '<img src="$2" alt="$1" style="max-width: 100%; height: auto;" />')
|
|
.replace(/\[([^\]]+)\]\(([^)]+)\)/gim, '<a href="$2" target="_blank">$1</a>')
|
|
.replace(/\*\*(.*?)\*\*/gim, '<strong>$1</strong>')
|
|
.replace(/\*(.*?)\*/gim, '<em>$1</em>')
|
|
.replace(/`([^`]+)`/gim, '<code>$1</code>')
|
|
.replace(/\n/gim, '<br>');
|
|
}
|
|
|
|
/**
|
|
* Apply theme to the editor
|
|
*/
|
|
applyTheme(themeName) {
|
|
// Remove existing theme classes
|
|
this.container.className = this.container.className
|
|
.split(' ')
|
|
.filter(c => !c.startsWith('theme-'))
|
|
.join(' ');
|
|
|
|
// Add new theme class
|
|
this.container.classList.add(`theme-${themeName}`);
|
|
|
|
// Apply base styles
|
|
this.container.style.cssText = `
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
|
line-height: 1.6;
|
|
color: #24292e;
|
|
background: #ffffff;
|
|
min-height: 300px;
|
|
`;
|
|
}
|
|
|
|
/**
|
|
* Get current markdown content
|
|
*/
|
|
getMarkdown() {
|
|
if (this.sectionManager) {
|
|
return this.sectionManager.getDocumentMarkdown();
|
|
}
|
|
return this.config.markdown;
|
|
}
|
|
|
|
/**
|
|
* Set markdown content
|
|
*/
|
|
setMarkdown(markdown) {
|
|
this.config.markdown = markdown;
|
|
|
|
if (this.config.mode === 'edit' && this.sectionManager) {
|
|
// Re-parse and render sections
|
|
this.sectionManager.sections.clear();
|
|
this.sectionManager.createSectionsFromMarkdown(markdown);
|
|
} else {
|
|
// Re-render view mode
|
|
this.container.innerHTML = '';
|
|
this.initViewMode();
|
|
}
|
|
|
|
this.emit('content-changed', { markdown });
|
|
}
|
|
|
|
/**
|
|
* Get editor status
|
|
*/
|
|
getStatus() {
|
|
if (this.sectionManager) {
|
|
const sections = this.sectionManager.getAllSections();
|
|
const editingSections = sections.filter(s => s.isEditing());
|
|
const modifiedSections = sections.filter(s => s.hasChanges());
|
|
|
|
return {
|
|
mode: this.config.mode,
|
|
totalSections: sections.length,
|
|
editingSections: editingSections.length,
|
|
modifiedSections: modifiedSections.length,
|
|
wordCount: this.getWordCount(),
|
|
characterCount: this.getMarkdown().length
|
|
};
|
|
}
|
|
|
|
return {
|
|
mode: this.config.mode,
|
|
wordCount: this.getWordCount(),
|
|
characterCount: this.config.markdown.length
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get word count
|
|
*/
|
|
getWordCount() {
|
|
const text = this.getMarkdown();
|
|
return text.split(/\s+/).filter(word => word.length > 0).length;
|
|
}
|
|
|
|
/**
|
|
* Show status dialog
|
|
*/
|
|
showStatus(status) {
|
|
const message = [
|
|
`Mode: ${status.mode}`,
|
|
`Total Sections: ${status.totalSections || 0}`,
|
|
`Editing: ${status.editingSections || 0}`,
|
|
`Modified: ${status.modifiedSections || 0}`,
|
|
`Words: ${status.wordCount}`,
|
|
`Characters: ${status.characterCount}`
|
|
].join('\n');
|
|
|
|
alert(message);
|
|
}
|
|
|
|
/**
|
|
* Save current content
|
|
*/
|
|
save() {
|
|
const markdown = this.getMarkdown();
|
|
this.emit('save', { markdown });
|
|
|
|
// Default: save to localStorage
|
|
this.saveToLocalStorage(markdown);
|
|
}
|
|
|
|
/**
|
|
* Save to browser localStorage
|
|
*/
|
|
saveToLocalStorage(markdown) {
|
|
try {
|
|
localStorage.setItem('testdrive-jsui-content', markdown);
|
|
localStorage.setItem('testdrive-jsui-timestamp', new Date().toISOString());
|
|
} catch (e) {
|
|
console.error('Failed to save to localStorage:', e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load from browser localStorage
|
|
*/
|
|
loadFromLocalStorage() {
|
|
try {
|
|
const markdown = localStorage.getItem('testdrive-jsui-content');
|
|
if (markdown) {
|
|
this.setMarkdown(markdown);
|
|
return true;
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load from localStorage:', e);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Download markdown as file
|
|
*/
|
|
download(filename = 'document.md') {
|
|
const markdown = this.getMarkdown();
|
|
const blob = new Blob([markdown], { type: 'text/markdown' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
|
|
this.emit('download', { filename, markdown });
|
|
}
|
|
|
|
/**
|
|
* Reset all sections to original content
|
|
*/
|
|
resetAll() {
|
|
if (this.sectionManager) {
|
|
const sections = this.sectionManager.getAllSections();
|
|
sections.forEach(section => {
|
|
this.sectionManager.resetSection(section.id);
|
|
});
|
|
}
|
|
|
|
this.emit('reset');
|
|
}
|
|
|
|
/**
|
|
* Toggle debug mode
|
|
*/
|
|
toggleDebug() {
|
|
this.config.debug = !this.config.debug;
|
|
|
|
const debugContainer = document.getElementById('debug-messages-container');
|
|
if (debugContainer) {
|
|
debugContainer.style.display = this.config.debug ? 'block' : 'none';
|
|
}
|
|
|
|
this.emit('debug-toggled', { enabled: this.config.debug });
|
|
}
|
|
|
|
/**
|
|
* Event listener system
|
|
*/
|
|
on(event, callback) {
|
|
if (!this.listeners.has(event)) {
|
|
this.listeners.set(event, []);
|
|
}
|
|
this.listeners.get(event).push(callback);
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Remove event listener
|
|
*/
|
|
off(event, callback) {
|
|
if (this.listeners.has(event)) {
|
|
const callbacks = this.listeners.get(event);
|
|
const index = callbacks.indexOf(callback);
|
|
if (index > -1) {
|
|
callbacks.splice(index, 1);
|
|
}
|
|
}
|
|
return this;
|
|
}
|
|
|
|
/**
|
|
* Emit event
|
|
*/
|
|
emit(event, data) {
|
|
if (this.listeners.has(event)) {
|
|
this.listeners.get(event).forEach(callback => {
|
|
try {
|
|
callback(data);
|
|
} catch (e) {
|
|
console.error(`Error in event listener for '${event}':`, e);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Destroy the editor and clean up
|
|
*/
|
|
destroy() {
|
|
// Clear autosave interval
|
|
if (this.autosaveInterval) {
|
|
clearInterval(this.autosaveInterval);
|
|
}
|
|
|
|
// Destroy document controls
|
|
if (this.documentControls) {
|
|
this.documentControls.destroy();
|
|
}
|
|
|
|
// Clear container
|
|
if (this.container) {
|
|
this.container.innerHTML = '';
|
|
}
|
|
|
|
// Clear references
|
|
this.sectionManager = null;
|
|
this.domRenderer = null;
|
|
this.documentControls = null;
|
|
this.listeners.clear();
|
|
this.isInitialized = false;
|
|
|
|
this.emit('destroyed');
|
|
}
|
|
}
|
|
|
|
// Export for CommonJS (Node.js, Jest)
|
|
if (typeof module !== 'undefined' && module.exports) {
|
|
module.exports = { TestDriveJSUI };
|
|
}
|
|
|
|
// Export for browser use
|
|
if (typeof window !== 'undefined') {
|
|
window.TestDriveJSUI = TestDriveJSUI;
|
|
}
|