Add backlink
This commit is contained in:
398
frontend/src/link-inserter.js
Normal file
398
frontend/src/link-inserter.js
Normal file
@ -0,0 +1,398 @@
|
||||
/**
|
||||
* LinkInserter - Modal de recherche pour insérer des liens vers d'autres notes
|
||||
* Intégré dans l'éditeur CodeMirror 6
|
||||
*/
|
||||
class LinkInserter {
|
||||
constructor() {
|
||||
this.modal = null;
|
||||
this.input = null;
|
||||
this.resultsContainer = null;
|
||||
this.isOpen = false;
|
||||
this.searchTimeout = null;
|
||||
this.selectedIndex = 0;
|
||||
this.results = [];
|
||||
this.callback = null; // Fonction appelée quand un lien est sélectionné
|
||||
this.editorView = null; // Référence à l'instance CodeMirror
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createModal();
|
||||
}
|
||||
|
||||
createModal() {
|
||||
// Créer la modale (plus compacte que SearchModal)
|
||||
this.modal = document.createElement('div');
|
||||
this.modal.id = 'link-inserter-modal';
|
||||
this.modal.className = 'link-inserter-modal';
|
||||
this.modal.style.display = 'none';
|
||||
|
||||
this.modal.innerHTML = `
|
||||
<div class="link-inserter-overlay"></div>
|
||||
<div class="link-inserter-container">
|
||||
<div class="link-inserter-header">
|
||||
<div class="link-inserter-input-wrapper">
|
||||
<svg class="link-inserter-icon" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path>
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
class="link-inserter-input"
|
||||
placeholder="Rechercher une note à lier..."
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<kbd class="link-inserter-kbd">ESC</kbd>
|
||||
</div>
|
||||
</div>
|
||||
<div class="link-inserter-body">
|
||||
<div class="link-inserter-results">
|
||||
<div class="link-inserter-help">
|
||||
<div class="link-inserter-help-text">
|
||||
🔗 Tapez pour rechercher une note
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="link-inserter-footer">
|
||||
<div class="link-inserter-footer-hint">
|
||||
<kbd>↑</kbd><kbd>↓</kbd> Navigation
|
||||
<kbd>↵</kbd> Insérer
|
||||
<kbd>ESC</kbd> Annuler
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(this.modal);
|
||||
|
||||
// Références aux éléments
|
||||
this.input = this.modal.querySelector('.link-inserter-input');
|
||||
this.resultsContainer = this.modal.querySelector('.link-inserter-results');
|
||||
|
||||
// Event listeners
|
||||
this.modal.querySelector('.link-inserter-overlay').addEventListener('click', () => {
|
||||
this.close();
|
||||
});
|
||||
|
||||
this.input.addEventListener('input', (e) => {
|
||||
this.handleSearch(e.target.value);
|
||||
});
|
||||
|
||||
this.input.addEventListener('keydown', (e) => {
|
||||
this.handleKeyNavigation(e);
|
||||
});
|
||||
}
|
||||
|
||||
handleSearch(query) {
|
||||
// Debounce de 200ms (plus rapide que SearchModal)
|
||||
clearTimeout(this.searchTimeout);
|
||||
|
||||
if (!query.trim()) {
|
||||
this.showHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
this.showLoading();
|
||||
|
||||
this.searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
// Utiliser l'API de recherche existante
|
||||
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) => {
|
||||
const title = link.querySelector('.search-result-title')?.textContent || 'Sans titre';
|
||||
const path = link.getAttribute('hx-get')?.replace('/api/notes/', '') || '';
|
||||
const tags = Array.from(link.querySelectorAll('.tag-pill')).map(t => t.textContent);
|
||||
const pathDisplay = link.querySelector('.search-result-path')?.textContent || '';
|
||||
|
||||
return {
|
||||
index,
|
||||
title: title.trim(),
|
||||
path: path.trim(),
|
||||
pathDisplay: pathDisplay.trim(),
|
||||
tags
|
||||
};
|
||||
});
|
||||
|
||||
if (this.results.length > 0) {
|
||||
this.renderResults(query);
|
||||
} else {
|
||||
this.showNoResults(query);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[LinkInserter] Erreur de recherche:', error);
|
||||
this.showError();
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
|
||||
handleKeyNavigation(event) {
|
||||
console.log('[LinkInserter] Key pressed:', event.key, 'Results:', this.results.length);
|
||||
|
||||
if (this.results.length === 0) {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
console.log('[LinkInserter] Arrow Down - moving to index:', this.selectedIndex + 1);
|
||||
event.preventDefault();
|
||||
this.selectedIndex = Math.min(this.selectedIndex + 1, this.results.length - 1);
|
||||
this.updateSelection();
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
console.log('[LinkInserter] Arrow Up - moving to index:', this.selectedIndex - 1);
|
||||
event.preventDefault();
|
||||
this.selectedIndex = Math.max(this.selectedIndex - 1, 0);
|
||||
this.updateSelection();
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
console.log('[LinkInserter] Enter pressed - calling selectResult()');
|
||||
event.preventDefault();
|
||||
this.selectResult();
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
console.log('[LinkInserter] Escape pressed - closing modal');
|
||||
event.preventDefault();
|
||||
this.close();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
updateSelection() {
|
||||
const resultItems = this.resultsContainer.querySelectorAll('.link-inserter-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() {
|
||||
console.log('[LinkInserter] selectResult called, results:', this.results.length);
|
||||
|
||||
if (this.results.length === 0) {
|
||||
console.warn('[LinkInserter] No results to select');
|
||||
return;
|
||||
}
|
||||
|
||||
const selected = this.results[this.selectedIndex];
|
||||
console.log('[LinkInserter] Selected:', selected);
|
||||
console.log('[LinkInserter] Callback exists:', !!this.callback);
|
||||
|
||||
if (selected && this.callback) {
|
||||
console.log('[LinkInserter] Calling callback with:', { title: selected.title, path: selected.path });
|
||||
|
||||
// Sauvegarder le callback localement avant de fermer
|
||||
const callback = this.callback;
|
||||
|
||||
// Fermer le modal d'abord
|
||||
this.close();
|
||||
|
||||
// Puis appeler le callback après un petit délai pour que le modal se ferme proprement
|
||||
setTimeout(() => {
|
||||
console.log('[LinkInserter] Executing callback now...');
|
||||
callback({
|
||||
title: selected.title,
|
||||
path: selected.path
|
||||
});
|
||||
}, 50);
|
||||
} else {
|
||||
console.error('[LinkInserter] Cannot select: no callback or no selected item');
|
||||
this.close();
|
||||
}
|
||||
}
|
||||
|
||||
renderResults(query) {
|
||||
this.resultsContainer.innerHTML = '';
|
||||
this.selectedIndex = 0;
|
||||
|
||||
const resultsHeader = document.createElement('div');
|
||||
resultsHeader.className = 'link-inserter-results-header';
|
||||
resultsHeader.innerHTML = `
|
||||
<span class="link-inserter-results-count">${this.results.length} note${this.results.length > 1 ? 's' : ''}</span>
|
||||
`;
|
||||
this.resultsContainer.appendChild(resultsHeader);
|
||||
|
||||
this.results.forEach((result, index) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'link-inserter-result-item';
|
||||
if (index === 0) item.classList.add('selected');
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="link-inserter-result-icon">📄</div>
|
||||
<div class="link-inserter-result-content">
|
||||
<div class="link-inserter-result-title">${this.highlightQuery(result.title, query)}</div>
|
||||
<div class="link-inserter-result-path">${result.pathDisplay}</div>
|
||||
${result.tags.length > 0 ? `
|
||||
<div class="link-inserter-result-tags">
|
||||
${result.tags.map(tag => `<span class="tag-pill-small">${tag}</span>`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Click handler
|
||||
item.addEventListener('click', (e) => {
|
||||
console.log('[LinkInserter] Item clicked, index:', index);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
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;
|
||||
|
||||
const terms = query.split(/\s+/)
|
||||
.filter(term => !term.includes(':'))
|
||||
.map(term => term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
|
||||
|
||||
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="link-inserter-help">
|
||||
<div class="link-inserter-help-text">
|
||||
🔗 Tapez pour rechercher une note
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
showLoading() {
|
||||
this.resultsContainer.innerHTML = `
|
||||
<div class="link-inserter-loading">
|
||||
<div class="link-inserter-spinner"></div>
|
||||
<p>Recherche...</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
showNoResults(query) {
|
||||
this.results = [];
|
||||
this.resultsContainer.innerHTML = `
|
||||
<div class="link-inserter-no-results">
|
||||
<div class="link-inserter-no-results-icon">🔍</div>
|
||||
<p>Aucune note trouvée pour « <strong>${this.escapeHtml(query)}</strong> »</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
showError() {
|
||||
this.resultsContainer.innerHTML = `
|
||||
<div class="link-inserter-error">
|
||||
<div class="link-inserter-error-icon">⚠️</div>
|
||||
<p>Erreur lors de la recherche</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ouvrir le modal de sélection de lien
|
||||
* @param {Object} options - Options d'ouverture
|
||||
* @param {EditorView} options.editorView - Instance CodeMirror
|
||||
* @param {Function} options.onSelect - Callback appelé avec {title, path}
|
||||
*/
|
||||
open({ editorView, onSelect }) {
|
||||
console.log('[LinkInserter] open() called with callback:', !!onSelect);
|
||||
|
||||
if (this.isOpen) return;
|
||||
|
||||
this.editorView = editorView;
|
||||
this.callback = onSelect;
|
||||
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 = [];
|
||||
this.callback = null;
|
||||
this.editorView = null;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.modal && this.modal.parentNode) {
|
||||
this.modal.parentNode.removeChild(this.modal);
|
||||
}
|
||||
|
||||
this.modal = null;
|
||||
this.input = null;
|
||||
this.resultsContainer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Instance globale
|
||||
window.linkInserter = null;
|
||||
|
||||
// Initialisation automatique
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.linkInserter = new LinkInserter();
|
||||
});
|
||||
|
||||
export { LinkInserter };
|
||||
Reference in New Issue
Block a user