351 lines
13 KiB
JavaScript
351 lines
13 KiB
JavaScript
/**
|
|
* MkDocs Search Integration
|
|
* Integrates MkDocs Material's search functionality into the map application
|
|
*/
|
|
|
|
export class MkDocsSearch {
|
|
constructor(config = {}) {
|
|
// Determine if we're in production based on current URL
|
|
const isProduction = window.location.hostname !== 'localhost' && !window.location.hostname.includes('127.0.0.1');
|
|
|
|
// Use production URL if we're not on localhost
|
|
if (isProduction && config.mkdocsUrl && config.mkdocsUrl.includes('localhost')) {
|
|
// Extract the base domain from the current hostname
|
|
// If we're on map.cmlite.org, we want cmlite.org
|
|
const currentDomain = window.location.hostname.replace(/^map\./, '');
|
|
this.mkdocsUrl = `https://${currentDomain}`;
|
|
} else {
|
|
this.mkdocsUrl = config.mkdocsUrl || window.MKDOCS_URL || 'http://localhost:4002';
|
|
}
|
|
|
|
this.searchIndex = null;
|
|
this.searchDocs = null;
|
|
this.lunr = null;
|
|
this.debounceTimeout = null;
|
|
this.minSearchLength = config.minSearchLength || 2;
|
|
this.initialized = false;
|
|
|
|
console.log('MkDocs Search initialized with URL:', this.mkdocsUrl);
|
|
}
|
|
|
|
async initialize() {
|
|
try {
|
|
console.log('Initializing MkDocs search...');
|
|
|
|
// Load Lunr.js dynamically
|
|
await this.loadLunr();
|
|
|
|
// Try multiple approaches to get the search index
|
|
let searchData = null;
|
|
|
|
// First try the proxy endpoint
|
|
try {
|
|
console.log('Trying proxy endpoint: /api/docs-search');
|
|
const proxyResponse = await fetch('/api/docs-search');
|
|
if (proxyResponse.ok) {
|
|
searchData = await proxyResponse.json();
|
|
console.log('Successfully loaded search index via proxy');
|
|
}
|
|
} catch (proxyError) {
|
|
console.warn('Proxy endpoint failed:', proxyError);
|
|
}
|
|
|
|
// If proxy fails, try direct access
|
|
if (!searchData) {
|
|
console.log(`Trying direct access: ${this.mkdocsUrl}/search/search_index.json`);
|
|
const response = await fetch(`${this.mkdocsUrl}/search/search_index.json`);
|
|
if (!response.ok) {
|
|
throw new Error(`Failed to load search index: ${response.status}`);
|
|
}
|
|
searchData = await response.json();
|
|
console.log('Successfully loaded search index directly');
|
|
}
|
|
|
|
// Build the Lunr index
|
|
this.searchIndex = this.lunr(function() {
|
|
this.ref('location');
|
|
this.field('title', { boost: 10 });
|
|
this.field('text');
|
|
|
|
searchData.docs.forEach(doc => {
|
|
this.add(doc);
|
|
});
|
|
});
|
|
|
|
this.searchDocs = searchData.docs;
|
|
this.initialized = true;
|
|
console.log('MkDocs search initialized with', this.searchDocs.length, 'documents');
|
|
return true;
|
|
} catch (error) {
|
|
console.error('Failed to initialize MkDocs search:', error);
|
|
this.initialized = false;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async loadLunr() {
|
|
if (window.lunr) {
|
|
this.lunr = window.lunr;
|
|
return;
|
|
}
|
|
|
|
// Load Lunr.js from CDN
|
|
return new Promise((resolve, reject) => {
|
|
const script = document.createElement('script');
|
|
script.src = 'https://unpkg.com/lunr@2.3.9/lunr.min.js';
|
|
script.onload = () => {
|
|
this.lunr = window.lunr;
|
|
resolve();
|
|
};
|
|
script.onerror = reject;
|
|
document.head.appendChild(script);
|
|
});
|
|
}
|
|
|
|
search(query) {
|
|
if (!this.initialized) {
|
|
console.warn('Search not initialized');
|
|
return [];
|
|
}
|
|
|
|
if (!this.searchIndex || !query || query.length < this.minSearchLength) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
// Perform the search
|
|
const results = this.searchIndex.search(query);
|
|
|
|
// Map results to include document data
|
|
return results.slice(0, 10).map(result => {
|
|
const doc = this.searchDocs.find(d => d.location === result.ref);
|
|
if (!doc) return null;
|
|
|
|
// Extract a snippet around the matched text
|
|
const snippet = this.extractSnippet(doc.text, query);
|
|
|
|
return {
|
|
...doc,
|
|
score: result.score,
|
|
url: `${this.mkdocsUrl}/${doc.location}`,
|
|
snippet: snippet
|
|
};
|
|
}).filter(Boolean);
|
|
} catch (error) {
|
|
console.error('Search error:', error);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
extractSnippet(text, query, maxLength = 150) {
|
|
const lowerText = text.toLowerCase();
|
|
const lowerQuery = query.toLowerCase();
|
|
const index = lowerText.indexOf(lowerQuery);
|
|
|
|
if (index === -1) {
|
|
return text.substring(0, maxLength) + '...';
|
|
}
|
|
|
|
const start = Math.max(0, index - 50);
|
|
const end = Math.min(text.length, index + query.length + 100);
|
|
let snippet = text.substring(start, end);
|
|
|
|
if (start > 0) snippet = '...' + snippet;
|
|
if (end < text.length) snippet = snippet + '...';
|
|
|
|
// Highlight the search term
|
|
const regex = new RegExp(`(${query})`, 'gi');
|
|
snippet = snippet.replace(regex, '<mark>$1</mark>');
|
|
|
|
return snippet;
|
|
}
|
|
|
|
bindToInput(inputElement, resultsElement) {
|
|
const resultsContainer = resultsElement.querySelector('.docs-search-results-list');
|
|
const resultsCount = resultsElement.querySelector('.results-count');
|
|
const closeBtn = resultsElement.querySelector('.close-results');
|
|
|
|
// Handle input with debouncing
|
|
inputElement.addEventListener('input', (e) => {
|
|
clearTimeout(this.debounceTimeout);
|
|
this.debounceTimeout = setTimeout(() => {
|
|
this.performSearch(e.target.value, resultsContainer, resultsCount, resultsElement);
|
|
}, 300);
|
|
});
|
|
|
|
// Handle keyboard shortcuts
|
|
inputElement.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
this.closeResults(inputElement, resultsElement);
|
|
}
|
|
});
|
|
|
|
// Global keyboard shortcut (Ctrl+K or Cmd+K)
|
|
document.addEventListener('keydown', (e) => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
|
e.preventDefault();
|
|
inputElement.focus();
|
|
inputElement.select();
|
|
}
|
|
});
|
|
|
|
// Close button
|
|
closeBtn.addEventListener('click', () => {
|
|
this.closeResults(inputElement, resultsElement);
|
|
});
|
|
|
|
// Click outside to close
|
|
document.addEventListener('click', (e) => {
|
|
if (!inputElement.contains(e.target) && !resultsElement.contains(e.target)) {
|
|
resultsElement.classList.add('hidden');
|
|
}
|
|
});
|
|
}
|
|
|
|
performSearch(query, resultsContainer, resultsCount, resultsElement) {
|
|
if (!query || query.length < this.minSearchLength) {
|
|
resultsElement.classList.add('hidden');
|
|
return;
|
|
}
|
|
|
|
const results = this.search(query);
|
|
|
|
if (results.length === 0) {
|
|
resultsContainer.innerHTML = '<div class="no-results">No results found</div>';
|
|
resultsCount.textContent = 'No results';
|
|
} else {
|
|
resultsCount.textContent = `${results.length} result${results.length > 1 ? 's' : ''}`;
|
|
resultsContainer.innerHTML = results.map((result, index) => `
|
|
<div class="search-result-item">
|
|
<a href="${result.url}" class="search-result" target="_blank" rel="noopener">
|
|
<div class="search-result-title">${this.escapeHtml(result.title)}</div>
|
|
<div class="search-result-snippet">${result.snippet}</div>
|
|
<div class="search-result-path">${result.location}</div>
|
|
</a>
|
|
<button class="btn btn-sm btn-secondary make-qr-btn" data-url="${result.url}" data-index="${index}">
|
|
<span class="btn-icon">📱</span>
|
|
<span class="btn-text">Make QR</span>
|
|
</button>
|
|
</div>
|
|
`).join('');
|
|
|
|
// Add event listeners to QR buttons
|
|
this.attachQRButtonListeners();
|
|
}
|
|
|
|
resultsElement.classList.remove('hidden');
|
|
}
|
|
|
|
// Add new method to handle QR button clicks
|
|
attachQRButtonListeners() {
|
|
const qrButtons = document.querySelectorAll('.make-qr-btn');
|
|
qrButtons.forEach(button => {
|
|
button.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const url = button.getAttribute('data-url');
|
|
this.showQRCodeModal(url);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Add method to show QR code modal
|
|
showQRCodeModal(url) {
|
|
// Create or get existing QR modal
|
|
let modal = document.getElementById('qr-code-modal');
|
|
if (!modal) {
|
|
modal = this.createQRModal();
|
|
document.body.appendChild(modal);
|
|
}
|
|
// Ensure modal has correct z-index and class
|
|
modal.classList.add('qr-modal');
|
|
modal.style.zIndex = '11000'; // Force above all overlays
|
|
|
|
// Update modal content
|
|
const qrImage = modal.querySelector('.qr-code-image');
|
|
const qrUrl = modal.querySelector('.qr-code-url');
|
|
const qrLoading = modal.querySelector('.qr-loading');
|
|
|
|
// Show modal and loading state
|
|
modal.classList.remove('hidden');
|
|
qrLoading.style.display = 'block';
|
|
qrImage.style.display = 'none';
|
|
|
|
// Update URL display
|
|
qrUrl.textContent = url;
|
|
qrUrl.href = url;
|
|
|
|
// Generate QR code
|
|
const qrApiUrl = `/api/qr?text=${encodeURIComponent(url)}&size=256`;
|
|
qrImage.src = qrApiUrl;
|
|
|
|
qrImage.onload = () => {
|
|
qrLoading.style.display = 'none';
|
|
qrImage.style.display = 'block';
|
|
};
|
|
|
|
qrImage.onerror = () => {
|
|
qrLoading.innerHTML = '<p style="color: var(--danger-color);">Failed to generate QR code</p>';
|
|
};
|
|
}
|
|
|
|
// Add method to create QR modal
|
|
createQRModal() {
|
|
const modal = document.createElement('div');
|
|
modal.id = 'qr-code-modal';
|
|
modal.className = 'modal qr-modal'; // Ensure both classes for styling
|
|
modal.innerHTML = `
|
|
<div class="modal-content qr-modal-content">
|
|
<div class="modal-header">
|
|
<h2>QR Code</h2>
|
|
<button class="modal-close" id="close-qr-modal">×</button>
|
|
</div>
|
|
<div class="modal-body qr-modal-body">
|
|
<div class="qr-loading">
|
|
<div class="spinner"></div>
|
|
<p>Generating QR code...</p>
|
|
</div>
|
|
<img class="qr-code-image" alt="QR Code" style="display: none;">
|
|
<div class="qr-code-info">
|
|
<p>Scan this QR code to open:</p>
|
|
<a class="qr-code-url" target="_blank" rel="noopener"></a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Add close functionality
|
|
const closeBtn = modal.querySelector('#close-qr-modal');
|
|
closeBtn.addEventListener('click', () => {
|
|
modal.classList.add('hidden');
|
|
});
|
|
|
|
// Close on background click
|
|
modal.addEventListener('click', (e) => {
|
|
if (e.target === modal) {
|
|
modal.classList.add('hidden');
|
|
}
|
|
});
|
|
|
|
// Close on Escape key
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape' && !modal.classList.contains('hidden')) {
|
|
modal.classList.add('hidden');
|
|
}
|
|
});
|
|
|
|
return modal;
|
|
}
|
|
|
|
closeResults(inputElement, resultsElement) {
|
|
resultsElement.classList.add('hidden');
|
|
inputElement.value = '';
|
|
inputElement.blur();
|
|
}
|
|
|
|
escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
} |