generated from coulomb/repo-seed
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>
287 lines
10 KiB
JavaScript
287 lines
10 KiB
JavaScript
/**
|
|
* ContentsControl - Table of Contents Display Control
|
|
*
|
|
* Provides an interactive table of contents for document navigation.
|
|
* Extracts headings from the document and displays them in a hierarchical
|
|
* structure with clickable links for quick navigation.
|
|
*
|
|
* Features:
|
|
* - Automatic heading extraction from document
|
|
* - Hierarchical display with proper indentation
|
|
* - Clickable navigation links with smooth scrolling
|
|
* - Real-time updates when document structure changes
|
|
* - Search functionality within the table of contents
|
|
*
|
|
* Dependencies:
|
|
* - ControlBase (base control functionality)
|
|
*/
|
|
|
|
/**
|
|
* ContentsControl - Interactive table of contents control
|
|
*
|
|
* Built on the base class architecture for consistency with other panels.
|
|
* Only implements content-specific functionality while inheriting all
|
|
* common panel behavior from ControlBase.
|
|
*/
|
|
class ContentsControl extends ControlBase {
|
|
constructor() {
|
|
super();
|
|
|
|
// Configure for contents functionality
|
|
this.config = {
|
|
icon: '📋',
|
|
title: 'Contents',
|
|
className: 'contents-control',
|
|
defaultContent: 'Loading table of contents...',
|
|
ariaLabel: 'Table of Contents Control',
|
|
position: 'w' // West positioning
|
|
};
|
|
|
|
// Contents-specific state
|
|
this.headings = [];
|
|
this.lastScanTime = null;
|
|
this.updateInterval = null;
|
|
this.searchQuery = '';
|
|
}
|
|
|
|
/**
|
|
* Generate contents control content (called by base class buildContent)
|
|
*/
|
|
generateContent() {
|
|
// Extract headings first
|
|
this.extractHeadings();
|
|
|
|
return this.safeOperation(() => {
|
|
if (this.headings.length === 0) {
|
|
return `
|
|
<div style="text-align: center; color: #666; padding: 2rem 0;">
|
|
<p>No headings found in document</p>
|
|
<button onclick="this.closest('.contents-control').contentsControl.refreshContents()"
|
|
style="padding: 0.5rem 1rem; background: #007bff; color: white; border: none; border-radius: 3px; cursor: pointer; margin-top: 0.5rem;">
|
|
🔄 Refresh
|
|
</button>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
const searchHTML = `
|
|
<div style="margin-bottom: 0.5rem; border-bottom: 1px solid #eee; padding-bottom: 0.5rem;">
|
|
<input type="text"
|
|
placeholder="Search headings..."
|
|
style="width: 100%; padding: 0.25rem; border: 1px solid #ddd; border-radius: 3px; font-size: 0.8rem; box-sizing: border-box; overflow: visible;"
|
|
onkeyup="this.closest('.contents-control').contentsControl.handleSearch(this.value)">
|
|
</div>
|
|
`;
|
|
|
|
const filteredHeadings = this.filterHeadings(this.headings, this.searchQuery);
|
|
const contentsHTML = filteredHeadings.map(heading => {
|
|
const indentLevel = Math.max(0, heading.level - 1);
|
|
const indentPx = indentLevel * 15;
|
|
|
|
return `
|
|
<div class="contents-item"
|
|
style="margin-bottom: 0.3rem; padding-left: ${indentPx}px; overflow: visible;">
|
|
<a href="#${heading.id}"
|
|
onclick="event.preventDefault(); this.closest('.contents-control').contentsControl.navigateToHeading('${heading.id}')"
|
|
style="display: block; padding: 0.2rem 0; color: #007bff; text-decoration: none; font-size: 0.8rem; line-height: 1.2; overflow: visible;"
|
|
onmouseover="this.style.backgroundColor='#f8f9fa'"
|
|
onmouseout="this.style.backgroundColor='transparent'">
|
|
<span class="heading-level" style="color: #666; margin-right: 0.3rem;">H${heading.level}</span>
|
|
<span class="heading-text">${heading.text}</span>
|
|
</a>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
const statusHTML = `
|
|
<div style="margin-bottom: 0.5rem; font-size: 0.7rem; color: #666; text-align: center;">
|
|
Found ${filteredHeadings.length} heading${filteredHeadings.length !== 1 ? 's' : ''}
|
|
</div>
|
|
`;
|
|
|
|
const refreshButtonHTML = `
|
|
<div style="border-top: 1px solid #eee; padding-top: 0.5rem; text-align: center; margin-top: 0.5rem;">
|
|
<button onclick="this.closest('.contents-control').contentsControl.refreshContents()"
|
|
style="padding: 0.3rem 0.6rem; font-size: 0.7rem; background: #28a745; color: white; border: none; border-radius: 3px; cursor: pointer;">
|
|
🔄 Refresh Contents
|
|
</button>
|
|
</div>
|
|
`;
|
|
|
|
return `
|
|
${searchHTML}
|
|
${statusHTML}
|
|
${contentsHTML}
|
|
${refreshButtonHTML}
|
|
`;
|
|
|
|
}, 'Error generating contents', 'generateContent');
|
|
}
|
|
|
|
/**
|
|
* Extract all headings from the document
|
|
* Creates a hierarchical structure of the document's heading elements
|
|
*/
|
|
extractHeadings() {
|
|
return this.safeOperation(() => {
|
|
const headingSelectors = 'h1, h2, h3, h4, h5, h6';
|
|
const headingElements = document.querySelectorAll(headingSelectors);
|
|
const extractedHeadings = [];
|
|
|
|
headingElements.forEach((heading, index) => {
|
|
const level = parseInt(heading.tagName.charAt(1));
|
|
const text = heading.textContent.trim();
|
|
|
|
// Generate or use existing ID for anchor links
|
|
let id = heading.id;
|
|
if (!id) {
|
|
id = text.toLowerCase()
|
|
.replace(/[^\w\s-]/g, '')
|
|
.replace(/\s+/g, '-')
|
|
.substring(0, 50);
|
|
|
|
// Ensure uniqueness
|
|
let counter = 1;
|
|
let uniqueId = id;
|
|
while (document.getElementById(uniqueId)) {
|
|
uniqueId = `${id}-${counter}`;
|
|
counter++;
|
|
}
|
|
|
|
heading.id = uniqueId;
|
|
id = uniqueId;
|
|
}
|
|
|
|
extractedHeadings.push({
|
|
id,
|
|
text,
|
|
level,
|
|
element: heading,
|
|
index
|
|
});
|
|
});
|
|
|
|
this.headings = extractedHeadings;
|
|
this.lastScanTime = Date.now();
|
|
return extractedHeadings;
|
|
|
|
}, [], 'extractHeadings');
|
|
}
|
|
|
|
/**
|
|
* Filter headings based on search query
|
|
*/
|
|
filterHeadings(headings, query) {
|
|
if (!query || query.trim() === '') {
|
|
return headings;
|
|
}
|
|
|
|
const normalizedQuery = query.toLowerCase().trim();
|
|
return headings.filter(heading =>
|
|
heading.text.toLowerCase().includes(normalizedQuery)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Navigate to a specific heading with smooth scrolling
|
|
*/
|
|
navigateToHeading(headingId) {
|
|
return this.safeOperation(() => {
|
|
const targetElement = document.getElementById(headingId);
|
|
if (targetElement) {
|
|
targetElement.scrollIntoView({
|
|
behavior: 'smooth',
|
|
block: 'start'
|
|
});
|
|
|
|
// Highlight the target temporarily
|
|
const originalStyle = targetElement.style.backgroundColor;
|
|
targetElement.style.backgroundColor = '#fff3cd';
|
|
targetElement.style.transition = 'background-color 0.3s ease';
|
|
|
|
setTimeout(() => {
|
|
targetElement.style.backgroundColor = originalStyle;
|
|
setTimeout(() => {
|
|
targetElement.style.transition = '';
|
|
}, 300);
|
|
}, 1500);
|
|
|
|
return true;
|
|
}
|
|
return false;
|
|
}, false, 'navigateToHeading');
|
|
}
|
|
|
|
/**
|
|
* Handle search input
|
|
*/
|
|
handleSearch(query) {
|
|
this.searchQuery = query;
|
|
this.buildContent(); // Rebuild content with new filter
|
|
}
|
|
|
|
/**
|
|
* Refresh the contents by re-scanning the document
|
|
*/
|
|
refreshContents() {
|
|
return this.safeOperation(() => {
|
|
this.extractHeadings();
|
|
this.buildContent(); // Rebuild content with updated headings
|
|
|
|
// Show success feedback
|
|
const refreshBtn = this.element?.querySelector('button');
|
|
if (refreshBtn && refreshBtn.textContent.includes('Refresh')) {
|
|
const originalText = refreshBtn.innerHTML;
|
|
refreshBtn.innerHTML = '✅ Updated';
|
|
refreshBtn.style.background = '#28a745';
|
|
|
|
setTimeout(() => {
|
|
refreshBtn.innerHTML = originalText;
|
|
refreshBtn.style.background = '#28a745';
|
|
}, 1000);
|
|
}
|
|
}, null, 'refreshContents');
|
|
}
|
|
|
|
/**
|
|
* 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.contentsControl = this;
|
|
}
|
|
|
|
// Set up auto-refresh for dynamic content
|
|
if (this.updateInterval) {
|
|
clearInterval(this.updateInterval);
|
|
}
|
|
|
|
this.updateInterval = setInterval(() => {
|
|
const currentHeadingCount = document.querySelectorAll('h1, h2, h3, h4, h5, h6').length;
|
|
if (currentHeadingCount !== this.headings.length) {
|
|
this.refreshContents();
|
|
}
|
|
}, 5000); // Check every 5 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 = ContentsControl;
|
|
} else {
|
|
window.ContentsControl = ContentsControl;
|
|
} |