generated from coulomb/repo-seed
feat: implement JavaScript-first TestDriveJSUI library (v1.0.0)
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>
This commit is contained in:
547
js/testdrive-jsui.js
Normal file
547
js/testdrive-jsui.js
Normal file
@@ -0,0 +1,547 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user