Add backlink

This commit is contained in:
2025-11-12 09:31:09 +01:00
parent 5e30a5cf5d
commit 584a4a0acd
25 changed files with 1769 additions and 79 deletions

View File

@ -5,6 +5,7 @@ import { markdown } from '@codemirror/lang-markdown';
import { oneDark } from '@codemirror/theme-one-dark';
import { keymap } from '@codemirror/view';
import { indentWithTab } from '@codemirror/commands';
import { LinkInserter } from './link-inserter.js';
// Import du mode Vim
let vimExtension = null;
@ -254,6 +255,9 @@ class MarkdownEditor {
htmx.process(this.preview);
}
// Intercepter les clics sur les liens internes (avec hx-get)
this.setupInternalLinkHandlers();
if (typeof hljs !== 'undefined') {
this.preview.querySelectorAll('pre code').forEach(block => {
hljs.highlightElement(block);
@ -264,6 +268,41 @@ class MarkdownEditor {
}
}
setupInternalLinkHandlers() {
// Trouver tous les liens avec hx-get (liens internes)
const internalLinks = this.preview.querySelectorAll('a[hx-get]');
internalLinks.forEach(link => {
// Retirer les anciens listeners pour éviter les doublons
link.replaceWith(link.cloneNode(true));
});
// Ré-sélectionner après clonage
const freshLinks = this.preview.querySelectorAll('a[hx-get]');
freshLinks.forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const target = link.getAttribute('hx-get');
const targetElement = link.getAttribute('hx-target') || '#editor-container';
const swapMethod = link.getAttribute('hx-swap') || 'innerHTML';
console.log('[InternalLink] Clicked:', target);
if (target && typeof htmx !== 'undefined') {
htmx.ajax('GET', target, {
target: targetElement,
swap: swapMethod
});
}
});
});
console.log('[Preview] Setup', freshLinks.length, 'internal link handlers');
}
syncToTextarea() {
if (this.editorView && this.textarea) {
this.textarea.value = this.editorView.state.doc.toString();
@ -332,6 +371,7 @@ class SlashCommands {
{ name: 'list', snippet: '- ' },
{ name: 'date', snippet: () => new Date().toLocaleDateString('fr-FR') },
{ name: 'link', snippet: '[texte](url)' },
{ name: 'ilink', isModal: true, handler: () => this.openLinkInserter() },
{ name: 'bold', snippet: '**texte**' },
{ name: 'italic', snippet: '*texte*' },
{ name: 'code', snippet: '`code`' },
@ -612,6 +652,15 @@ class SlashCommands {
return;
}
// Commande spéciale avec modal (comme /ilink)
if (command.isModal && command.handler) {
console.log('Executing modal command:', command.name);
// NE PAS cacher la palette tout de suite car le handler a besoin de slashPos
// La palette sera cachée par le handler lui-même
command.handler();
return;
}
let snippet = command.snippet;
if (typeof snippet === 'function') {
snippet = snippet();
@ -632,6 +681,59 @@ class SlashCommands {
this.hidePalette();
}
openLinkInserter() {
// Sauvegarder la position du slash IMMÉDIATEMENT avant toute autre opération
const savedSlashPos = this.slashPos;
console.log('[SlashCommands] openLinkInserter - savedSlashPos:', savedSlashPos);
if (!savedSlashPos) {
console.error('[SlashCommands] No slash position available!');
this.hidePalette();
return;
}
// Maintenant on peut cacher la palette en toute sécurité
this.hidePalette();
// S'assurer que le LinkInserter global existe, le créer si nécessaire
if (!window.linkInserter) {
console.log('Initializing LinkInserter...');
window.linkInserter = new LinkInserter();
}
// Ouvrir le modal de sélection de lien
window.linkInserter.open({
editorView: this.editorView,
onSelect: ({ title, path }) => {
console.log('[SlashCommands] onSelect callback received:', { title, path });
console.log('[SlashCommands] savedSlashPos:', savedSlashPos);
// Créer un lien HTMX cliquable dans le preview
// Format : <a href="#" onclick="return false;" hx-get="/api/notes/path" hx-target="#editor-container" hx-swap="innerHTML">Title</a>
// Le onclick="return false;" empêche le comportement par défaut du # qui pourrait rediriger
const linkHtml = `<a href="#" onclick="return false;" hx-get="/api/notes/${path}" hx-target="#editor-container" hx-swap="innerHTML">${title}</a>`;
console.log('[SlashCommands] Inserting:', linkHtml);
const { state, dispatch } = this.editorView;
const { from } = state.selection.main;
// Remplacer depuis le "/" jusqu'au curseur actuel
const replaceFrom = savedSlashPos.absolutePos;
console.log('[SlashCommands] Replacing from', replaceFrom, 'to', from);
dispatch(state.update({
changes: { from: replaceFrom, to: from, insert: linkHtml },
selection: { anchor: replaceFrom + linkHtml.length }
}));
this.editorView.focus();
console.log('[SlashCommands] Link inserted successfully');
}
});
}
destroy() {
// Retirer tous les listeners d'événements
if (this.editorView) {

View 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 };

View File

@ -3,3 +3,4 @@ import './file-tree.js';
import './ui.js';
import './search.js';
import './daily-notes.js';
import './link-inserter.js';