generated from coulomb/repo-seed
feat: add refactored testdrive-jsui capability with consolidated architecture
Complete integration of refactored testdrive-jsui capability: ## Refactored Architecture - js/ - All JavaScript source (controls, components, core) - static/ - CSS, images, templates - src/testdrive_jsui/ - Python package - tests/ - Python tests ## Plugin Self-Declaration - get_plugin_source_dir() - plugin declares own location - get_asset_paths() - organized asset paths - No hardcoded discovery logic ## Merged Content - Baseline UI scaffold (tutorials, LICENSE, INTRODUCTION.md) - Refactored capability implementation - Comprehensive documentation Ready for standalone use or integration with markitect. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
371
js/controls/status-control.js
Normal file
371
js/controls/status-control.js
Normal file
@@ -0,0 +1,371 @@
|
||||
/**
|
||||
* StatusControl - Document Statistics and Change Tracking Control
|
||||
*
|
||||
* Provides real-time document statistics including word count, character count,
|
||||
* reading time estimation, and change tracking. Monitors document modifications
|
||||
* and provides insights into document structure and content metrics.
|
||||
*
|
||||
* Features:
|
||||
* - Real-time word and character counting
|
||||
* - Reading time estimation based on content
|
||||
* - Document structure analysis (headings, paragraphs, lists)
|
||||
* - Change tracking with before/after comparisons
|
||||
* - Content complexity metrics
|
||||
* - Export functionality for statistics
|
||||
*
|
||||
* Dependencies:
|
||||
* - ControlBase (base control functionality)
|
||||
*/
|
||||
|
||||
/**
|
||||
* StatusControl - Document statistics and monitoring control
|
||||
*
|
||||
* This control continuously monitors the document for changes and provides
|
||||
* detailed statistics about content, structure, and reading metrics.
|
||||
* Useful for writers, editors, and content creators.
|
||||
*/
|
||||
class StatusControl extends ControlBase {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Configure for status functionality
|
||||
this.config = {
|
||||
icon: '📊',
|
||||
title: 'Status',
|
||||
className: 'status-control',
|
||||
defaultContent: 'Loading document statistics...',
|
||||
ariaLabel: 'Document Status Control',
|
||||
position: 'e' // East positioning
|
||||
};
|
||||
|
||||
// Status tracking state
|
||||
this.stats = {
|
||||
characters: 0,
|
||||
charactersNoSpaces: 0,
|
||||
words: 0,
|
||||
sentences: 0,
|
||||
paragraphs: 0,
|
||||
headings: 0,
|
||||
lists: 0,
|
||||
images: 0,
|
||||
links: 0,
|
||||
readingTimeMinutes: 0
|
||||
};
|
||||
|
||||
this.previousStats = { ...this.stats };
|
||||
this.lastUpdateTime = null;
|
||||
this.updateInterval = null;
|
||||
this.wordsPerMinute = 200; // Average reading speed
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and count document content statistics
|
||||
*/
|
||||
analyzeDocument() {
|
||||
return this.safeOperation(() => {
|
||||
const contentArea = document.querySelector('#markitect-content') || document.body;
|
||||
const textContent = contentArea.textContent || '';
|
||||
|
||||
// Basic text statistics
|
||||
this.stats.characters = textContent.length;
|
||||
this.stats.charactersNoSpaces = textContent.replace(/\s/g, '').length;
|
||||
|
||||
// Word counting (more accurate)
|
||||
const words = textContent.trim().split(/\s+/).filter(word => word.length > 0);
|
||||
this.stats.words = words.length;
|
||||
|
||||
// Sentence counting (approximate)
|
||||
const sentences = textContent.split(/[.!?]+/).filter(s => s.trim().length > 0);
|
||||
this.stats.sentences = sentences.length;
|
||||
|
||||
// Structural elements
|
||||
this.stats.paragraphs = contentArea.querySelectorAll('p').length;
|
||||
this.stats.headings = contentArea.querySelectorAll('h1, h2, h3, h4, h5, h6').length;
|
||||
this.stats.lists = contentArea.querySelectorAll('ul, ol').length;
|
||||
this.stats.images = contentArea.querySelectorAll('img').length;
|
||||
this.stats.links = contentArea.querySelectorAll('a').length;
|
||||
|
||||
// Reading time calculation
|
||||
this.stats.readingTimeMinutes = Math.ceil(this.stats.words / this.wordsPerMinute);
|
||||
|
||||
this.lastUpdateTime = Date.now();
|
||||
return this.stats;
|
||||
|
||||
}, this.stats, 'analyzeDocument');
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate changes since last analysis
|
||||
*/
|
||||
calculateChanges() {
|
||||
return this.safeOperation(() => {
|
||||
const changes = {};
|
||||
for (const [key, currentValue] of Object.entries(this.stats)) {
|
||||
const previousValue = this.previousStats[key] || 0;
|
||||
const difference = currentValue - previousValue;
|
||||
changes[key] = {
|
||||
current: currentValue,
|
||||
previous: previousValue,
|
||||
change: difference,
|
||||
hasChanged: difference !== 0
|
||||
};
|
||||
}
|
||||
return changes;
|
||||
}, {}, 'calculateChanges');
|
||||
}
|
||||
|
||||
/**
|
||||
* Format statistics for display
|
||||
*/
|
||||
formatStatistics() {
|
||||
return this.safeOperation(() => {
|
||||
const changes = this.calculateChanges();
|
||||
|
||||
const formatChange = (changeData) => {
|
||||
if (!changeData.hasChanged) return '';
|
||||
const sign = changeData.change > 0 ? '+' : '';
|
||||
const color = changeData.change > 0 ? '#28a745' : '#dc3545';
|
||||
return `<span style="color: ${color}; font-size: 0.7rem;"> (${sign}${changeData.change})</span>`;
|
||||
};
|
||||
|
||||
const formatNumber = (num) => num.toLocaleString();
|
||||
|
||||
return `
|
||||
<div class="stats-grid" style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-bottom: 1rem;">
|
||||
<div class="stat-item">
|
||||
<strong>Words:</strong><br>
|
||||
<span style="font-size: 1.1em; color: #007bff;">${formatNumber(this.stats.words)}</span>
|
||||
${formatChange(changes.words)}
|
||||
</div>
|
||||
|
||||
<div class="stat-item">
|
||||
<strong>Characters:</strong><br>
|
||||
<span style="font-size: 1.1em; color: #007bff;">${formatNumber(this.stats.characters)}</span>
|
||||
${formatChange(changes.characters)}
|
||||
</div>
|
||||
|
||||
<div class="stat-item">
|
||||
<strong>Reading Time:</strong><br>
|
||||
<span style="font-size: 1.1em; color: #007bff;">${this.stats.readingTimeMinutes} min</span>
|
||||
${formatChange(changes.readingTimeMinutes)}
|
||||
</div>
|
||||
|
||||
<div class="stat-item">
|
||||
<strong>Sentences:</strong><br>
|
||||
<span style="font-size: 1.1em; color: #007bff;">${formatNumber(this.stats.sentences)}</span>
|
||||
${formatChange(changes.sentences)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="structure-stats" style="border-top: 1px solid #eee; padding-top: 0.5rem; margin-bottom: 1rem;">
|
||||
<div style="margin: 0 0 0.5rem 0; font-size: 0.9em; font-weight: 600; color: #555;">Document Structure</div>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 0.3rem;">
|
||||
<span>Paragraphs:</span>
|
||||
<span>${this.stats.paragraphs}${formatChange(changes.paragraphs)}</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 0.3rem;">
|
||||
<span>Headings:</span>
|
||||
<span>${this.stats.headings}${formatChange(changes.headings)}</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 0.3rem;">
|
||||
<span>Lists:</span>
|
||||
<span>${this.stats.lists}${formatChange(changes.lists)}</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 0.3rem;">
|
||||
<span>Images:</span>
|
||||
<span>${this.stats.images}${formatChange(changes.images)}</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; margin-bottom: 0.3rem;">
|
||||
<span>Links:</span>
|
||||
<span>${this.stats.links}${formatChange(changes.links)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="actions" style="border-top: 1px solid #eee; padding-top: 0.5rem; text-align: center;">
|
||||
<button onclick="this.closest('.status-control').statusControl.refreshStats()"
|
||||
style="padding: 0.3rem 0.6rem; margin-right: 0.3rem; font-size: 0.7rem; background: #007bff; color: white; border: none; border-radius: 3px; cursor: pointer;">
|
||||
🔄 Refresh
|
||||
</button>
|
||||
|
||||
<button onclick="this.closest('.status-control').statusControl.exportStats()"
|
||||
style="padding: 0.3rem 0.6rem; font-size: 0.7rem; background: #28a745; color: white; border: none; border-radius: 3px; cursor: pointer;">
|
||||
📊 Export
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${this.lastUpdateTime ? `
|
||||
<div style="margin-top: 0.5rem; padding-top: 0.5rem; border-top: 1px solid #eee; font-size: 0.7rem; color: #666; text-align: center;">
|
||||
Updated: ${new Date(this.lastUpdateTime).toLocaleTimeString()}
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
|
||||
}, '<p>Error displaying statistics</p>', 'formatStatistics');
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh statistics and update display
|
||||
*/
|
||||
refreshStats() {
|
||||
return this.safeOperation(() => {
|
||||
// Save current stats as previous
|
||||
this.previousStats = { ...this.stats };
|
||||
|
||||
// Analyze document
|
||||
this.analyzeDocument();
|
||||
|
||||
// Update display
|
||||
this.buildContent();
|
||||
|
||||
// Show success feedback
|
||||
const refreshBtn = this.element?.querySelector('button');
|
||||
if (refreshBtn) {
|
||||
const originalText = refreshBtn.innerHTML;
|
||||
refreshBtn.innerHTML = '✅ Updated';
|
||||
|
||||
setTimeout(() => {
|
||||
refreshBtn.innerHTML = originalText;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
}, null, 'refreshStats');
|
||||
}
|
||||
|
||||
/**
|
||||
* Export statistics to various formats
|
||||
*/
|
||||
exportStats() {
|
||||
return this.safeOperation(() => {
|
||||
const exportData = {
|
||||
timestamp: new Date().toISOString(),
|
||||
document: {
|
||||
title: document.title || 'Untitled Document',
|
||||
url: window.location.href
|
||||
},
|
||||
statistics: this.stats,
|
||||
metadata: {
|
||||
wordsPerMinute: this.wordsPerMinute,
|
||||
analysisDate: new Date(this.lastUpdateTime).toISOString()
|
||||
}
|
||||
};
|
||||
|
||||
// Create downloadable JSON
|
||||
const dataStr = JSON.stringify(exportData, null, 2);
|
||||
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(dataBlob);
|
||||
|
||||
// Create temporary download link
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `document-stats-${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Clean up
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
// Show feedback
|
||||
const exportBtn = this.element?.querySelector('button:last-child');
|
||||
if (exportBtn) {
|
||||
const originalText = exportBtn.innerHTML;
|
||||
exportBtn.innerHTML = '✅ Exported';
|
||||
exportBtn.style.background = '#28a745';
|
||||
|
||||
setTimeout(() => {
|
||||
exportBtn.innerHTML = originalText;
|
||||
exportBtn.style.background = '#28a745';
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
}, null, 'exportStats');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reading difficulty score (Flesch Reading Ease approximation)
|
||||
*/
|
||||
calculateReadabilityScore() {
|
||||
return this.safeOperation(() => {
|
||||
if (this.stats.sentences === 0 || this.stats.words === 0) {
|
||||
return { score: 0, level: 'Unknown' };
|
||||
}
|
||||
|
||||
const avgWordsPerSentence = this.stats.words / this.stats.sentences;
|
||||
const avgSyllablesPerWord = 1.5; // Simplified approximation
|
||||
|
||||
// Flesch Reading Ease formula (simplified)
|
||||
const score = 206.835 - (1.015 * avgWordsPerSentence) - (84.6 * avgSyllablesPerWord);
|
||||
|
||||
let level;
|
||||
if (score >= 90) level = 'Very Easy';
|
||||
else if (score >= 80) level = 'Easy';
|
||||
else if (score >= 70) level = 'Fairly Easy';
|
||||
else if (score >= 60) level = 'Standard';
|
||||
else if (score >= 50) level = 'Fairly Difficult';
|
||||
else if (score >= 30) level = 'Difficult';
|
||||
else level = 'Very Difficult';
|
||||
|
||||
return { score: Math.round(score), level };
|
||||
}, { score: 0, level: 'Unknown' }, 'calculateReadabilityScore');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the control content
|
||||
* Override of base class method to provide status-specific functionality
|
||||
*/
|
||||
/**
|
||||
* Generate status control content (called by base class buildContent)
|
||||
*/
|
||||
generateContent() {
|
||||
// Analyze document first
|
||||
this.analyzeDocument();
|
||||
|
||||
return this.safeOperation(() => {
|
||||
return this.formatStatistics();
|
||||
}, 'Error generating status content', 'generateContent');
|
||||
}
|
||||
|
||||
/**
|
||||
* Override buildContent to add control reference and auto-refresh
|
||||
*/
|
||||
buildContent() {
|
||||
super.buildContent();
|
||||
|
||||
// Store reference to this control for onclick handlers
|
||||
if (this.element) {
|
||||
this.element.statusControl = this;
|
||||
}
|
||||
|
||||
// Set up auto-refresh for dynamic content
|
||||
if (this.updateInterval) {
|
||||
clearInterval(this.updateInterval);
|
||||
}
|
||||
|
||||
this.updateInterval = setInterval(() => {
|
||||
this.refreshStats();
|
||||
}, 10000); // Update every 10 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources when control is destroyed
|
||||
*/
|
||||
destroy() {
|
||||
if (this.updateInterval) {
|
||||
clearInterval(this.updateInterval);
|
||||
this.updateInterval = null;
|
||||
}
|
||||
super.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
// Export for module systems or attach to global for direct usage
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = StatusControl;
|
||||
} else {
|
||||
window.StatusControl = StatusControl;
|
||||
}
|
||||
Reference in New Issue
Block a user