Files
ETPlot/gallery/assets/js/search-manager.js
T
2026-05-08 13:05:39 +02:00

218 lines
6.6 KiB
JavaScript

/**
* Search functionality
*/
export class SearchManager {
constructor(debounceMs = 300) {
this.searchTimeout = null;
this.SEARCH_DEBOUNCE_MS = debounceMs;
this.init();
}
/**
* Initialize search functionality with debouncing
*/
init() {
const searchBox = document.getElementById('searchBox');
const searchResults = document.getElementById('searchResults');
if (!searchBox || !searchResults) return;
searchBox.addEventListener('input', (e) => {
clearTimeout(this.searchTimeout);
const query = e.target.value.trim();
if (query.length === 0) {
searchResults.style.display = 'none';
return;
}
this.searchTimeout = setTimeout(() => {
this.performSearch(query);
}, this.SEARCH_DEBOUNCE_MS);
});
document.addEventListener('click', (e) => {
if (!searchBox.contains(e.target) && !searchResults.contains(e.target)) {
searchResults.style.display = 'none';
}
});
}
/**
* Perform search across plot names
*/
async performSearch(query) {
const searchResults = document.getElementById('searchResults');
if (!searchResults) return;
searchResults.innerHTML = '<div style="padding: 1rem;">🔍 Searching...</div>';
searchResults.style.display = 'block';
try {
const results = await this.searchPlots(query);
this.displaySearchResults(results, query);
} catch (error) {
console.error('Search error:', error);
searchResults.innerHTML = '<div style="padding: 1rem; color: red;">❌ Search failed</div>';
}
}
/**
* Search for plots matching the query
*/
async searchPlots(query) {
const results = [];
const visited = new Set();
const lowerQuery = query.toLowerCase();
await this.searchInPage(window.location.pathname, lowerQuery, results, visited);
await this.searchRecursive(window.location.pathname, lowerQuery, results, visited, 0, 5);
return results.slice(0, 20);
}
/**
* Search for plots in a specific page
*/
async searchInPage(path, query, results, visited, maxResults = 50) {
if (visited.has(path) || results.length >= maxResults) return;
visited.add(path);
try {
const response = await fetch(path);
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const items = doc.querySelectorAll('.grid-item');
items.forEach(item => {
const nameElement = item.querySelector('.plot-name');
const imgElement = item.querySelector('img');
const linkElement = item.querySelector('a');
if (nameElement && imgElement && linkElement) {
const name = nameElement.textContent.toLowerCase();
if (name.includes(query)) {
results.push({
name: nameElement.textContent,
path: path,
href: linkElement.href,
imgSrc: imgElement.src,
relevance: this.calculateRelevance(name, query)
});
}
}
});
} catch (error) {
console.error('Error searching in', path, error);
}
}
/**
* Recursively search in subdirectories
*/
async searchRecursive(path, query, results, visited, depth, maxDepth) {
if (depth >= maxDepth || results.length >= 50) return;
try {
const response = await fetch(path);
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const subdirs = doc.querySelectorAll('h2 + ul li a');
for (const subdir of subdirs) {
const href = subdir.getAttribute('href');
if (href) {
const baseUrl = path.replace(/\/[^\/]*$/, '/');
const subPath = baseUrl + href;
await this.searchInPage(subPath, query, results, visited);
await this.searchRecursive(subPath, query, results, visited, depth + 1, maxDepth);
}
}
} catch (error) {
console.error('Error in recursive search:', error);
}
}
/**
* Calculate search relevance score
*/
calculateRelevance(text, query) {
const exactMatch = text === query;
const startsWith = text.startsWith(query);
const wordMatch = text.split(/\s+/).some(word => word.startsWith(query));
if (exactMatch) return 100;
if (startsWith) return 80;
if (wordMatch) return 60;
return 40;
}
/**
* Display search results with highlighting
*/
displaySearchResults(results, query) {
const searchResults = document.getElementById('searchResults');
if (!searchResults) return;
if (results.length === 0) {
searchResults.innerHTML = '<div style="padding: 1rem;">📭 No plots found</div>';
return;
}
results.sort((a, b) => b.relevance - a.relevance);
let html = '';
results.forEach(result => {
const highlightedName = this.highlightText(result.name, query);
const relativePath = this.getRelativePath(result.path);
html += `
<div class="search-result-item" onclick="window.searchManager.openSearchResult('${result.href}')" title="${result.name}">
<div style="display: flex; gap: 0.7rem; align-items: center;">
<img src="${result.imgSrc}" style="width: 50px; height: 40px; object-fit: cover; border-radius: 4px;" />
<div style="flex: 1; min-width: 0;">
<div style="font-weight: 600; margin-bottom: 0.3rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
${highlightedName}
</div>
<div style="font-size: 0.8rem; color: var(--breadcrumb-color); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
📍 ${relativePath}
</div>
</div>
</div>
</div>
`;
});
searchResults.innerHTML = html;
}
/**
* Highlight search query in text
*/
highlightText(text, query) {
const regex = new RegExp(`(${query})`, 'gi');
return text.replace(regex, '<span class="search-highlight">$1</span>');
}
/**
* Get relative path for display
*/
getRelativePath(fullPath) {
const parts = fullPath.split('/').filter(p => p !== '' && p !== 'index.html');
return parts.length > 0 ? parts.join(' / ') : 'Root';
}
/**
* Open search result and track it
*/
openSearchResult(href) {
if (window.recentPlotsManager) {
window.recentPlotsManager.addToRecentPlots(href);
}
window.open(href, '_blank');
document.getElementById('searchResults').style.display = 'none';
}
}