Files
markitect-main/testdrive-jsui/static/js/widgets/navigation/DocumentNavigator.js
tegwick 8ef356af57 feat: implement plugin infrastructure for rendering engines
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>
2025-11-14 06:49:41 +01:00

625 lines
19 KiB
JavaScript

/**
* DocumentNavigator Widget
*
* Substack-style floating document navigation widget that displays a hierarchical
* table of contents based on document headings. Supports smooth scrolling,
* scroll spy, expand/collapse, and responsive behavior.
*/
import { UIWidget } from '../base/UIWidget.js';
export class DocumentNavigator extends UIWidget {
constructor(options = {}) {
super(options);
// Navigation state
this.isCollapsed = this.config.collapsed;
this.currentSection = null;
this.headings = [];
this.navigationTree = [];
// Scroll spy state
this.scrollSpyEnabled = this.config.enableScrollSpy;
this.scrollThrottle = null;
// Event bindings
this.boundScrollHandler = this.handleScroll.bind(this);
this.boundResizeHandler = this.handleResize.bind(this);
// Initialize responsive behavior
this.mediaQuery = window.matchMedia('(max-width: 768px)');
}
getDefaultConfig() {
return {
...super.getDefaultConfig(),
position: 'left', // 'left' or 'right'
collapsed: true, // Start collapsed
autoHide: true, // Hide on mobile
maxHeadingLevel: 3, // H1, H2, H3
enableScrollSpy: true, // Highlight current section
smoothScroll: true, // Smooth scroll behavior
animationDuration: 300, // Animation timing
minHeadings: 2, // Min headings to show navigator
theme: 'default', // Theme support
// Styling options
width: '280px',
collapsedWidth: '40px',
offset: { top: '80px', side: '20px' },
// Accessibility
enableKeyboard: true,
ariaLabel: 'Document Navigation'
};
}
async initialize() {
await super.initialize();
// Extract headings from container
this.extractHeadings();
this.buildNavigationTree();
// Set up event listeners
if (this.scrollSpyEnabled) {
window.addEventListener('scroll', this.boundScrollHandler, { passive: true });
}
if (this.config.autoHide) {
window.addEventListener('resize', this.boundResizeHandler);
this.handleResize(); // Initial check
}
return this;
}
async render() {
if (this.isRendered) {
return this.element;
}
// Check if we have enough headings
if (this.headings.length < this.config.minHeadings) {
this.isRendered = true;
return null; // Don't render if too few headings
}
// Create main container
this.element = this.createElement('nav', {
className: 'document-navigator markitect-widget',
attributes: {
'aria-label': this.config.ariaLabel,
'role': 'navigation'
},
style: this.getNavigatorStyle()
});
// Apply CSS classes
this.applyCSSClasses();
this.addClass('theme-' + this.theme);
this.addClass('position-' + this.config.position);
// Create toggle button (always visible)
this.createToggleButton();
// Create navigation list (hidden when collapsed)
this.createNavigationList();
// Set initial visibility state
if (this.isCollapsed) {
await this.collapse({ immediate: true });
} else {
await this.expand({ immediate: true });
}
// Append to container
this.container.appendChild(this.element);
// Initialize scroll spy
if (this.scrollSpyEnabled) {
this.updateCurrentSection();
}
this.isRendered = true;
this.emit('rendered');
return this.element;
}
createToggleButton() {
this.toggleButton = this.createElement('button', {
className: 'navigator-toggle',
attributes: {
'type': 'button',
'aria-label': this.isCollapsed ? 'Expand navigation' : 'Collapse navigation',
'aria-expanded': !this.isCollapsed
},
innerHTML: this.getToggleIcon(),
style: this.getToggleStyle()
});
// Toggle on click
this.toggleButton.addEventListener('click', async () => {
await this.toggle();
});
// Keyboard support
if (this.config.enableKeyboard) {
this.toggleButton.addEventListener('keydown', this.handleKeyboard.bind(this));
}
this.element.appendChild(this.toggleButton);
}
createNavigationList() {
this.navigationList = this.createElement('div', {
className: 'navigator-list',
style: this.getListStyle()
});
if (this.headings.length === 0) {
this.createEmptyState();
} else {
this.populateNavigationList();
}
this.element.appendChild(this.navigationList);
}
createEmptyState() {
const emptyMessage = this.createElement('div', {
className: 'navigator-empty',
textContent: 'No headings found',
style: {
padding: '1rem',
textAlign: 'center',
color: '#666',
fontStyle: 'italic'
}
});
this.navigationList.appendChild(emptyMessage);
}
populateNavigationList() {
// Create header
const header = this.createElement('div', {
className: 'navigator-header',
innerHTML: `
<h3>Contents</h3>
<button class="navigator-close" aria-label="Close navigation">✕</button>
`,
style: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '1rem 1rem 0.5rem',
borderBottom: '1px solid #eee',
marginBottom: '0.5rem'
}
});
// Close button functionality
const closeButton = header.querySelector('.navigator-close');
closeButton.addEventListener('click', async () => {
await this.collapse();
});
this.navigationList.appendChild(header);
// Create navigation items
const navContainer = this.createElement('div', {
className: 'navigator-items',
style: {
maxHeight: '70vh',
overflowY: 'auto',
padding: '0 0.5rem 1rem'
}
});
this.renderNavigationTree(navContainer, this.navigationTree);
this.navigationList.appendChild(navContainer);
}
renderNavigationTree(container, items, level = 0) {
items.forEach(item => {
const navItem = this.createElement('div', {
className: `navigator-item level-${level}`,
style: {
marginLeft: `${level * 1}rem`,
marginBottom: '0.25rem'
}
});
// Create clickable link
const link = this.createElement('a', {
className: 'navigator-link',
textContent: item.text,
attributes: {
'href': `#${item.id}`,
'data-target': item.id,
'data-level': item.level,
'role': 'button',
'tabindex': '0'
},
style: {
display: 'block',
padding: '0.5rem 0.75rem',
textDecoration: 'none',
color: '#333',
borderRadius: '4px',
fontSize: level === 0 ? '0.9rem' : '0.8rem',
fontWeight: level === 0 ? '600' : '400',
transition: 'all 0.2s ease',
cursor: 'pointer'
}
});
// Hover effects
link.addEventListener('mouseenter', () => {
link.style.backgroundColor = '#f0f0f0';
});
link.addEventListener('mouseleave', () => {
if (!link.classList.contains('active')) {
link.style.backgroundColor = '';
}
});
// Click navigation
link.addEventListener('click', (e) => {
e.preventDefault();
this.navigateToHeading(item.id);
});
navItem.appendChild(link);
// Render children recursively
if (item.children && item.children.length > 0) {
this.renderNavigationTree(navItem, item.children, level + 1);
}
container.appendChild(navItem);
});
}
extractHeadings() {
const headingSelectors = [];
for (let i = 1; i <= this.config.maxHeadingLevel; i++) {
headingSelectors.push(`h${i}`);
}
const headingElements = this.container.querySelectorAll(headingSelectors.join(', '));
this.headings = Array.from(headingElements).map((heading, index) => {
// Ensure heading has an ID
if (!heading.id) {
heading.id = `heading-${index + 1}`;
}
return {
element: heading,
id: heading.id,
text: heading.textContent.trim(),
level: parseInt(heading.tagName.substring(1)),
offset: heading.offsetTop
};
});
return this.headings;
}
buildNavigationTree() {
this.navigationTree = [];
const stack = [];
this.headings.forEach(heading => {
const item = {
...heading,
children: []
};
// Find correct parent based on heading level
while (stack.length > 0 && stack[stack.length - 1].level >= heading.level) {
stack.pop();
}
if (stack.length === 0) {
// Top level item
this.navigationTree.push(item);
} else {
// Child item
stack[stack.length - 1].children.push(item);
}
stack.push(item);
});
return this.navigationTree;
}
async toggle(options = {}) {
return this.isCollapsed ? this.expand(options) : this.collapse(options);
}
async expand(options = {}) {
if (!this.isCollapsed) {
return this;
}
this.isCollapsed = false;
if (this.toggleButton) {
this.toggleButton.setAttribute('aria-expanded', 'true');
this.toggleButton.setAttribute('aria-label', 'Collapse navigation');
this.toggleButton.innerHTML = this.getToggleIcon();
}
if (this.navigationList) {
if (this.enableAnimations && !options.immediate) {
await this.animateExpand();
} else {
this.navigationList.style.display = '';
this.element.style.width = this.config.width;
}
}
this.emit('toggle', { expanded: true });
return this;
}
async collapse(options = {}) {
if (this.isCollapsed) {
return this;
}
this.isCollapsed = true;
if (this.toggleButton) {
this.toggleButton.setAttribute('aria-expanded', 'false');
this.toggleButton.setAttribute('aria-label', 'Expand navigation');
this.toggleButton.innerHTML = this.getToggleIcon();
}
if (this.navigationList) {
if (this.enableAnimations && !options.immediate) {
await this.animateCollapse();
} else {
this.navigationList.style.display = 'none';
this.element.style.width = this.config.collapsedWidth;
}
}
this.emit('toggle', { expanded: false });
return this;
}
async animateExpand() {
return new Promise(resolve => {
this.navigationList.style.opacity = '0';
this.navigationList.style.display = '';
// Animate width and opacity
this.element.style.transition = `width ${this.animationDuration}ms ease-in-out`;
this.navigationList.style.transition = `opacity ${this.animationDuration}ms ease-in-out`;
// Force reflow
this.element.offsetWidth;
this.element.style.width = this.config.width;
this.navigationList.style.opacity = '1';
setTimeout(() => {
this.element.style.transition = '';
this.navigationList.style.transition = '';
resolve();
}, this.animationDuration);
});
}
async animateCollapse() {
return new Promise(resolve => {
this.element.style.transition = `width ${this.animationDuration}ms ease-in-out`;
this.navigationList.style.transition = `opacity ${this.animationDuration}ms ease-in-out`;
this.navigationList.style.opacity = '0';
this.element.style.width = this.config.collapsedWidth;
setTimeout(() => {
this.navigationList.style.display = 'none';
this.element.style.transition = '';
this.navigationList.style.transition = '';
resolve();
}, this.animationDuration);
});
}
navigateToHeading(headingId) {
const targetElement = document.getElementById(headingId);
if (!targetElement) {
console.warn(`Heading with ID '${headingId}' not found`);
return;
}
// Update active navigation item
this.setActiveItem(headingId);
// Scroll to target
if (this.config.smoothScroll) {
targetElement.scrollIntoView({
behavior: 'smooth',
block: 'start',
inline: 'nearest'
});
} else {
targetElement.scrollIntoView();
}
// Emit navigation event
this.emit('navigate', { target: headingId, element: targetElement });
// Optionally collapse after navigation on mobile
if (this.mediaQuery.matches && this.config.autoHide) {
setTimeout(() => this.collapse(), 500);
}
}
setActiveItem(headingId) {
// Remove previous active state
const previousActive = this.findElement('.navigator-link.active');
if (previousActive) {
previousActive.classList.remove('active');
previousActive.style.backgroundColor = '';
}
// Set new active state
const newActive = this.findElement(`[data-target="${headingId}"]`);
if (newActive) {
newActive.classList.add('active');
newActive.style.backgroundColor = '#e3f2fd';
newActive.style.color = '#1976d2';
}
this.currentSection = headingId;
}
handleScroll() {
if (!this.scrollSpyEnabled || !this.isRendered) {
return;
}
// Throttle scroll events
if (this.scrollThrottle) {
return;
}
this.scrollThrottle = setTimeout(() => {
this.updateCurrentSection();
this.scrollThrottle = null;
}, 100);
}
updateCurrentSection() {
const scrollPosition = window.pageYOffset + 100; // Offset for header
let currentHeading = null;
// Find the current heading based on scroll position
for (let i = this.headings.length - 1; i >= 0; i--) {
const heading = this.headings[i];
if (heading.element.offsetTop <= scrollPosition) {
currentHeading = heading;
break;
}
}
if (currentHeading && currentHeading.id !== this.currentSection) {
this.setActiveItem(currentHeading.id);
}
}
getCurrentSection() {
return this.currentSection;
}
handleResize() {
if (!this.config.autoHide) {
return;
}
if (this.mediaQuery.matches) {
// Mobile: hide navigator
if (this.element) {
this.element.style.display = 'none';
}
} else {
// Desktop: show navigator
if (this.element) {
this.element.style.display = '';
}
}
}
handleKeyboard(event) {
switch (event.key) {
case 'Enter':
case ' ':
event.preventDefault();
this.toggle();
break;
case 'Escape':
event.preventDefault();
this.collapse();
break;
}
}
getNavigatorStyle() {
const baseStyle = {
position: 'fixed',
top: this.config.offset.top,
zIndex: '1000',
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: '1px solid #e1e5e9',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
backdropFilter: 'blur(8px)',
width: this.isCollapsed ? this.config.collapsedWidth : this.config.width,
maxHeight: '80vh',
overflow: 'hidden',
transition: 'width 0.3s ease-in-out'
};
// Position-specific styling
if (this.config.position === 'left') {
baseStyle.left = this.config.offset.side;
} else {
baseStyle.right = this.config.offset.side;
}
return baseStyle;
}
getToggleStyle() {
return {
width: '100%',
height: this.config.collapsedWidth,
border: 'none',
backgroundColor: 'transparent',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '16px',
color: '#666',
transition: 'color 0.2s ease'
};
}
getListStyle() {
return {
display: this.isCollapsed ? 'none' : '',
opacity: this.isCollapsed ? '0' : '1'
};
}
getToggleIcon() {
if (this.isCollapsed) {
return this.config.position === 'left' ? '☰' : '☰';
} else {
return '✕';
}
}
async destroy() {
// Remove event listeners
window.removeEventListener('scroll', this.boundScrollHandler);
window.removeEventListener('resize', this.boundResizeHandler);
// Clear throttle
if (this.scrollThrottle) {
clearTimeout(this.scrollThrottle);
}
await super.destroy();
}
}