fix: improve control positioning and drag behavior
- Update compass positioning to be top-aligned instead of center-aligned - Fix drag offset calculation to maintain cursor position at icon - Ensure expanded controls appear top-aligned with anchor position - Apply fixes to both viewing and edit mode Control implementations - Improve user experience with more intuitive positioning and dragging 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1177,50 +1177,133 @@ class CleanDocumentManager:
|
||||
console.error("Scroll indicators failed to initialize:", error);
|
||||
}}
|
||||
|
||||
// Step 4: Initialize DocumentNavigator (lazy loading for all modes)
|
||||
try {{
|
||||
const documentNavigator = {{
|
||||
navElement: null,
|
||||
// Step 4: Define abstract Control class for UI controls
|
||||
const Control = {{
|
||||
// Abstract control properties
|
||||
element: null,
|
||||
isExpanded: false,
|
||||
isDragging: false,
|
||||
dragOffset: {{ x: 0, y: 0 }},
|
||||
originalPosition: {{ top: '80px', left: '20px' }},
|
||||
|
||||
createControl: function() {{
|
||||
console.log("📋 Creating DocumentNavigator control for view mode...");
|
||||
// Configuration properties (to be overridden by subclasses)
|
||||
config: {{
|
||||
icon: '?',
|
||||
title: 'Control',
|
||||
className: 'control',
|
||||
defaultContent: 'Template only',
|
||||
ariaLabel: 'Control',
|
||||
position: 'w' // Default compass position: west (middle-left)
|
||||
}},
|
||||
|
||||
this.navElement = document.createElement('nav');
|
||||
this.navElement.className = 'document-navigator';
|
||||
this.navElement.innerHTML = `
|
||||
<button class="navigator-toggle" aria-label="Document Navigation">☰</button>
|
||||
<div class="navigator-panel" style="display: none;">
|
||||
<div class="navigator-header">
|
||||
<span class="navigator-icon">☰</span>
|
||||
<h3>Contents</h3>
|
||||
<button class="navigator-close">✕</button>
|
||||
// Compass positioning system (top-aligned for proper expansion)
|
||||
compassPositions: {{
|
||||
// North positions (top)
|
||||
'n': {{ top: '20px', left: '50%', transform: 'translateX(-50%)' }},
|
||||
'nne': {{ top: '20px', left: '65%', transform: 'translateX(-50%)' }},
|
||||
'ne': {{ top: '20px', right: '20px' }},
|
||||
'ene': {{ top: '80px', right: '20px' }}, // Top-aligned instead of center
|
||||
|
||||
// East positions (right)
|
||||
'e': {{ top: '50vh', right: '20px', transform: 'translateY(-20px)' }}, // Anchor at icon level
|
||||
'ese': {{ top: 'calc(65vh - 20px)', right: '20px' }}, // Top-aligned
|
||||
'se': {{ bottom: '20px', right: '20px' }},
|
||||
'sse': {{ bottom: '20px', right: '35%', transform: 'translateX(50%)' }},
|
||||
|
||||
// South positions (bottom)
|
||||
's': {{ bottom: '20px', left: '50%', transform: 'translateX(-50%)' }},
|
||||
'ssw': {{ bottom: '20px', left: '35%', transform: 'translateX(-50%)' }},
|
||||
'sw': {{ bottom: '20px', left: '20px' }},
|
||||
'wsw': {{ bottom: '80px', left: '20px' }}, // Top-aligned instead of center
|
||||
|
||||
// West positions (left) - top-aligned for proper expansion
|
||||
'w': {{ top: '50vh', left: '20px', transform: 'translateY(-20px)' }}, // Anchor at icon level
|
||||
'wnw': {{ top: '80px', left: '20px' }}, // Top-aligned instead of center
|
||||
'nw': {{ top: '20px', left: '20px' }},
|
||||
'nnw': {{ top: '20px', left: '35%', transform: 'translateX(-50%)' }}
|
||||
}},
|
||||
|
||||
// Get expansion direction based on compass position
|
||||
getExpansionDirection: function() {{
|
||||
const pos = this.config.position;
|
||||
const rightBorderPositions = ['ne', 'ene', 'e', 'ese', 'se'];
|
||||
const bottomBorderPositions = ['sw', 'ssw', 's', 'sse', 'se'];
|
||||
|
||||
return {{
|
||||
header: rightBorderPositions.includes(pos) ? 'left' : 'right',
|
||||
body: bottomBorderPositions.includes(pos) ? 'up' : 'down'
|
||||
}};
|
||||
}},
|
||||
|
||||
// Calculate position styles based on compass direction
|
||||
getPositionStyles: function() {{
|
||||
const compassPos = this.compassPositions[this.config.position] || this.compassPositions['w'];
|
||||
return {{
|
||||
position: 'fixed',
|
||||
top: compassPos.top || 'auto',
|
||||
right: compassPos.right || 'auto',
|
||||
bottom: compassPos.bottom || 'auto',
|
||||
left: compassPos.left || 'auto',
|
||||
transform: compassPos.transform || 'none',
|
||||
zIndex: 1000
|
||||
}};
|
||||
}},
|
||||
|
||||
// Abstract methods (to be implemented by subclasses)
|
||||
buildContent: function() {{
|
||||
const content = this.element.querySelector('.control-content');
|
||||
content.innerHTML = `<p style="padding: 1rem; color: #666;">${{this.config.defaultContent}}</p>`;
|
||||
}},
|
||||
|
||||
// Concrete methods (shared by all controls)
|
||||
createControl: function() {{
|
||||
console.log(`🎛️ Creating ${{this.config.title}} control...`);
|
||||
|
||||
this.element = document.createElement('div');
|
||||
this.element.className = this.config.className;
|
||||
this.element.innerHTML = `
|
||||
<button class="control-toggle" aria-label="${{this.config.ariaLabel}}">${{this.config.icon}}</button>
|
||||
<div class="control-panel" style="display: none;">
|
||||
<div class="control-header">
|
||||
<span class="control-icon">${{this.config.icon}}</span>
|
||||
<h3>${{this.config.title}}</h3>
|
||||
<button class="control-close">✕</button>
|
||||
</div>
|
||||
<div class="navigator-content">Loading...</div>
|
||||
<div class="control-content">Loading...</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Position on left side following UI convention
|
||||
this.navElement.style.cssText = `
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
left: 20px;
|
||||
z-index: 1000;
|
||||
// Position using compass direction
|
||||
const positionStyles = this.getPositionStyles();
|
||||
this.element.style.cssText = `
|
||||
position: ${{positionStyles.position}};
|
||||
top: ${{positionStyles.top}};
|
||||
right: ${{positionStyles.right}};
|
||||
bottom: ${{positionStyles.bottom}};
|
||||
left: ${{positionStyles.left}};
|
||||
transform: ${{positionStyles.transform}};
|
||||
z-index: ${{positionStyles.zIndex}};
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid #e1e5e9;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
backdrop-filter: blur(8px);
|
||||
width: 40px;
|
||||
transition: width 0.3s ease;
|
||||
transition: all 0.3s ease;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
`;
|
||||
|
||||
// Store original position for reset
|
||||
this.originalPosition = {{
|
||||
top: positionStyles.top,
|
||||
right: positionStyles.right,
|
||||
bottom: positionStyles.bottom,
|
||||
left: positionStyles.left,
|
||||
transform: positionStyles.transform
|
||||
}};
|
||||
|
||||
// Style toggle button
|
||||
const toggleBtn = this.navElement.querySelector('.navigator-toggle');
|
||||
const toggleBtn = this.element.querySelector('.control-toggle');
|
||||
toggleBtn.style.cssText = `
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
@@ -1232,18 +1315,18 @@ class CleanDocumentManager:
|
||||
transition: color 0.2s ease;
|
||||
`;
|
||||
|
||||
// Handle click to build navigation on-demand
|
||||
// Handle click to build content on-demand
|
||||
toggleBtn.addEventListener('click', () => {{
|
||||
if (this.isExpanded) {{
|
||||
this.collapse();
|
||||
}} else {{
|
||||
console.log("📋 Navigator toggle clicked - building navigation...");
|
||||
this.buildNavigation();
|
||||
console.log(`🎛️ ${{this.config.title}} toggle clicked - building content...`);
|
||||
this.buildContent();
|
||||
}}
|
||||
}});
|
||||
|
||||
// Close button handler
|
||||
const closeBtn = this.navElement.querySelector('.navigator-close');
|
||||
const closeBtn = this.element.querySelector('.control-close');
|
||||
closeBtn.addEventListener('click', () => {{
|
||||
this.collapse();
|
||||
}});
|
||||
@@ -1251,26 +1334,24 @@ class CleanDocumentManager:
|
||||
// Responsive behavior
|
||||
window.addEventListener('resize', () => {{
|
||||
if (window.innerWidth <= 768) {{
|
||||
this.navElement.style.display = 'none';
|
||||
this.element.style.display = 'none';
|
||||
}} else {{
|
||||
this.navElement.style.display = '';
|
||||
this.element.style.display = '';
|
||||
}}
|
||||
}});
|
||||
|
||||
document.body.appendChild(this.navElement);
|
||||
document.body.appendChild(this.element);
|
||||
|
||||
// Hide on mobile
|
||||
if (window.innerWidth <= 768) {{
|
||||
this.navElement.style.display = 'none';
|
||||
this.element.style.display = 'none';
|
||||
}}
|
||||
|
||||
console.log("📋 DocumentNavigator control created");
|
||||
console.log(`🎛️ ${{this.config.title}} control created`);
|
||||
}},
|
||||
|
||||
buildNavigation: function() {{
|
||||
const panel = this.navElement.querySelector('.navigator-panel');
|
||||
const content = this.navElement.querySelector('.navigator-content');
|
||||
const header = this.navElement.querySelector('.navigator-header');
|
||||
styleHeader: function() {{
|
||||
const header = this.element.querySelector('.control-header');
|
||||
|
||||
// Style the header to show icon, title, and close button in one line
|
||||
// Match the height of the collapsed icon state (40px)
|
||||
@@ -1284,7 +1365,7 @@ class CleanDocumentManager:
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
const icon = header.querySelector('.navigator-icon');
|
||||
const icon = header.querySelector('.control-icon');
|
||||
if (icon) {{
|
||||
icon.style.cssText = `
|
||||
font-size: 16px;
|
||||
@@ -1309,7 +1390,7 @@ class CleanDocumentManager:
|
||||
`;
|
||||
}}
|
||||
|
||||
const closeBtn = header.querySelector('.navigator-close');
|
||||
const closeBtn = header.querySelector('.control-close');
|
||||
if (closeBtn) {{
|
||||
closeBtn.style.cssText = `
|
||||
background: none;
|
||||
@@ -1325,6 +1406,135 @@ class CleanDocumentManager:
|
||||
justify-content: center;
|
||||
`;
|
||||
}}
|
||||
}},
|
||||
|
||||
styleContent: function() {{
|
||||
const content = this.element.querySelector('.control-content');
|
||||
const expansion = this.getExpansionDirection();
|
||||
|
||||
// Style the content area based on expansion direction
|
||||
let contentStyles = `
|
||||
padding: 0.5rem;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
if (expansion.body === 'up') {{
|
||||
// Body expands upward (for bottom border positions)
|
||||
contentStyles += `
|
||||
max-height: calc(80vh - 40px);
|
||||
`;
|
||||
content.parentElement.style.flexDirection = 'column-reverse';
|
||||
}} else {{
|
||||
// Body expands downward (default)
|
||||
contentStyles += `
|
||||
max-height: calc(80vh - 40px);
|
||||
`;
|
||||
content.parentElement.style.flexDirection = 'column';
|
||||
}}
|
||||
|
||||
content.style.cssText = contentStyles;
|
||||
}},
|
||||
|
||||
expand: function() {{
|
||||
this.isExpanded = true;
|
||||
const panel = this.element.querySelector('.control-panel');
|
||||
const toggleBtn = this.element.querySelector('.control-toggle');
|
||||
|
||||
// Get expansion direction based on compass position
|
||||
const expansion = this.getExpansionDirection();
|
||||
|
||||
// Apply expansion styling based on direction
|
||||
if (expansion.header === 'left') {{
|
||||
// Header expands to the left (for right border positions)
|
||||
this.element.style.width = '280px';
|
||||
this.element.style.transformOrigin = 'top right';
|
||||
}} else {{
|
||||
// Header expands to the right (default)
|
||||
this.element.style.width = '280px';
|
||||
this.element.style.transformOrigin = 'top left';
|
||||
}}
|
||||
|
||||
panel.style.display = 'block';
|
||||
toggleBtn.style.display = 'none';
|
||||
|
||||
this.styleHeader();
|
||||
this.styleContent();
|
||||
}},
|
||||
|
||||
collapse: function() {{
|
||||
this.isExpanded = false;
|
||||
const panel = this.element.querySelector('.control-panel');
|
||||
const toggleBtn = this.element.querySelector('.control-toggle');
|
||||
panel.style.display = 'none';
|
||||
this.element.style.width = '40px';
|
||||
toggleBtn.style.display = 'block';
|
||||
|
||||
// Reset position to original compass location
|
||||
this.element.style.top = this.originalPosition.top;
|
||||
this.element.style.right = this.originalPosition.right;
|
||||
this.element.style.bottom = this.originalPosition.bottom;
|
||||
this.element.style.left = this.originalPosition.left;
|
||||
this.element.style.transform = this.originalPosition.transform;
|
||||
}},
|
||||
|
||||
setupDragHandlers: function(dragElement) {{
|
||||
dragElement.addEventListener('mousedown', (e) => {{
|
||||
this.isDragging = true;
|
||||
const rect = this.element.getBoundingClientRect();
|
||||
const iconRect = dragElement.getBoundingClientRect();
|
||||
|
||||
// Calculate offset relative to the icon position, not the element
|
||||
this.dragOffset.x = e.clientX - rect.left;
|
||||
this.dragOffset.y = iconRect.top - rect.top + (iconRect.height / 2); // Keep mouse at icon center
|
||||
|
||||
dragElement.style.cursor = 'grabbing';
|
||||
|
||||
e.preventDefault();
|
||||
}});
|
||||
|
||||
document.addEventListener('mousemove', (e) => {{
|
||||
if (!this.isDragging || !this.isExpanded) return;
|
||||
|
||||
const newX = e.clientX - this.dragOffset.x;
|
||||
const newY = e.clientY - this.dragOffset.y;
|
||||
|
||||
// Keep within viewport bounds
|
||||
const maxX = window.innerWidth - this.element.offsetWidth;
|
||||
const maxY = window.innerHeight - this.element.offsetHeight;
|
||||
|
||||
const boundedX = Math.max(0, Math.min(newX, maxX));
|
||||
const boundedY = Math.max(0, Math.min(newY, maxY));
|
||||
|
||||
this.element.style.left = boundedX + 'px';
|
||||
this.element.style.top = boundedY + 'px';
|
||||
}});
|
||||
|
||||
document.addEventListener('mouseup', () => {{
|
||||
if (this.isDragging) {{
|
||||
this.isDragging = false;
|
||||
dragElement.style.cursor = 'grab';
|
||||
}}
|
||||
}});
|
||||
}}
|
||||
}};
|
||||
|
||||
// Step 5: Initialize ContentsControl (new implementation based on Control class)
|
||||
try {{
|
||||
const contentsControl = Object.create(Control);
|
||||
|
||||
// Configure for contents navigation
|
||||
contentsControl.config = {{
|
||||
icon: '☰',
|
||||
title: 'Contents',
|
||||
className: 'contents-control',
|
||||
defaultContent: 'No headings found',
|
||||
ariaLabel: 'Document Navigation',
|
||||
position: 'wnw' // West-north-west positioning
|
||||
}};
|
||||
|
||||
// Override buildContent method for navigation functionality
|
||||
contentsControl.buildContent = function() {{
|
||||
const content = this.element.querySelector('.control-content');
|
||||
|
||||
// Build navigation content from current DOM
|
||||
const allHeadings = document.querySelectorAll('h1, h2, h3');
|
||||
@@ -1352,7 +1562,7 @@ class CleanDocumentManager:
|
||||
border-radius: 4px; cursor: pointer;"
|
||||
onmouseover="this.style.backgroundColor='#f5f5f5'"
|
||||
onmouseout="this.style.backgroundColor=''"
|
||||
onclick="event.preventDefault(); document.getElementById('${{heading.id}}').scrollIntoView({{behavior: 'smooth'}}); if (window.innerWidth <= 768) setTimeout(() => documentNavigator.collapse(), 500);">
|
||||
onclick="event.preventDefault(); document.getElementById('${{heading.id}}').scrollIntoView({{behavior: 'smooth'}}); if (window.innerWidth <= 768) setTimeout(() => contentsControl.collapse(), 500);">
|
||||
${{heading.textContent.trim()}}
|
||||
</a>
|
||||
`;
|
||||
@@ -1360,85 +1570,19 @@ class CleanDocumentManager:
|
||||
content.innerHTML = navHtml;
|
||||
}}
|
||||
|
||||
// Style the content area to work with compact header
|
||||
content.style.cssText = `
|
||||
padding: 0.5rem;
|
||||
max-height: calc(80vh - 40px);
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
// Show panel
|
||||
this.expand();
|
||||
}},
|
||||
|
||||
expand: function() {{
|
||||
this.isExpanded = true;
|
||||
const panel = this.navElement.querySelector('.navigator-panel');
|
||||
const toggleBtn = this.navElement.querySelector('.navigator-toggle');
|
||||
this.navElement.style.width = '280px';
|
||||
panel.style.display = 'block';
|
||||
toggleBtn.style.display = 'none';
|
||||
}},
|
||||
|
||||
collapse: function() {{
|
||||
this.isExpanded = false;
|
||||
const panel = this.navElement.querySelector('.navigator-panel');
|
||||
const toggleBtn = this.navElement.querySelector('.navigator-toggle');
|
||||
panel.style.display = 'none';
|
||||
this.navElement.style.width = '40px';
|
||||
toggleBtn.style.display = 'block';
|
||||
|
||||
// Reset position to original location
|
||||
this.navElement.style.top = this.originalPosition.top;
|
||||
this.navElement.style.left = this.originalPosition.left;
|
||||
}},
|
||||
|
||||
setupDragHandlers: function(dragElement) {{
|
||||
dragElement.addEventListener('mousedown', (e) => {{
|
||||
this.isDragging = true;
|
||||
const rect = this.navElement.getBoundingClientRect();
|
||||
this.dragOffset.x = e.clientX - rect.left;
|
||||
this.dragOffset.y = e.clientY - rect.top;
|
||||
|
||||
dragElement.style.cursor = 'grabbing';
|
||||
|
||||
e.preventDefault();
|
||||
}});
|
||||
|
||||
document.addEventListener('mousemove', (e) => {{
|
||||
if (!this.isDragging || !this.isExpanded) return;
|
||||
|
||||
const newX = e.clientX - this.dragOffset.x;
|
||||
const newY = e.clientY - this.dragOffset.y;
|
||||
|
||||
// Keep within viewport bounds
|
||||
const maxX = window.innerWidth - this.navElement.offsetWidth;
|
||||
const maxY = window.innerHeight - this.navElement.offsetHeight;
|
||||
|
||||
const boundedX = Math.max(0, Math.min(newX, maxX));
|
||||
const boundedY = Math.max(0, Math.min(newY, maxY));
|
||||
|
||||
this.navElement.style.left = boundedX + 'px';
|
||||
this.navElement.style.top = boundedY + 'px';
|
||||
}});
|
||||
|
||||
document.addEventListener('mouseup', () => {{
|
||||
if (this.isDragging) {{
|
||||
this.isDragging = false;
|
||||
dragElement.style.cursor = 'grab';
|
||||
}}
|
||||
}});
|
||||
}}
|
||||
}};
|
||||
|
||||
// Initialize the DocumentNavigator control
|
||||
documentNavigator.createControl();
|
||||
// Initialize the ContentsControl
|
||||
contentsControl.createControl();
|
||||
|
||||
// Make globally available for mobile collapse
|
||||
window.documentNavigator = documentNavigator;
|
||||
window.contentsControl = contentsControl;
|
||||
}} catch (error) {{
|
||||
console.error("DocumentNavigator failed to initialize:", error);
|
||||
console.error("ContentsControl failed to initialize:", error);
|
||||
}}
|
||||
|
||||
}});
|
||||
|
||||
// Handle CDN loading errors
|
||||
@@ -1500,49 +1644,133 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Create document controls
|
||||
documentControls.create();
|
||||
|
||||
// Create DocumentNavigator for edit mode (lazy loading)
|
||||
const documentNavigator = {
|
||||
navElement: null,
|
||||
// Define abstract Control class for UI controls (same as viewing mode)
|
||||
const Control = {
|
||||
// Abstract control properties
|
||||
element: null,
|
||||
isExpanded: false,
|
||||
isDragging: false,
|
||||
dragOffset: { x: 0, y: 0 },
|
||||
originalPosition: { top: '80px', left: '20px' },
|
||||
|
||||
createControl: function() {
|
||||
console.log("📋 Creating DocumentNavigator control for edit mode...");
|
||||
// Configuration properties (to be overridden by subclasses)
|
||||
config: {
|
||||
icon: '?',
|
||||
title: 'Control',
|
||||
className: 'control',
|
||||
defaultContent: 'Template only',
|
||||
ariaLabel: 'Control',
|
||||
position: 'w' // Default compass position: west (middle-left)
|
||||
},
|
||||
|
||||
this.navElement = document.createElement('nav');
|
||||
this.navElement.className = 'document-navigator edit-mode';
|
||||
this.navElement.innerHTML = `
|
||||
<button class="navigator-toggle" aria-label="Document Navigation">☰</button>
|
||||
<div class="navigator-panel" style="display: none;">
|
||||
<div class="navigator-header">
|
||||
<span class="navigator-icon">☰</span>
|
||||
<h3>Contents</h3>
|
||||
<button class="navigator-close">✕</button>
|
||||
// Compass positioning system (top-aligned for proper expansion)
|
||||
compassPositions: {
|
||||
// North positions (top)
|
||||
'n': { top: '20px', left: '50%', transform: 'translateX(-50%)' },
|
||||
'nne': { top: '20px', left: '65%', transform: 'translateX(-50%)' },
|
||||
'ne': { top: '20px', right: '20px' },
|
||||
'ene': { top: '80px', right: '20px' }, // Top-aligned instead of center
|
||||
|
||||
// East positions (right)
|
||||
'e': { top: '50vh', right: '20px', transform: 'translateY(-20px)' }, // Anchor at icon level
|
||||
'ese': { top: 'calc(65vh - 20px)', right: '20px' }, // Top-aligned
|
||||
'se': { bottom: '20px', right: '20px' },
|
||||
'sse': { bottom: '20px', right: '35%', transform: 'translateX(50%)' },
|
||||
|
||||
// South positions (bottom)
|
||||
's': { bottom: '20px', left: '50%', transform: 'translateX(-50%)' },
|
||||
'ssw': { bottom: '20px', left: '35%', transform: 'translateX(-50%)' },
|
||||
'sw': { bottom: '20px', left: '20px' },
|
||||
'wsw': { bottom: '80px', left: '20px' }, // Top-aligned instead of center
|
||||
|
||||
// West positions (left) - top-aligned for proper expansion
|
||||
'w': { top: '50vh', left: '20px', transform: 'translateY(-20px)' }, // Anchor at icon level
|
||||
'wnw': { top: '80px', left: '20px' }, // Top-aligned instead of center
|
||||
'nw': { top: '20px', left: '20px' },
|
||||
'nnw': { top: '20px', left: '35%', transform: 'translateX(-50%)' }
|
||||
},
|
||||
|
||||
// Get expansion direction based on compass position
|
||||
getExpansionDirection: function() {
|
||||
const pos = this.config.position;
|
||||
const rightBorderPositions = ['ne', 'ene', 'e', 'ese', 'se'];
|
||||
const bottomBorderPositions = ['sw', 'ssw', 's', 'sse', 'se'];
|
||||
|
||||
return {
|
||||
header: rightBorderPositions.includes(pos) ? 'left' : 'right',
|
||||
body: bottomBorderPositions.includes(pos) ? 'up' : 'down'
|
||||
};
|
||||
},
|
||||
|
||||
// Calculate position styles based on compass direction
|
||||
getPositionStyles: function() {
|
||||
const compassPos = this.compassPositions[this.config.position] || this.compassPositions['w'];
|
||||
return {
|
||||
position: 'fixed',
|
||||
top: compassPos.top || 'auto',
|
||||
right: compassPos.right || 'auto',
|
||||
bottom: compassPos.bottom || 'auto',
|
||||
left: compassPos.left || 'auto',
|
||||
transform: compassPos.transform || 'none',
|
||||
zIndex: 1001
|
||||
};
|
||||
},
|
||||
|
||||
// Abstract methods (to be implemented by subclasses)
|
||||
buildContent: function() {
|
||||
const content = this.element.querySelector('.control-content');
|
||||
content.innerHTML = `<p style="padding: 1rem; color: #666;">${this.config.defaultContent}</p>`;
|
||||
},
|
||||
|
||||
// Concrete methods (shared by all controls)
|
||||
createControl: function() {
|
||||
console.log(`🎛️ Creating ${this.config.title} control...`);
|
||||
|
||||
this.element = document.createElement('div');
|
||||
this.element.className = this.config.className;
|
||||
this.element.innerHTML = `
|
||||
<button class="control-toggle" aria-label="${this.config.ariaLabel}">${this.config.icon}</button>
|
||||
<div class="control-panel" style="display: none;">
|
||||
<div class="control-header">
|
||||
<span class="control-icon">${this.config.icon}</span>
|
||||
<h3>${this.config.title}</h3>
|
||||
<button class="control-close">✕</button>
|
||||
</div>
|
||||
<div class="navigator-content">Loading...</div>
|
||||
<div class="control-content">Loading...</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Position on left side following UI convention
|
||||
this.navElement.style.cssText = `
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
left: 20px;
|
||||
z-index: 1001;
|
||||
// Position using compass direction
|
||||
const positionStyles = this.getPositionStyles();
|
||||
this.element.style.cssText = `
|
||||
position: ${positionStyles.position};
|
||||
top: ${positionStyles.top};
|
||||
right: ${positionStyles.right};
|
||||
bottom: ${positionStyles.bottom};
|
||||
left: ${positionStyles.left};
|
||||
transform: ${positionStyles.transform};
|
||||
z-index: ${positionStyles.zIndex};
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid #e1e5e9;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
backdrop-filter: blur(8px);
|
||||
width: 40px;
|
||||
transition: width 0.3s ease;
|
||||
transition: all 0.3s ease;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
`;
|
||||
|
||||
// Store original position for reset
|
||||
this.originalPosition = {
|
||||
top: positionStyles.top,
|
||||
right: positionStyles.right,
|
||||
bottom: positionStyles.bottom,
|
||||
left: positionStyles.left,
|
||||
transform: positionStyles.transform
|
||||
};
|
||||
|
||||
// Style toggle button
|
||||
const toggleBtn = this.navElement.querySelector('.navigator-toggle');
|
||||
const toggleBtn = this.element.querySelector('.control-toggle');
|
||||
toggleBtn.style.cssText = `
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
@@ -1554,30 +1782,28 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
transition: color 0.2s ease;
|
||||
`;
|
||||
|
||||
// Handle click to build navigation on-demand
|
||||
// Handle click to build content on-demand
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
if (this.isExpanded) {
|
||||
this.collapse();
|
||||
} else {
|
||||
console.log("📋 Navigator toggle clicked - building navigation...");
|
||||
this.buildNavigation();
|
||||
console.log(`🎛️ ${this.config.title} toggle clicked - building content...`);
|
||||
this.buildContent();
|
||||
}
|
||||
});
|
||||
|
||||
// Close button handler
|
||||
const closeBtn = this.navElement.querySelector('.navigator-close');
|
||||
const closeBtn = this.element.querySelector('.control-close');
|
||||
closeBtn.addEventListener('click', () => {
|
||||
this.collapse();
|
||||
});
|
||||
|
||||
document.body.appendChild(this.navElement);
|
||||
console.log("📋 DocumentNavigator control created");
|
||||
document.body.appendChild(this.element);
|
||||
console.log(`🎛️ ${this.config.title} control created`);
|
||||
},
|
||||
|
||||
buildNavigation: function() {
|
||||
const panel = this.navElement.querySelector('.navigator-panel');
|
||||
const content = this.navElement.querySelector('.navigator-content');
|
||||
const header = this.navElement.querySelector('.navigator-header');
|
||||
styleHeader: function() {
|
||||
const header = this.element.querySelector('.control-header');
|
||||
|
||||
// Style the header to show icon, title, and close button in one line
|
||||
// Match the height of the collapsed icon state (40px)
|
||||
@@ -1591,7 +1817,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
margin-bottom: 0;
|
||||
`;
|
||||
|
||||
const icon = header.querySelector('.navigator-icon');
|
||||
const icon = header.querySelector('.control-icon');
|
||||
if (icon) {
|
||||
icon.style.cssText = `
|
||||
font-size: 16px;
|
||||
@@ -1616,7 +1842,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
`;
|
||||
}
|
||||
|
||||
const closeBtn = header.querySelector('.navigator-close');
|
||||
const closeBtn = header.querySelector('.control-close');
|
||||
if (closeBtn) {
|
||||
closeBtn.style.cssText = `
|
||||
background: none;
|
||||
@@ -1632,6 +1858,135 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
justify-content: center;
|
||||
`;
|
||||
}
|
||||
},
|
||||
|
||||
styleContent: function() {
|
||||
const content = this.element.querySelector('.control-content');
|
||||
const expansion = this.getExpansionDirection();
|
||||
|
||||
// Style the content area based on expansion direction
|
||||
let contentStyles = `
|
||||
padding: 0.5rem;
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
if (expansion.body === 'up') {
|
||||
// Body expands upward (for bottom border positions)
|
||||
contentStyles += `
|
||||
max-height: calc(80vh - 40px);
|
||||
`;
|
||||
content.parentElement.style.flexDirection = 'column-reverse';
|
||||
} else {
|
||||
// Body expands downward (default)
|
||||
contentStyles += `
|
||||
max-height: calc(80vh - 40px);
|
||||
`;
|
||||
content.parentElement.style.flexDirection = 'column';
|
||||
}
|
||||
|
||||
content.style.cssText = contentStyles;
|
||||
},
|
||||
|
||||
expand: function() {
|
||||
this.isExpanded = true;
|
||||
const panel = this.element.querySelector('.control-panel');
|
||||
const toggleBtn = this.element.querySelector('.control-toggle');
|
||||
|
||||
// Get expansion direction based on compass position
|
||||
const expansion = this.getExpansionDirection();
|
||||
|
||||
// Apply expansion styling based on direction
|
||||
if (expansion.header === 'left') {
|
||||
// Header expands to the left (for right border positions)
|
||||
this.element.style.width = '300px';
|
||||
this.element.style.transformOrigin = 'top right';
|
||||
} else {
|
||||
// Header expands to the right (default)
|
||||
this.element.style.width = '300px';
|
||||
this.element.style.transformOrigin = 'top left';
|
||||
}
|
||||
|
||||
panel.style.display = 'block';
|
||||
toggleBtn.style.display = 'none';
|
||||
|
||||
this.styleHeader();
|
||||
this.styleContent();
|
||||
},
|
||||
|
||||
collapse: function() {
|
||||
this.isExpanded = false;
|
||||
const panel = this.element.querySelector('.control-panel');
|
||||
const toggleBtn = this.element.querySelector('.control-toggle');
|
||||
panel.style.display = 'none';
|
||||
this.element.style.width = '40px';
|
||||
toggleBtn.style.display = 'block';
|
||||
|
||||
// Reset position to original compass location
|
||||
this.element.style.top = this.originalPosition.top;
|
||||
this.element.style.right = this.originalPosition.right;
|
||||
this.element.style.bottom = this.originalPosition.bottom;
|
||||
this.element.style.left = this.originalPosition.left;
|
||||
this.element.style.transform = this.originalPosition.transform;
|
||||
},
|
||||
|
||||
setupDragHandlers: function(dragElement) {
|
||||
dragElement.addEventListener('mousedown', (e) => {
|
||||
this.isDragging = true;
|
||||
const rect = this.element.getBoundingClientRect();
|
||||
const iconRect = dragElement.getBoundingClientRect();
|
||||
|
||||
// Calculate offset relative to the icon position, not the element
|
||||
this.dragOffset.x = e.clientX - rect.left;
|
||||
this.dragOffset.y = iconRect.top - rect.top + (iconRect.height / 2); // Keep mouse at icon center
|
||||
|
||||
dragElement.style.cursor = 'grabbing';
|
||||
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!this.isDragging || !this.isExpanded) return;
|
||||
|
||||
const newX = e.clientX - this.dragOffset.x;
|
||||
const newY = e.clientY - this.dragOffset.y;
|
||||
|
||||
// Keep within viewport bounds
|
||||
const maxX = window.innerWidth - this.element.offsetWidth;
|
||||
const maxY = window.innerHeight - this.element.offsetHeight;
|
||||
|
||||
const boundedX = Math.max(0, Math.min(newX, maxX));
|
||||
const boundedY = Math.max(0, Math.min(newY, maxY));
|
||||
|
||||
this.element.style.left = boundedX + 'px';
|
||||
this.element.style.top = boundedY + 'px';
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
if (this.isDragging) {
|
||||
this.isDragging = false;
|
||||
dragElement.style.cursor = 'grab';
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Create ContentsControl for edit mode (new implementation based on Control class)
|
||||
try {
|
||||
const contentsControl = Object.create(Control);
|
||||
|
||||
// Configure for contents navigation in edit mode
|
||||
contentsControl.config = {
|
||||
icon: '☰',
|
||||
title: 'Contents',
|
||||
className: 'contents-control edit-mode',
|
||||
defaultContent: 'No headings found',
|
||||
ariaLabel: 'Document Navigation',
|
||||
position: 'wnw' // West-north-west positioning
|
||||
};
|
||||
|
||||
// Override buildContent method for navigation functionality
|
||||
contentsControl.buildContent = function() {
|
||||
const content = this.element.querySelector('.control-content');
|
||||
|
||||
// Build navigation content from current DOM
|
||||
const allHeadings = document.querySelectorAll('h1, h2, h3');
|
||||
@@ -1659,7 +2014,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
border-radius: 4px; cursor: pointer;"
|
||||
onmouseover="this.style.backgroundColor='#f5f5f5'"
|
||||
onmouseout="this.style.backgroundColor=''"
|
||||
onclick="event.preventDefault(); document.getElementById('${heading.id}').scrollIntoView({behavior: 'smooth'});">
|
||||
onclick="event.preventDefault(); document.getElementById('${heading.id}').scrollIntoView({behavior: 'smooth'}); if (window.innerWidth <= 768) setTimeout(() => contentsControl.collapse(), 500);">
|
||||
${heading.textContent.trim()}
|
||||
</a>
|
||||
`;
|
||||
@@ -1667,79 +2022,19 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
content.innerHTML = navHtml;
|
||||
}
|
||||
|
||||
// Style the content area to work with compact header
|
||||
content.style.cssText = `
|
||||
padding: 0.5rem;
|
||||
max-height: calc(80vh - 40px);
|
||||
overflow-y: auto;
|
||||
`;
|
||||
|
||||
// Show panel
|
||||
this.expand();
|
||||
},
|
||||
|
||||
expand: function() {
|
||||
this.isExpanded = true;
|
||||
const panel = this.navElement.querySelector('.navigator-panel');
|
||||
const toggleBtn = this.navElement.querySelector('.navigator-toggle');
|
||||
this.navElement.style.width = '300px';
|
||||
panel.style.display = 'block';
|
||||
toggleBtn.style.display = 'none';
|
||||
},
|
||||
|
||||
collapse: function() {
|
||||
this.isExpanded = false;
|
||||
const panel = this.navElement.querySelector('.navigator-panel');
|
||||
const toggleBtn = this.navElement.querySelector('.navigator-toggle');
|
||||
panel.style.display = 'none';
|
||||
this.navElement.style.width = '40px';
|
||||
toggleBtn.style.display = 'block';
|
||||
|
||||
// Reset position to original location
|
||||
this.navElement.style.top = this.originalPosition.top;
|
||||
this.navElement.style.left = this.originalPosition.left;
|
||||
},
|
||||
|
||||
setupDragHandlers: function(dragElement) {
|
||||
dragElement.addEventListener('mousedown', (e) => {
|
||||
this.isDragging = true;
|
||||
const rect = this.navElement.getBoundingClientRect();
|
||||
this.dragOffset.x = e.clientX - rect.left;
|
||||
this.dragOffset.y = e.clientY - rect.top;
|
||||
|
||||
dragElement.style.cursor = 'grabbing';
|
||||
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!this.isDragging || !this.isExpanded) return;
|
||||
|
||||
const newX = e.clientX - this.dragOffset.x;
|
||||
const newY = e.clientY - this.dragOffset.y;
|
||||
|
||||
// Keep within viewport bounds
|
||||
const maxX = window.innerWidth - this.navElement.offsetWidth;
|
||||
const maxY = window.innerHeight - this.navElement.offsetHeight;
|
||||
|
||||
const boundedX = Math.max(0, Math.min(newX, maxX));
|
||||
const boundedY = Math.max(0, Math.min(newY, maxY));
|
||||
|
||||
this.navElement.style.left = boundedX + 'px';
|
||||
this.navElement.style.top = boundedY + 'px';
|
||||
});
|
||||
|
||||
document.addEventListener('mouseup', () => {
|
||||
if (this.isDragging) {
|
||||
this.isDragging = false;
|
||||
dragElement.style.cursor = 'grab';
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize the DocumentNavigator control
|
||||
documentNavigator.createControl();
|
||||
// Initialize the ContentsControl
|
||||
contentsControl.createControl();
|
||||
|
||||
// Make globally available for mobile collapse
|
||||
window.contentsControl = contentsControl;
|
||||
} catch (error) {
|
||||
console.error("ContentsControl failed to initialize:", error);
|
||||
}
|
||||
|
||||
|
||||
// Wire up event handlers
|
||||
documentControls.setEventHandlers({
|
||||
|
||||
Reference in New Issue
Block a user