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);
|
console.error("Scroll indicators failed to initialize:", error);
|
||||||
}}
|
}}
|
||||||
|
|
||||||
// Step 4: Initialize DocumentNavigator (lazy loading for all modes)
|
// Step 4: Define abstract Control class for UI controls
|
||||||
try {{
|
const Control = {{
|
||||||
const documentNavigator = {{
|
// Abstract control properties
|
||||||
navElement: null,
|
element: null,
|
||||||
isExpanded: false,
|
isExpanded: false,
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
dragOffset: {{ x: 0, y: 0 }},
|
dragOffset: {{ x: 0, y: 0 }},
|
||||||
originalPosition: {{ top: '80px', left: '20px' }},
|
originalPosition: {{ top: '80px', left: '20px' }},
|
||||||
|
|
||||||
createControl: function() {{
|
// Configuration properties (to be overridden by subclasses)
|
||||||
console.log("📋 Creating DocumentNavigator control for view mode...");
|
config: {{
|
||||||
|
icon: '?',
|
||||||
|
title: 'Control',
|
||||||
|
className: 'control',
|
||||||
|
defaultContent: 'Template only',
|
||||||
|
ariaLabel: 'Control',
|
||||||
|
position: 'w' // Default compass position: west (middle-left)
|
||||||
|
}},
|
||||||
|
|
||||||
this.navElement = document.createElement('nav');
|
// Compass positioning system (top-aligned for proper expansion)
|
||||||
this.navElement.className = 'document-navigator';
|
compassPositions: {{
|
||||||
this.navElement.innerHTML = `
|
// North positions (top)
|
||||||
<button class="navigator-toggle" aria-label="Document Navigation">☰</button>
|
'n': {{ top: '20px', left: '50%', transform: 'translateX(-50%)' }},
|
||||||
<div class="navigator-panel" style="display: none;">
|
'nne': {{ top: '20px', left: '65%', transform: 'translateX(-50%)' }},
|
||||||
<div class="navigator-header">
|
'ne': {{ top: '20px', right: '20px' }},
|
||||||
<span class="navigator-icon">☰</span>
|
'ene': {{ top: '80px', right: '20px' }}, // Top-aligned instead of center
|
||||||
<h3>Contents</h3>
|
|
||||||
<button class="navigator-close">✕</button>
|
// 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>
|
||||||
<div class="navigator-content">Loading...</div>
|
<div class="control-content">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Position on left side following UI convention
|
// Position using compass direction
|
||||||
this.navElement.style.cssText = `
|
const positionStyles = this.getPositionStyles();
|
||||||
position: fixed;
|
this.element.style.cssText = `
|
||||||
top: 80px;
|
position: ${{positionStyles.position}};
|
||||||
left: 20px;
|
top: ${{positionStyles.top}};
|
||||||
z-index: 1000;
|
right: ${{positionStyles.right}};
|
||||||
|
bottom: ${{positionStyles.bottom}};
|
||||||
|
left: ${{positionStyles.left}};
|
||||||
|
transform: ${{positionStyles.transform}};
|
||||||
|
z-index: ${{positionStyles.zIndex}};
|
||||||
background: rgba(255, 255, 255, 0.95);
|
background: rgba(255, 255, 255, 0.95);
|
||||||
border: 1px solid #e1e5e9;
|
border: 1px solid #e1e5e9;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
width: 40px;
|
width: 40px;
|
||||||
transition: width 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
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
|
// Style toggle button
|
||||||
const toggleBtn = this.navElement.querySelector('.navigator-toggle');
|
const toggleBtn = this.element.querySelector('.control-toggle');
|
||||||
toggleBtn.style.cssText = `
|
toggleBtn.style.cssText = `
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
@@ -1232,18 +1315,18 @@ class CleanDocumentManager:
|
|||||||
transition: color 0.2s ease;
|
transition: color 0.2s ease;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Handle click to build navigation on-demand
|
// Handle click to build content on-demand
|
||||||
toggleBtn.addEventListener('click', () => {{
|
toggleBtn.addEventListener('click', () => {{
|
||||||
if (this.isExpanded) {{
|
if (this.isExpanded) {{
|
||||||
this.collapse();
|
this.collapse();
|
||||||
}} else {{
|
}} else {{
|
||||||
console.log("📋 Navigator toggle clicked - building navigation...");
|
console.log(`🎛️ ${{this.config.title}} toggle clicked - building content...`);
|
||||||
this.buildNavigation();
|
this.buildContent();
|
||||||
}}
|
}}
|
||||||
}});
|
}});
|
||||||
|
|
||||||
// Close button handler
|
// Close button handler
|
||||||
const closeBtn = this.navElement.querySelector('.navigator-close');
|
const closeBtn = this.element.querySelector('.control-close');
|
||||||
closeBtn.addEventListener('click', () => {{
|
closeBtn.addEventListener('click', () => {{
|
||||||
this.collapse();
|
this.collapse();
|
||||||
}});
|
}});
|
||||||
@@ -1251,26 +1334,24 @@ class CleanDocumentManager:
|
|||||||
// Responsive behavior
|
// Responsive behavior
|
||||||
window.addEventListener('resize', () => {{
|
window.addEventListener('resize', () => {{
|
||||||
if (window.innerWidth <= 768) {{
|
if (window.innerWidth <= 768) {{
|
||||||
this.navElement.style.display = 'none';
|
this.element.style.display = 'none';
|
||||||
}} else {{
|
}} else {{
|
||||||
this.navElement.style.display = '';
|
this.element.style.display = '';
|
||||||
}}
|
}}
|
||||||
}});
|
}});
|
||||||
|
|
||||||
document.body.appendChild(this.navElement);
|
document.body.appendChild(this.element);
|
||||||
|
|
||||||
// Hide on mobile
|
// Hide on mobile
|
||||||
if (window.innerWidth <= 768) {{
|
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() {{
|
styleHeader: function() {{
|
||||||
const panel = this.navElement.querySelector('.navigator-panel');
|
const header = this.element.querySelector('.control-header');
|
||||||
const content = this.navElement.querySelector('.navigator-content');
|
|
||||||
const header = this.navElement.querySelector('.navigator-header');
|
|
||||||
|
|
||||||
// Style the header to show icon, title, and close button in one line
|
// Style the header to show icon, title, and close button in one line
|
||||||
// Match the height of the collapsed icon state (40px)
|
// Match the height of the collapsed icon state (40px)
|
||||||
@@ -1284,7 +1365,7 @@ class CleanDocumentManager:
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const icon = header.querySelector('.navigator-icon');
|
const icon = header.querySelector('.control-icon');
|
||||||
if (icon) {{
|
if (icon) {{
|
||||||
icon.style.cssText = `
|
icon.style.cssText = `
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@@ -1309,7 +1390,7 @@ class CleanDocumentManager:
|
|||||||
`;
|
`;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
const closeBtn = header.querySelector('.navigator-close');
|
const closeBtn = header.querySelector('.control-close');
|
||||||
if (closeBtn) {{
|
if (closeBtn) {{
|
||||||
closeBtn.style.cssText = `
|
closeBtn.style.cssText = `
|
||||||
background: none;
|
background: none;
|
||||||
@@ -1325,6 +1406,135 @@ class CleanDocumentManager:
|
|||||||
justify-content: center;
|
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
|
// Build navigation content from current DOM
|
||||||
const allHeadings = document.querySelectorAll('h1, h2, h3');
|
const allHeadings = document.querySelectorAll('h1, h2, h3');
|
||||||
@@ -1352,7 +1562,7 @@ class CleanDocumentManager:
|
|||||||
border-radius: 4px; cursor: pointer;"
|
border-radius: 4px; cursor: pointer;"
|
||||||
onmouseover="this.style.backgroundColor='#f5f5f5'"
|
onmouseover="this.style.backgroundColor='#f5f5f5'"
|
||||||
onmouseout="this.style.backgroundColor=''"
|
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()}}
|
${{heading.textContent.trim()}}
|
||||||
</a>
|
</a>
|
||||||
`;
|
`;
|
||||||
@@ -1360,85 +1570,19 @@ class CleanDocumentManager:
|
|||||||
content.innerHTML = navHtml;
|
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
|
// Show panel
|
||||||
this.expand();
|
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
|
// Initialize the ContentsControl
|
||||||
documentNavigator.createControl();
|
contentsControl.createControl();
|
||||||
|
|
||||||
// Make globally available for mobile collapse
|
// Make globally available for mobile collapse
|
||||||
window.documentNavigator = documentNavigator;
|
window.contentsControl = contentsControl;
|
||||||
}} catch (error) {{
|
}} catch (error) {{
|
||||||
console.error("DocumentNavigator failed to initialize:", error);
|
console.error("ContentsControl failed to initialize:", error);
|
||||||
}}
|
}}
|
||||||
|
|
||||||
}});
|
}});
|
||||||
|
|
||||||
// Handle CDN loading errors
|
// Handle CDN loading errors
|
||||||
@@ -1500,49 +1644,133 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
// Create document controls
|
// Create document controls
|
||||||
documentControls.create();
|
documentControls.create();
|
||||||
|
|
||||||
// Create DocumentNavigator for edit mode (lazy loading)
|
// Define abstract Control class for UI controls (same as viewing mode)
|
||||||
const documentNavigator = {
|
const Control = {
|
||||||
navElement: null,
|
// Abstract control properties
|
||||||
|
element: null,
|
||||||
isExpanded: false,
|
isExpanded: false,
|
||||||
isDragging: false,
|
isDragging: false,
|
||||||
dragOffset: { x: 0, y: 0 },
|
dragOffset: { x: 0, y: 0 },
|
||||||
originalPosition: { top: '80px', left: '20px' },
|
originalPosition: { top: '80px', left: '20px' },
|
||||||
|
|
||||||
createControl: function() {
|
// Configuration properties (to be overridden by subclasses)
|
||||||
console.log("📋 Creating DocumentNavigator control for edit mode...");
|
config: {
|
||||||
|
icon: '?',
|
||||||
|
title: 'Control',
|
||||||
|
className: 'control',
|
||||||
|
defaultContent: 'Template only',
|
||||||
|
ariaLabel: 'Control',
|
||||||
|
position: 'w' // Default compass position: west (middle-left)
|
||||||
|
},
|
||||||
|
|
||||||
this.navElement = document.createElement('nav');
|
// Compass positioning system (top-aligned for proper expansion)
|
||||||
this.navElement.className = 'document-navigator edit-mode';
|
compassPositions: {
|
||||||
this.navElement.innerHTML = `
|
// North positions (top)
|
||||||
<button class="navigator-toggle" aria-label="Document Navigation">☰</button>
|
'n': { top: '20px', left: '50%', transform: 'translateX(-50%)' },
|
||||||
<div class="navigator-panel" style="display: none;">
|
'nne': { top: '20px', left: '65%', transform: 'translateX(-50%)' },
|
||||||
<div class="navigator-header">
|
'ne': { top: '20px', right: '20px' },
|
||||||
<span class="navigator-icon">☰</span>
|
'ene': { top: '80px', right: '20px' }, // Top-aligned instead of center
|
||||||
<h3>Contents</h3>
|
|
||||||
<button class="navigator-close">✕</button>
|
// 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>
|
||||||
<div class="navigator-content">Loading...</div>
|
<div class="control-content">Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Position on left side following UI convention
|
// Position using compass direction
|
||||||
this.navElement.style.cssText = `
|
const positionStyles = this.getPositionStyles();
|
||||||
position: fixed;
|
this.element.style.cssText = `
|
||||||
top: 80px;
|
position: ${positionStyles.position};
|
||||||
left: 20px;
|
top: ${positionStyles.top};
|
||||||
z-index: 1001;
|
right: ${positionStyles.right};
|
||||||
|
bottom: ${positionStyles.bottom};
|
||||||
|
left: ${positionStyles.left};
|
||||||
|
transform: ${positionStyles.transform};
|
||||||
|
z-index: ${positionStyles.zIndex};
|
||||||
background: rgba(255, 255, 255, 0.95);
|
background: rgba(255, 255, 255, 0.95);
|
||||||
border: 1px solid #e1e5e9;
|
border: 1px solid #e1e5e9;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
backdrop-filter: blur(8px);
|
backdrop-filter: blur(8px);
|
||||||
width: 40px;
|
width: 40px;
|
||||||
transition: width 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
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
|
// Style toggle button
|
||||||
const toggleBtn = this.navElement.querySelector('.navigator-toggle');
|
const toggleBtn = this.element.querySelector('.control-toggle');
|
||||||
toggleBtn.style.cssText = `
|
toggleBtn.style.cssText = `
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
@@ -1554,30 +1782,28 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
transition: color 0.2s ease;
|
transition: color 0.2s ease;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Handle click to build navigation on-demand
|
// Handle click to build content on-demand
|
||||||
toggleBtn.addEventListener('click', () => {
|
toggleBtn.addEventListener('click', () => {
|
||||||
if (this.isExpanded) {
|
if (this.isExpanded) {
|
||||||
this.collapse();
|
this.collapse();
|
||||||
} else {
|
} else {
|
||||||
console.log("📋 Navigator toggle clicked - building navigation...");
|
console.log(`🎛️ ${this.config.title} toggle clicked - building content...`);
|
||||||
this.buildNavigation();
|
this.buildContent();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close button handler
|
// Close button handler
|
||||||
const closeBtn = this.navElement.querySelector('.navigator-close');
|
const closeBtn = this.element.querySelector('.control-close');
|
||||||
closeBtn.addEventListener('click', () => {
|
closeBtn.addEventListener('click', () => {
|
||||||
this.collapse();
|
this.collapse();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.body.appendChild(this.navElement);
|
document.body.appendChild(this.element);
|
||||||
console.log("📋 DocumentNavigator control created");
|
console.log(`🎛️ ${this.config.title} control created`);
|
||||||
},
|
},
|
||||||
|
|
||||||
buildNavigation: function() {
|
styleHeader: function() {
|
||||||
const panel = this.navElement.querySelector('.navigator-panel');
|
const header = this.element.querySelector('.control-header');
|
||||||
const content = this.navElement.querySelector('.navigator-content');
|
|
||||||
const header = this.navElement.querySelector('.navigator-header');
|
|
||||||
|
|
||||||
// Style the header to show icon, title, and close button in one line
|
// Style the header to show icon, title, and close button in one line
|
||||||
// Match the height of the collapsed icon state (40px)
|
// Match the height of the collapsed icon state (40px)
|
||||||
@@ -1591,7 +1817,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const icon = header.querySelector('.navigator-icon');
|
const icon = header.querySelector('.control-icon');
|
||||||
if (icon) {
|
if (icon) {
|
||||||
icon.style.cssText = `
|
icon.style.cssText = `
|
||||||
font-size: 16px;
|
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) {
|
if (closeBtn) {
|
||||||
closeBtn.style.cssText = `
|
closeBtn.style.cssText = `
|
||||||
background: none;
|
background: none;
|
||||||
@@ -1632,6 +1858,135 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
justify-content: center;
|
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
|
// Build navigation content from current DOM
|
||||||
const allHeadings = document.querySelectorAll('h1, h2, h3');
|
const allHeadings = document.querySelectorAll('h1, h2, h3');
|
||||||
@@ -1659,7 +2014,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
border-radius: 4px; cursor: pointer;"
|
border-radius: 4px; cursor: pointer;"
|
||||||
onmouseover="this.style.backgroundColor='#f5f5f5'"
|
onmouseover="this.style.backgroundColor='#f5f5f5'"
|
||||||
onmouseout="this.style.backgroundColor=''"
|
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()}
|
${heading.textContent.trim()}
|
||||||
</a>
|
</a>
|
||||||
`;
|
`;
|
||||||
@@ -1667,79 +2022,19 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
content.innerHTML = navHtml;
|
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
|
// Show panel
|
||||||
this.expand();
|
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
|
// Initialize the ContentsControl
|
||||||
documentNavigator.createControl();
|
contentsControl.createControl();
|
||||||
|
|
||||||
|
// Make globally available for mobile collapse
|
||||||
|
window.contentsControl = contentsControl;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("ContentsControl failed to initialize:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Wire up event handlers
|
// Wire up event handlers
|
||||||
documentControls.setEventHandlers({
|
documentControls.setEventHandlers({
|
||||||
|
|||||||
Reference in New Issue
Block a user