418 lines
15 KiB
JavaScript
418 lines
15 KiB
JavaScript
import { debug, debugError } from './debug.js';
|
|
/**
|
|
* SearchModal - Système de recherche modale avec raccourcis clavier
|
|
* Inspiré des Command Palettes modernes (VSCode, Notion, etc.)
|
|
*/
|
|
class SearchModal {
|
|
constructor() {
|
|
this.modal = null;
|
|
this.input = null;
|
|
this.resultsContainer = null;
|
|
this.isOpen = false;
|
|
this.searchTimeout = null;
|
|
this.selectedIndex = 0;
|
|
this.results = [];
|
|
this._keydownHandler = null;
|
|
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
this.createModal();
|
|
this.attachKeyboardShortcuts();
|
|
}
|
|
|
|
createModal() {
|
|
// Créer la modale
|
|
this.modal = document.createElement('div');
|
|
this.modal.id = 'search-modal';
|
|
this.modal.className = 'search-modal';
|
|
this.modal.style.display = 'none';
|
|
|
|
this.modal.innerHTML = `
|
|
<div class="search-modal-overlay"></div>
|
|
<div class="search-modal-container">
|
|
<div class="search-modal-header">
|
|
<div class="search-modal-input-wrapper">
|
|
<svg class="search-modal-icon" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<circle cx="11" cy="11" r="8"></circle>
|
|
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
|
</svg>
|
|
<input
|
|
type="text"
|
|
class="search-modal-input"
|
|
placeholder="Rechercher dans les notes... (tag:projet, title:réunion, path:docs)"
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
/>
|
|
<kbd class="search-modal-kbd">ESC</kbd>
|
|
</div>
|
|
</div>
|
|
<div class="search-modal-body">
|
|
<div class="search-modal-results">
|
|
<div class="search-modal-help">
|
|
<div class="search-modal-help-title"><i data-lucide="lightbulb" class="icon-sm"></i> Recherche avancée</div>
|
|
<div class="search-modal-help-items">
|
|
<div class="search-modal-help-item">
|
|
<code>tag:projet</code>
|
|
<span>Filtrer par tag</span>
|
|
</div>
|
|
<div class="search-modal-help-item">
|
|
<code>title:réunion</code>
|
|
<span>Rechercher dans les titres</span>
|
|
</div>
|
|
<div class="search-modal-help-item">
|
|
<code>path:docs</code>
|
|
<span>Rechercher dans les chemins</span>
|
|
</div>
|
|
<div class="search-modal-help-item">
|
|
<code>"phrase exacte"</code>
|
|
<span>Recherche de phrase</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="search-modal-footer">
|
|
<div class="search-modal-footer-hint">
|
|
<kbd>↑</kbd><kbd>↓</kbd> Navigation
|
|
<kbd>↵</kbd> Ouvrir
|
|
<kbd>ESC</kbd> Fermer
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
document.body.appendChild(this.modal);
|
|
|
|
// Références aux éléments
|
|
this.input = this.modal.querySelector('.search-modal-input');
|
|
this.resultsContainer = this.modal.querySelector('.search-modal-results');
|
|
|
|
// Event listeners
|
|
this.modal.querySelector('.search-modal-overlay').addEventListener('click', () => {
|
|
this.close();
|
|
});
|
|
|
|
this.input.addEventListener('input', (e) => {
|
|
this.handleSearch(e.target.value);
|
|
});
|
|
|
|
this.input.addEventListener('keydown', (e) => {
|
|
this.handleKeyNavigation(e);
|
|
});
|
|
}
|
|
|
|
attachKeyboardShortcuts() {
|
|
this._keydownHandler = (event) => {
|
|
// Ctrl+K ou Cmd+K pour ouvrir
|
|
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
|
|
event.preventDefault();
|
|
this.toggle();
|
|
}
|
|
|
|
// Escape pour fermer
|
|
if (event.key === 'Escape' && this.isOpen) {
|
|
event.preventDefault();
|
|
this.close();
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', this._keydownHandler);
|
|
}
|
|
|
|
handleSearch(query) {
|
|
// Debounce de 300ms
|
|
clearTimeout(this.searchTimeout);
|
|
|
|
if (!query.trim()) {
|
|
this.showHelp();
|
|
return;
|
|
}
|
|
|
|
this.showLoading();
|
|
|
|
this.searchTimeout = setTimeout(async () => {
|
|
try {
|
|
const response = await fetch(`/api/search?query=${encodeURIComponent(query)}`);
|
|
const html = await response.text();
|
|
|
|
// Parser le HTML pour extraire les résultats
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(html, 'text/html');
|
|
|
|
// Extraire les liens de résultats
|
|
const resultLinks = doc.querySelectorAll('.search-result-link');
|
|
this.results = Array.from(resultLinks).map((link, index) => ({
|
|
index,
|
|
element: link.cloneNode(true)
|
|
}));
|
|
|
|
if (this.results.length > 0) {
|
|
this.renderResults(query);
|
|
} else {
|
|
this.showNoResults(query);
|
|
}
|
|
} catch (error) {
|
|
console.error('[SearchModal] Erreur de recherche:', error);
|
|
this.showError();
|
|
}
|
|
}, 300);
|
|
}
|
|
|
|
handleKeyNavigation(event) {
|
|
if (this.results.length === 0) return;
|
|
|
|
switch (event.key) {
|
|
case 'ArrowDown':
|
|
event.preventDefault();
|
|
this.selectedIndex = Math.min(this.selectedIndex + 1, this.results.length - 1);
|
|
this.updateSelection();
|
|
break;
|
|
|
|
case 'ArrowUp':
|
|
event.preventDefault();
|
|
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
|
|
this.updateSelection();
|
|
break;
|
|
|
|
case 'Enter':
|
|
event.preventDefault();
|
|
this.selectResult();
|
|
break;
|
|
}
|
|
}
|
|
|
|
updateSelection() {
|
|
const resultItems = this.resultsContainer.querySelectorAll('.search-modal-result-item');
|
|
resultItems.forEach((item, index) => {
|
|
if (index === this.selectedIndex) {
|
|
item.classList.add('selected');
|
|
item.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
|
|
} else {
|
|
item.classList.remove('selected');
|
|
}
|
|
});
|
|
}
|
|
|
|
selectResult() {
|
|
if (this.results.length === 0) return;
|
|
|
|
const selected = this.results[this.selectedIndex];
|
|
if (selected && selected.element) {
|
|
// Déclencher le clic sur le lien htmx
|
|
const path = selected.element.getAttribute('hx-get');
|
|
if (path) {
|
|
// Utiliser htmx pour charger la note
|
|
htmx.ajax('GET', path, {
|
|
target: '#editor-container',
|
|
swap: 'innerHTML'
|
|
});
|
|
this.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
renderResults(query) {
|
|
this.resultsContainer.innerHTML = '';
|
|
this.selectedIndex = 0;
|
|
|
|
const resultsHeader = document.createElement('div');
|
|
resultsHeader.className = 'search-modal-results-header';
|
|
resultsHeader.innerHTML = `
|
|
<span class="search-modal-results-count">${this.results.length} résultat${this.results.length > 1 ? 's' : ''}</span>
|
|
`;
|
|
this.resultsContainer.appendChild(resultsHeader);
|
|
|
|
this.results.forEach((result, index) => {
|
|
const item = document.createElement('div');
|
|
item.className = 'search-modal-result-item';
|
|
if (index === 0) item.classList.add('selected');
|
|
|
|
// Copier le contenu du lien de résultat
|
|
const originalLink = result.element;
|
|
const icon = originalLink.querySelector('.search-result-icon')?.textContent || '📄';
|
|
const title = originalLink.querySelector('.search-result-title')?.textContent || 'Sans titre';
|
|
const path = originalLink.querySelector('.search-result-path')?.textContent || '';
|
|
const snippet = originalLink.querySelector('.search-result-snippet')?.textContent || '';
|
|
const tags = Array.from(originalLink.querySelectorAll('.tag-pill')).map(t => t.textContent);
|
|
const lastModified = originalLink.querySelector('.search-result-date')?.textContent || '';
|
|
|
|
item.innerHTML = `
|
|
<div class="search-modal-result-icon">${icon}</div>
|
|
<div class="search-modal-result-content">
|
|
<div class="search-modal-result-title">${this.highlightQuery(title, query)}</div>
|
|
<div class="search-modal-result-path">${path}</div>
|
|
${snippet ? `<div class="search-modal-result-snippet">${this.highlightQuery(snippet, query)}</div>` : ''}
|
|
<div class="search-modal-result-footer">
|
|
${tags.length > 0 ? `
|
|
<div class="search-modal-result-tags">
|
|
${tags.map(tag => `<span class="tag-pill">${tag}</span>`).join('')}
|
|
</div>
|
|
` : ''}
|
|
${lastModified ? `<span class="search-modal-result-date">${lastModified}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Click handler
|
|
item.addEventListener('click', () => {
|
|
this.selectedIndex = index;
|
|
this.selectResult();
|
|
});
|
|
|
|
// Hover handler
|
|
item.addEventListener('mouseenter', () => {
|
|
this.selectedIndex = index;
|
|
this.updateSelection();
|
|
});
|
|
|
|
this.resultsContainer.appendChild(item);
|
|
});
|
|
}
|
|
|
|
highlightQuery(text, query) {
|
|
if (!query || !text) return text;
|
|
|
|
// Extraire les termes de recherche (ignore les préfixes tag:, title:, etc.)
|
|
const terms = query.split(/\s+/)
|
|
.filter(term => !term.includes(':'))
|
|
.map(term => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); // Escape regex
|
|
|
|
if (terms.length === 0) return text;
|
|
|
|
const regex = new RegExp(`(${terms.join('|')})`, 'gi');
|
|
return text.replace(regex, '<mark>$1</mark>');
|
|
}
|
|
|
|
showHelp() {
|
|
this.results = [];
|
|
this.resultsContainer.innerHTML = `
|
|
<div class="search-modal-help">
|
|
<div class="search-modal-help-title"><i data-lucide="lightbulb" class="icon-sm"></i> Recherche avancée</div>
|
|
<div class="search-modal-help-items">
|
|
<div class="search-modal-help-item">
|
|
<code>tag:projet</code>
|
|
<span>Filtrer par tag</span>
|
|
</div>
|
|
<div class="search-modal-help-item">
|
|
<code>title:réunion</code>
|
|
<span>Rechercher dans les titres</span>
|
|
</div>
|
|
<div class="search-modal-help-item">
|
|
<code>path:docs</code>
|
|
<span>Rechercher dans les chemins</span>
|
|
</div>
|
|
<div class="search-modal-help-item">
|
|
<code>"phrase exacte"</code>
|
|
<span>Recherche de phrase</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
showLoading() {
|
|
this.resultsContainer.innerHTML = `
|
|
<div class="search-modal-loading">
|
|
<div class="search-modal-spinner"></div>
|
|
<p>Recherche en cours...</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
showNoResults(query) {
|
|
this.results = [];
|
|
this.resultsContainer.innerHTML = `
|
|
<div class="search-modal-no-results">
|
|
<div class="search-modal-no-results-icon"><i data-lucide="search" class="icon-lg"></i></div>
|
|
<p class="search-modal-no-results-text">Aucun résultat pour « <strong>${this.escapeHtml(query)}</strong> »</p>
|
|
<p class="search-modal-no-results-hint">Essayez d'autres mots-clés ou utilisez les filtres</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
showError() {
|
|
this.resultsContainer.innerHTML = `
|
|
<div class="search-modal-error">
|
|
<div class="search-modal-error-icon"><i data-lucide="alert-triangle" class="icon-lg"></i></div>
|
|
<p>Une erreur s'est produite lors de la recherche</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
open() {
|
|
if (this.isOpen) return;
|
|
|
|
this.isOpen = true;
|
|
this.modal.style.display = 'flex';
|
|
|
|
// Animation
|
|
requestAnimationFrame(() => {
|
|
this.modal.classList.add('active');
|
|
});
|
|
|
|
// Focus sur l'input
|
|
setTimeout(() => {
|
|
this.input.focus();
|
|
this.input.select();
|
|
}, 100);
|
|
|
|
// Reset
|
|
this.selectedIndex = 0;
|
|
this.showHelp();
|
|
}
|
|
|
|
close() {
|
|
if (!this.isOpen) return;
|
|
|
|
this.isOpen = false;
|
|
this.modal.classList.remove('active');
|
|
|
|
setTimeout(() => {
|
|
this.modal.style.display = 'none';
|
|
this.input.value = '';
|
|
this.results = [];
|
|
}, 200);
|
|
}
|
|
|
|
toggle() {
|
|
if (this.isOpen) {
|
|
this.close();
|
|
} else {
|
|
this.open();
|
|
}
|
|
}
|
|
|
|
destroy() {
|
|
if (this._keydownHandler) {
|
|
document.removeEventListener('keydown', this._keydownHandler);
|
|
this._keydownHandler = null;
|
|
}
|
|
|
|
if (this.modal && this.modal.parentNode) {
|
|
this.modal.parentNode.removeChild(this.modal);
|
|
}
|
|
|
|
this.modal = null;
|
|
this.input = null;
|
|
this.resultsContainer = null;
|
|
}
|
|
}
|
|
|
|
// Instance globale
|
|
window.searchModal = null;
|
|
|
|
// Initialisation automatique
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
window.searchModal = new SearchModal();
|
|
});
|
|
|
|
export { SearchModal };
|