Premier commit déjà bien avancé
This commit is contained in:
859
frontend/src/editor.js
Normal file
859
frontend/src/editor.js
Normal file
@ -0,0 +1,859 @@
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
import { basicSetup } from '@codemirror/basic-setup';
|
||||
import { markdown } from '@codemirror/lang-markdown';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { keymap } from '@codemirror/view';
|
||||
import { indentWithTab } from '@codemirror/commands';
|
||||
|
||||
/**
|
||||
* MarkdownEditor - Éditeur Markdown avec preview en temps réel
|
||||
*/
|
||||
class MarkdownEditor {
|
||||
constructor(textareaElement, previewElement) {
|
||||
this.textarea = textareaElement;
|
||||
this.preview = previewElement;
|
||||
this._updateTimeout = null;
|
||||
this.editorView = null; // CodeMirror 6 uses EditorView
|
||||
this._isSyncing = false;
|
||||
this._autoSaveTimeout = null;
|
||||
|
||||
if (!this.textarea || !this.preview) {
|
||||
console.error('MarkdownEditor: textarea or preview element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Configuration de marked.js pour le preview
|
||||
if (typeof marked !== 'undefined') {
|
||||
marked.setOptions({
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
highlight: (code, lang) => {
|
||||
if (typeof hljs !== 'undefined') {
|
||||
try {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
return hljs.highlight(code, { language: lang }).value;
|
||||
}
|
||||
return hljs.highlightAuto(code).value;
|
||||
} catch (err) {
|
||||
console.warn('Highlight.js error:', err);
|
||||
}
|
||||
}
|
||||
return code;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialiser CodeMirror 6
|
||||
const startState = EditorState.create({
|
||||
doc: this.textarea.value,
|
||||
extensions: [
|
||||
basicSetup,
|
||||
markdown(),
|
||||
oneDark,
|
||||
keymap.of([indentWithTab]),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
// Debounce la mise à jour du preview
|
||||
if (this._updateTimeout) {
|
||||
clearTimeout(this._updateTimeout);
|
||||
}
|
||||
this._updateTimeout = setTimeout(() => {
|
||||
this.updatePreview();
|
||||
}, 150);
|
||||
|
||||
// Auto-save logic
|
||||
if (this._autoSaveTimeout) {
|
||||
clearTimeout(this._autoSaveTimeout);
|
||||
}
|
||||
this._autoSaveTimeout = setTimeout(() => {
|
||||
const form = this.textarea.closest('form');
|
||||
if (form) {
|
||||
const saveStatus = document.getElementById('auto-save-status');
|
||||
if (saveStatus) {
|
||||
saveStatus.textContent = 'Sauvegarde...';
|
||||
}
|
||||
form.requestSubmit();
|
||||
}
|
||||
}, 2000); // Auto-save after 2 seconds of inactivity
|
||||
}
|
||||
}),
|
||||
// Keymap for Ctrl/Cmd+S
|
||||
keymap.of([{
|
||||
key: "Mod-s",
|
||||
run: () => {
|
||||
const form = this.textarea.closest('form');
|
||||
if (form) {
|
||||
form.requestSubmit();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}])
|
||||
]
|
||||
});
|
||||
|
||||
this.editorView = new EditorView({
|
||||
state: startState,
|
||||
parent: this.textarea.parentElement
|
||||
});
|
||||
|
||||
// Hide the original textarea
|
||||
this.textarea.style.display = 'none';
|
||||
|
||||
// Adjust height (similar to CM5, but targeting the CM6 editor)
|
||||
const adjustHeight = () => {
|
||||
const editorDom = this.editorView.dom;
|
||||
if (!editorDom) return;
|
||||
|
||||
const height = window.innerHeight - 180; // Adjust as needed
|
||||
editorDom.style.height = `${height}px`;
|
||||
editorDom.style.maxHeight = `${height}px`; // Ensure it doesn't grow beyond this
|
||||
editorDom.style.overflowY = 'auto';
|
||||
};
|
||||
adjustHeight();
|
||||
window.addEventListener('resize', adjustHeight);
|
||||
|
||||
// Scroll syncing (simplified for now, might need more complex logic)
|
||||
this.editorView.dom.addEventListener('scroll', () => {
|
||||
if (this._isSyncing) return;
|
||||
this._isSyncing = true;
|
||||
|
||||
const editorScrollTop = this.editorView.scrollDOM.scrollTop;
|
||||
const editorScrollHeight = this.editorView.scrollDOM.scrollHeight - this.editorView.scrollDOM.clientHeight;
|
||||
|
||||
if (editorScrollHeight > 0 && this.preview) {
|
||||
const scrollPercent = editorScrollTop / editorScrollHeight;
|
||||
const previewScrollHeight = this.preview.scrollHeight - this.preview.clientHeight;
|
||||
|
||||
if (previewScrollHeight > 0) {
|
||||
this.preview.scrollTop = scrollPercent * previewScrollHeight;
|
||||
}
|
||||
}
|
||||
setTimeout(() => { this._isSyncing = false; }, 50);
|
||||
});
|
||||
|
||||
this.preview.addEventListener('scroll', () => {
|
||||
if (this._isSyncing) return;
|
||||
this._isSyncing = true;
|
||||
|
||||
const previewScrollTop = this.preview.scrollTop;
|
||||
const previewScrollHeight = this.preview.scrollHeight - this.preview.clientHeight;
|
||||
|
||||
if (previewScrollHeight > 0 && this.editorView) {
|
||||
const scrollPercent = previewScrollTop / previewScrollHeight;
|
||||
const editorScrollHeight = this.editorView.scrollDOM.scrollHeight - this.editorView.scrollDOM.clientHeight;
|
||||
|
||||
if (editorScrollHeight > 0) {
|
||||
this.editorView.scrollDOM.scrollTop = scrollPercent * editorScrollHeight;
|
||||
}
|
||||
}
|
||||
setTimeout(() => { this._isSyncing = false; }, 50);
|
||||
});
|
||||
|
||||
// Initial preview update
|
||||
this.updatePreview();
|
||||
}
|
||||
|
||||
stripFrontMatter(markdownContent) {
|
||||
const lines = markdownContent.split('\n');
|
||||
if (lines.length > 0 && lines[0].trim() === '---') {
|
||||
let firstDelimiter = -1;
|
||||
let secondDelimiter = -1;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].trim() === '---') {
|
||||
if (firstDelimiter === -1) {
|
||||
firstDelimiter = i;
|
||||
} else {
|
||||
secondDelimiter = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (firstDelimiter !== -1 && secondDelimiter !== -1) {
|
||||
return lines.slice(secondDelimiter + 1).join('\n');
|
||||
}
|
||||
}
|
||||
return markdownContent;
|
||||
}
|
||||
|
||||
updatePreview() {
|
||||
const content = this.editorView ? this.editorView.state.doc.toString() : this.textarea.value;
|
||||
const contentWithoutFrontMatter = this.stripFrontMatter(content);
|
||||
|
||||
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
|
||||
const html = marked.parse(contentWithoutFrontMatter);
|
||||
// Permettre les attributs HTMX et onclick dans DOMPurify
|
||||
const cleanHtml = DOMPurify.sanitize(html, {
|
||||
ADD_ATTR: ['hx-get', 'hx-target', 'hx-swap', 'onclick']
|
||||
});
|
||||
this.preview.innerHTML = cleanHtml;
|
||||
|
||||
// Traiter les nouveaux éléments HTMX
|
||||
if (typeof htmx !== 'undefined') {
|
||||
htmx.process(this.preview);
|
||||
}
|
||||
|
||||
if (typeof hljs !== 'undefined') {
|
||||
this.preview.querySelectorAll('pre code').forEach(block => {
|
||||
hljs.highlightElement(block);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this.preview.textContent = contentWithoutFrontMatter;
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._updateTimeout) {
|
||||
clearTimeout(this._updateTimeout);
|
||||
this._updateTimeout = null;
|
||||
}
|
||||
if (this._autoSaveTimeout) {
|
||||
clearTimeout(this._autoSaveTimeout);
|
||||
this._autoSaveTimeout = null;
|
||||
}
|
||||
|
||||
if (this.editorView) {
|
||||
this.editorView.destroy(); // Destroy CM6 instance
|
||||
this.editorView = null;
|
||||
}
|
||||
|
||||
// Restore the textarea
|
||||
if (this.textarea) {
|
||||
this.textarea.style.display = '';
|
||||
}
|
||||
|
||||
this.textarea = null;
|
||||
this.preview = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Global instances
|
||||
let currentMarkdownEditor = null;
|
||||
let currentSlashCommands = null; // SlashCommands will need significant refactoring for CM6
|
||||
|
||||
/**
|
||||
* SlashCommands - Système de commandes slash pour l'éditeur CodeMirror 6
|
||||
* Utilise l'API native de CodeMirror 6 pour une meilleure fiabilité
|
||||
*/
|
||||
class SlashCommands {
|
||||
constructor({ editorView }) {
|
||||
this.editorView = editorView;
|
||||
|
||||
if (!this.editorView) {
|
||||
console.error('SlashCommands: EditorView instance required');
|
||||
return;
|
||||
}
|
||||
|
||||
this.active = false;
|
||||
this.query = '';
|
||||
this.selectedIndex = 0;
|
||||
this.palette = null;
|
||||
this.slashPos = null;
|
||||
this._updateListener = null;
|
||||
this._keydownHandler = null;
|
||||
|
||||
this.commands = [
|
||||
{ name: 'h1', snippet: '# ' },
|
||||
{ name: 'h2', snippet: '## ' },
|
||||
{ name: 'h3', snippet: '### ' },
|
||||
{ name: 'list', snippet: '- ' },
|
||||
{ name: 'date', snippet: () => new Date().toLocaleDateString('fr-FR') },
|
||||
{ name: 'link', snippet: '[texte](url)' },
|
||||
{ name: 'bold', snippet: '**texte**' },
|
||||
{ name: 'italic', snippet: '*texte*' },
|
||||
{ name: 'code', snippet: '`code`' },
|
||||
{ name: 'codeblock', snippet: '```\ncode\n```' },
|
||||
{ name: 'quote', snippet: '> ' },
|
||||
{ name: 'hr', snippet: '---' },
|
||||
{ name: 'table', snippet: '| Colonne 1 | Colonne 2 | Colonne 3 |\n|-----------|-----------|-----------|\n| Ligne 1 | Données | Données |\n| Ligne 2 | Données | Données |' },
|
||||
];
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.createPalette();
|
||||
|
||||
// Écouter les événements input pour détecter les changements de texte
|
||||
this._inputHandler = () => {
|
||||
Promise.resolve().then(() => {
|
||||
this.checkForSlashCommand();
|
||||
});
|
||||
};
|
||||
|
||||
// Écouter les changements de sélection
|
||||
this._selectionHandler = () => {
|
||||
Promise.resolve().then(() => {
|
||||
this.checkForSlashCommand();
|
||||
});
|
||||
};
|
||||
|
||||
this.editorView.dom.addEventListener('input', this._inputHandler);
|
||||
this.editorView.dom.addEventListener('selectionchange', this._selectionHandler);
|
||||
|
||||
// Gérer uniquement les touches de navigation quand la palette est active
|
||||
this._keydownHandler = (event) => {
|
||||
if (!this.active) return;
|
||||
|
||||
const filteredCommands = this.getFilteredCommands();
|
||||
if (filteredCommands.length === 0) {
|
||||
this.hidePalette();
|
||||
return;
|
||||
}
|
||||
|
||||
let handled = false;
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
this.selectedIndex = (this.selectedIndex + 1) % filteredCommands.length;
|
||||
this.updatePalette();
|
||||
handled = true;
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
this.selectedIndex = (this.selectedIndex - 1 + filteredCommands.length) % filteredCommands.length;
|
||||
this.updatePalette();
|
||||
handled = true;
|
||||
break;
|
||||
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
this.executeCommand(filteredCommands[this.selectedIndex]);
|
||||
handled = true;
|
||||
break;
|
||||
|
||||
case 'Escape':
|
||||
this.hidePalette();
|
||||
handled = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
// Utiliser la phase de capture pour intercepter avant CodeMirror
|
||||
this.editorView.dom.addEventListener('keydown', this._keydownHandler, true);
|
||||
}
|
||||
|
||||
createPalette() {
|
||||
this.palette = document.createElement('ul');
|
||||
this.palette.id = 'slash-commands-palette';
|
||||
this.palette.style.cssText = `
|
||||
position: fixed;
|
||||
background: #161b22;
|
||||
background-color: #161b22 !important;
|
||||
border: 1px solid #58a6ff;
|
||||
list-style: none;
|
||||
padding: 0.5rem;
|
||||
margin: 0;
|
||||
border-radius: 8px;
|
||||
z-index: 10000;
|
||||
display: none;
|
||||
min-width: 220px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.3), 0 0 20px rgba(88, 166, 255, 0.2);
|
||||
opacity: 1 !important;
|
||||
`;
|
||||
|
||||
document.body.appendChild(this.palette);
|
||||
}
|
||||
|
||||
checkForSlashCommand() {
|
||||
if (!this.editorView) return;
|
||||
|
||||
const { state } = this.editorView;
|
||||
const { from, to } = state.selection.main;
|
||||
|
||||
// Ne pas afficher si une sélection est active
|
||||
if (from !== to) {
|
||||
this.hidePalette();
|
||||
return;
|
||||
}
|
||||
|
||||
const line = state.doc.lineAt(from);
|
||||
const textBeforeCursor = state.doc.sliceString(line.from, from);
|
||||
|
||||
// Chercher le dernier "/" dans la ligne avant le curseur
|
||||
const slashIndex = textBeforeCursor.lastIndexOf('/');
|
||||
|
||||
if (slashIndex === -1) {
|
||||
this.hidePalette();
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérifier que le "/" est au début de la ligne ou après un espace/newline
|
||||
const charBeforeSlash = slashIndex > 0 ? textBeforeCursor[slashIndex - 1] : '';
|
||||
const isValidPosition = slashIndex === 0 || /\s/.test(charBeforeSlash);
|
||||
|
||||
if (!isValidPosition) {
|
||||
this.hidePalette();
|
||||
return;
|
||||
}
|
||||
|
||||
// Extraire la query après le "/"
|
||||
const potentialQuery = textBeforeCursor.substring(slashIndex + 1);
|
||||
|
||||
// Si la query contient un espace ou un newline, cacher la palette
|
||||
if (potentialQuery.includes(' ') || potentialQuery.includes('\n')) {
|
||||
this.hidePalette();
|
||||
return;
|
||||
}
|
||||
|
||||
// Mettre à jour l'état et afficher la palette
|
||||
this.query = potentialQuery;
|
||||
this.slashPos = { lineNumber: line.number, offset: slashIndex, absolutePos: line.from + slashIndex };
|
||||
|
||||
// Réinitialiser l'index sélectionné si la query change
|
||||
if (!this.active) {
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
|
||||
this.showPalette();
|
||||
}
|
||||
|
||||
getFilteredCommands() {
|
||||
if (!this.query) {
|
||||
return this.commands;
|
||||
}
|
||||
return this.commands.filter(cmd =>
|
||||
cmd.name.toLowerCase().includes(this.query.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
showPalette() {
|
||||
const filteredCommands = this.getFilteredCommands();
|
||||
|
||||
if (filteredCommands.length === 0) {
|
||||
this.hidePalette();
|
||||
return;
|
||||
}
|
||||
|
||||
this.active = true;
|
||||
this.updatePalette();
|
||||
this.positionPalette();
|
||||
|
||||
if (this.palette) {
|
||||
this.palette.style.display = 'block';
|
||||
}
|
||||
}
|
||||
|
||||
hidePalette() {
|
||||
this.active = false;
|
||||
this.query = '';
|
||||
this.selectedIndex = 0;
|
||||
this.slashPos = null;
|
||||
|
||||
if (this.palette) {
|
||||
this.palette.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
updatePalette() {
|
||||
if (!this.palette) return;
|
||||
|
||||
const filteredCommands = this.getFilteredCommands();
|
||||
this.palette.innerHTML = '';
|
||||
|
||||
filteredCommands.forEach((cmd, index) => {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `<span style="color: #7d8590; margin-right: 0.5rem;">⌘</span>/${cmd.name}`;
|
||||
|
||||
const isSelected = index === this.selectedIndex;
|
||||
li.style.cssText = `
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
color: ${isSelected ? 'white' : '#e6edf3'};
|
||||
background: ${isSelected ? 'linear-gradient(135deg, #58a6ff, #8b5cf6)' : 'transparent'};
|
||||
border-radius: 4px;
|
||||
margin: 4px 0;
|
||||
transition: all 150ms ease;
|
||||
font-family: 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||
font-size: 0.9rem;
|
||||
font-weight: ${isSelected ? '500' : '400'};
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`;
|
||||
|
||||
li.addEventListener('mouseenter', () => {
|
||||
this.selectedIndex = index;
|
||||
this.updatePalette();
|
||||
});
|
||||
|
||||
li.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.executeCommand(cmd);
|
||||
});
|
||||
|
||||
this.palette.appendChild(li);
|
||||
|
||||
if (isSelected) {
|
||||
li.scrollIntoView({
|
||||
block: 'nearest',
|
||||
behavior: 'auto'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
positionPalette() {
|
||||
if (!this.palette || !this.editorView || !this.slashPos) return;
|
||||
|
||||
// Utiliser requestAnimationFrame pour s'assurer que le DOM est à jour
|
||||
requestAnimationFrame(() => {
|
||||
const { state } = this.editorView;
|
||||
const { from } = state.selection.main;
|
||||
|
||||
// Obtenir les coordonnées de la position du curseur
|
||||
const coords = this.editorView.coordsAtPos(from);
|
||||
|
||||
if (coords) {
|
||||
const { left, bottom } = coords;
|
||||
const paletteHeight = 320; // max-height de la palette
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
// Si pas assez de place en bas, afficher au-dessus
|
||||
const spaceBelow = windowHeight - bottom;
|
||||
const showAbove = spaceBelow < paletteHeight && bottom > paletteHeight;
|
||||
|
||||
this.palette.style.left = `${left}px`;
|
||||
|
||||
if (showAbove) {
|
||||
this.palette.style.top = `${coords.top - paletteHeight - 5}px`;
|
||||
this.palette.style.bottom = 'auto';
|
||||
} else {
|
||||
this.palette.style.top = `${bottom + 5}px`;
|
||||
this.palette.style.bottom = 'auto';
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
executeCommand(command) {
|
||||
if (!command || !this.slashPos) {
|
||||
this.hidePalette();
|
||||
return;
|
||||
}
|
||||
|
||||
let snippet = command.snippet;
|
||||
if (typeof snippet === 'function') {
|
||||
snippet = snippet();
|
||||
}
|
||||
|
||||
const { state, dispatch } = this.editorView;
|
||||
const { from } = state.selection.main;
|
||||
|
||||
// Remplacer depuis le "/" jusqu'au curseur
|
||||
const replaceFrom = this.slashPos.absolutePos;
|
||||
|
||||
dispatch(state.update({
|
||||
changes: { from: replaceFrom, to: from, insert: snippet },
|
||||
selection: { anchor: replaceFrom + snippet.length }
|
||||
}));
|
||||
|
||||
this.editorView.focus();
|
||||
this.hidePalette();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
// Retirer tous les listeners d'événements
|
||||
if (this.editorView) {
|
||||
if (this._keydownHandler) {
|
||||
this.editorView.dom.removeEventListener('keydown', this._keydownHandler, true);
|
||||
this._keydownHandler = null;
|
||||
}
|
||||
if (this._inputHandler) {
|
||||
this.editorView.dom.removeEventListener('input', this._inputHandler);
|
||||
this._inputHandler = null;
|
||||
}
|
||||
if (this._selectionHandler) {
|
||||
this.editorView.dom.removeEventListener('selectionchange', this._selectionHandler);
|
||||
this._selectionHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
this.hidePalette();
|
||||
|
||||
if (this.palette && this.palette.parentNode) {
|
||||
this.palette.parentNode.removeChild(this.palette);
|
||||
}
|
||||
|
||||
this.palette = null;
|
||||
this.editorView = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Global instances
|
||||
window.currentMarkdownEditor = null;
|
||||
window.currentSlashCommands = null; // SlashCommands will need significant refactoring for CM6
|
||||
|
||||
function initializeMarkdownEditor(context) {
|
||||
const scope = context && typeof context.querySelector === 'function' ? context : document;
|
||||
|
||||
const textarea = scope.querySelector('#editor') || document.getElementById('editor');
|
||||
const preview = scope.querySelector('#preview') || document.getElementById('preview');
|
||||
|
||||
if (!textarea || !preview) {
|
||||
console.error('initializeMarkdownEditor: Missing textarea or preview elements');
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.currentMarkdownEditor) {
|
||||
window.currentMarkdownEditor.destroy();
|
||||
window.currentMarkdownEditor = null;
|
||||
}
|
||||
|
||||
if (window.currentSlashCommands) {
|
||||
window.currentSlashCommands.destroy();
|
||||
window.currentSlashCommands = null;
|
||||
}
|
||||
|
||||
const markdownEditor = new MarkdownEditor(textarea, preview);
|
||||
window.currentMarkdownEditor = markdownEditor;
|
||||
|
||||
if (markdownEditor.editorView) {
|
||||
const slashCommands = new SlashCommands({
|
||||
editorView: markdownEditor.editorView
|
||||
});
|
||||
window.currentSlashCommands = slashCommands;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle between editor-only, split, and preview-only modes
|
||||
*/
|
||||
window.togglePreview = function() {
|
||||
const grid = document.getElementById('editor-grid');
|
||||
const editorPanel = document.querySelector('.editor-panel');
|
||||
const preview = document.getElementById('preview');
|
||||
const btn = document.getElementById('toggle-preview-btn');
|
||||
|
||||
if (!grid || !editorPanel || !preview || !btn) {
|
||||
console.error('togglePreview: Missing elements', { grid, editorPanel, preview, btn });
|
||||
return;
|
||||
}
|
||||
|
||||
// Détecter si on est sur mobile
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
|
||||
// Récupérer le mode actuel
|
||||
let currentMode = localStorage.getItem('viewMode') || 'split';
|
||||
|
||||
let newMode;
|
||||
if (isMobile) {
|
||||
// Sur mobile: seulement 2 modes (editor-only <-> preview-only)
|
||||
if (currentMode === 'editor-only') {
|
||||
newMode = 'preview-only';
|
||||
} else {
|
||||
// Depuis preview-only ou split, aller vers editor-only
|
||||
newMode = 'editor-only';
|
||||
}
|
||||
} else {
|
||||
// Desktop: 3 modes (split -> editor-only -> preview-only -> split)
|
||||
if (currentMode === 'split') {
|
||||
newMode = 'editor-only';
|
||||
} else if (currentMode === 'editor-only') {
|
||||
newMode = 'preview-only';
|
||||
} else {
|
||||
newMode = 'split';
|
||||
}
|
||||
}
|
||||
|
||||
// Appliquer le nouveau mode
|
||||
window.applyViewMode(newMode, document);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply view mode (editor-only, split, preview-only)
|
||||
*/
|
||||
window.applyViewMode = function(mode, context) {
|
||||
const scope = context && typeof context.querySelector === 'function' ? context : document;
|
||||
|
||||
const grid = scope.querySelector('#editor-grid');
|
||||
const editorPanel = scope.querySelector('.editor-panel');
|
||||
const preview = scope.querySelector('#preview');
|
||||
const btn = scope.querySelector('#toggle-preview-btn');
|
||||
|
||||
if (!grid || !editorPanel || !preview) {
|
||||
console.error('applyViewMode: Missing essential elements', { grid, editorPanel, preview });
|
||||
return;
|
||||
}
|
||||
|
||||
// Le bouton peut ne pas exister (page d'accueil par exemple)
|
||||
// Ce n'est pas une erreur critique
|
||||
|
||||
// Détecter si on est sur mobile
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
|
||||
// Retirer toutes les classes de mode
|
||||
grid.classList.remove('preview-hidden', 'editor-hidden', 'split-view');
|
||||
editorPanel.classList.remove('hidden');
|
||||
preview.classList.remove('hidden');
|
||||
|
||||
switch (mode) {
|
||||
case 'editor-only':
|
||||
grid.classList.add('preview-hidden');
|
||||
preview.classList.add('hidden');
|
||||
if (btn) {
|
||||
btn.textContent = '◧ Éditeur';
|
||||
btn.title = isMobile
|
||||
? 'Mode: Éditeur → Cliquer pour Preview'
|
||||
: 'Mode: Éditeur seul → Cliquer pour Preview seule';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'preview-only':
|
||||
grid.classList.add('editor-hidden');
|
||||
editorPanel.classList.add('hidden');
|
||||
if (btn) {
|
||||
btn.textContent = '◨ Preview';
|
||||
btn.title = isMobile
|
||||
? 'Mode: Preview → Cliquer pour Éditeur'
|
||||
: 'Mode: Preview seule → Cliquer pour Split';
|
||||
}
|
||||
break;
|
||||
|
||||
case 'split':
|
||||
default:
|
||||
grid.classList.add('split-view');
|
||||
if (btn) {
|
||||
btn.textContent = '◫ Split';
|
||||
btn.title = 'Mode: Split → Cliquer pour Éditeur seul';
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
localStorage.setItem('viewMode', mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore view mode from localStorage
|
||||
*/
|
||||
window.restorePreviewState = function(context) {
|
||||
const viewMode = localStorage.getItem('viewMode') || 'split';
|
||||
window.applyViewMode(viewMode, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialisation automatique quand le DOM est prêt
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Check if editor elements are present on initial load
|
||||
const initialTextarea = document.querySelector('#editor');
|
||||
const initialPreview = document.querySelector('#preview');
|
||||
|
||||
if (initialTextarea && initialPreview) {
|
||||
initializeMarkdownEditor();
|
||||
window.restorePreviewState(document);
|
||||
}
|
||||
|
||||
// Appliquer le mode d'affichage AVANT le swap pour éviter le flash
|
||||
document.body.addEventListener('htmx:beforeSwap', (event) => {
|
||||
const target = event.detail?.target;
|
||||
|
||||
if (target && target.id === 'editor-container') {
|
||||
// Récupérer le contenu qui va être inséré
|
||||
const serverResponse = event.detail.serverResponse;
|
||||
|
||||
if (serverResponse) {
|
||||
// Créer un élément temporaire pour parser la réponse
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = serverResponse;
|
||||
|
||||
// Appliquer les classes au grid AVANT l'insertion
|
||||
const grid = temp.querySelector('#editor-grid');
|
||||
const editorPanel = temp.querySelector('.editor-panel');
|
||||
const preview = temp.querySelector('#preview');
|
||||
const toggleBtn = temp.querySelector('#toggle-preview-btn');
|
||||
|
||||
if (grid && editorPanel && preview) {
|
||||
// Vérifier si c'est la page d'accueil (pas de bouton toggle)
|
||||
const isHomePage = !toggleBtn;
|
||||
|
||||
if (isHomePage) {
|
||||
// Page d'accueil : toujours en mode preview-only, ne rien changer
|
||||
// Le template a déjà les bonnes classes (editor-hidden)
|
||||
return;
|
||||
}
|
||||
|
||||
const viewMode = localStorage.getItem('viewMode') || 'split';
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
|
||||
// Sur mobile, convertir split en preview-only
|
||||
const effectiveMode = (isMobile && viewMode === 'split') ? 'preview-only' : viewMode;
|
||||
|
||||
// Retirer toutes les classes
|
||||
grid.classList.remove('preview-hidden', 'editor-hidden', 'split-view');
|
||||
editorPanel.classList.remove('hidden');
|
||||
preview.classList.remove('hidden');
|
||||
|
||||
// Appliquer le bon mode
|
||||
switch (effectiveMode) {
|
||||
case 'editor-only':
|
||||
grid.classList.add('preview-hidden');
|
||||
preview.classList.add('hidden');
|
||||
break;
|
||||
case 'preview-only':
|
||||
grid.classList.add('editor-hidden');
|
||||
editorPanel.classList.add('hidden');
|
||||
break;
|
||||
case 'split':
|
||||
default:
|
||||
grid.classList.add('split-view');
|
||||
break;
|
||||
}
|
||||
|
||||
// Mettre à jour la réponse serveur avec les bonnes classes
|
||||
event.detail.serverResponse = temp.innerHTML;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener('htmx:afterSwap', (event) => {
|
||||
const target = event.detail?.target;
|
||||
|
||||
// The target is 'editor-container', check if it contains editor elements
|
||||
if (target && target.id === 'editor-container') {
|
||||
// Supprimer le trigger "load" pour éviter qu'il se redéclenche
|
||||
target.removeAttribute('hx-trigger');
|
||||
target.removeAttribute('hx-get');
|
||||
|
||||
const textarea = target.querySelector('#editor');
|
||||
const preview = target.querySelector('#preview');
|
||||
const toggleBtn = target.querySelector('#toggle-preview-btn');
|
||||
|
||||
// Vérifier si c'est la page d'accueil (pas de bouton toggle)
|
||||
const isHomePage = !toggleBtn;
|
||||
|
||||
if (textarea && preview) {
|
||||
initializeMarkdownEditor(target);
|
||||
// Ne pas restaurer le mode pour la page d'accueil (toujours preview-only)
|
||||
if (!isHomePage) {
|
||||
window.restorePreviewState(target);
|
||||
}
|
||||
} else {
|
||||
// If the editor was previously active, destroy it
|
||||
if (window.currentMarkdownEditor) {
|
||||
window.currentMarkdownEditor.destroy();
|
||||
window.currentMarkdownEditor = null;
|
||||
}
|
||||
if (window.currentSlashCommands) {
|
||||
window.currentSlashCommands.destroy();
|
||||
window.currentSlashCommands = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
392
frontend/src/file-tree.js
Normal file
392
frontend/src/file-tree.js
Normal file
@ -0,0 +1,392 @@
|
||||
/**
|
||||
* FileTree - Gère l'arborescence hiérarchique avec drag & drop
|
||||
*/
|
||||
class FileTree {
|
||||
constructor() {
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Écouter les changements htmx dans le file-tree
|
||||
document.body.addEventListener('htmx:afterSwap', (event) => {
|
||||
if (event.detail.target.id === 'file-tree' || event.detail.target.closest('#file-tree')) {
|
||||
this.setupFolderToggles();
|
||||
this.setupDragAndDrop();
|
||||
}
|
||||
});
|
||||
|
||||
// Setup initial si déjà chargé
|
||||
if (document.getElementById('file-tree')) {
|
||||
this.setupFolderToggles();
|
||||
this.setupDragAndDrop();
|
||||
}
|
||||
}
|
||||
|
||||
setupFolderToggles() {
|
||||
const folderHeaders = document.querySelectorAll('.folder-header');
|
||||
|
||||
folderHeaders.forEach(header => {
|
||||
// Éviter d'ajouter plusieurs fois le même listener
|
||||
if (header.dataset.toggleInitialized === 'true') {
|
||||
return;
|
||||
}
|
||||
header.dataset.toggleInitialized = 'true';
|
||||
|
||||
header.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleFolder(header);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
toggleFolder(header) {
|
||||
const folderItem = header.parentElement;
|
||||
const children = folderItem.querySelector('.folder-children');
|
||||
const toggle = header.querySelector('.folder-toggle');
|
||||
const icon = header.querySelector('.folder-icon');
|
||||
|
||||
if (children.style.display === 'none') {
|
||||
// Ouvrir le dossier
|
||||
children.style.display = 'block';
|
||||
toggle.classList.add('expanded');
|
||||
icon.textContent = '📂';
|
||||
} else {
|
||||
// Fermer le dossier
|
||||
children.style.display = 'none';
|
||||
toggle.classList.remove('expanded');
|
||||
icon.textContent = '📁';
|
||||
}
|
||||
}
|
||||
|
||||
setupDragAndDrop() {
|
||||
const fileItems = document.querySelectorAll('.file-item[draggable="true"]');
|
||||
const folderItems = document.querySelectorAll('.folder-item');
|
||||
|
||||
console.log('Setup drag & drop:', {
|
||||
filesCount: fileItems.length,
|
||||
foldersCount: folderItems.length
|
||||
});
|
||||
|
||||
// Setup drag events pour les fichiers
|
||||
fileItems.forEach(file => {
|
||||
file.addEventListener('dragstart', (e) => this.handleDragStart(e));
|
||||
file.addEventListener('dragend', (e) => this.handleDragEnd(e));
|
||||
// Empêcher htmx de gérer le clic pendant le drag
|
||||
file.addEventListener('click', (e) => {
|
||||
if (e.dataTransfer) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}, true);
|
||||
});
|
||||
|
||||
// Setup drop zones pour les dossiers
|
||||
folderItems.forEach(folder => {
|
||||
const header = folder.querySelector('.folder-header');
|
||||
header.addEventListener('dragover', (e) => this.handleDragOver(e));
|
||||
header.addEventListener('dragleave', (e) => this.handleDragLeave(e));
|
||||
header.addEventListener('drop', (e) => this.handleDrop(e));
|
||||
});
|
||||
}
|
||||
|
||||
handleDragStart(e) {
|
||||
const item = e.target;
|
||||
item.classList.add('dragging');
|
||||
|
||||
const path = item.dataset.path;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', path);
|
||||
e.dataTransfer.setData('application/note-path', path);
|
||||
}
|
||||
|
||||
handleDragEnd(e) {
|
||||
const item = e.target;
|
||||
item.classList.remove('dragging');
|
||||
|
||||
// Supprimer les highlights de tous les dossiers
|
||||
document.querySelectorAll('.folder-item.drag-over').forEach(f => {
|
||||
f.classList.remove('drag-over');
|
||||
});
|
||||
}
|
||||
|
||||
handleDragOver(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
|
||||
const folderHeader = e.currentTarget;
|
||||
const folderItem = folderHeader.closest('.folder-item');
|
||||
if (folderItem && !folderItem.classList.contains('drag-over')) {
|
||||
folderItem.classList.add('drag-over');
|
||||
}
|
||||
}
|
||||
|
||||
handleDragLeave(e) {
|
||||
e.stopPropagation();
|
||||
const folderHeader = e.currentTarget;
|
||||
const folderItem = folderHeader.closest('.folder-item');
|
||||
|
||||
// Vérifier que la souris a vraiment quitté le dossier
|
||||
const rect = folderHeader.getBoundingClientRect();
|
||||
if (e.clientX < rect.left || e.clientX >= rect.right ||
|
||||
e.clientY < rect.top || e.clientY >= rect.bottom) {
|
||||
if (folderItem) {
|
||||
folderItem.classList.remove('drag-over');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleDrop(e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const folderHeader = e.currentTarget;
|
||||
const folderItem = folderHeader.closest('.folder-item');
|
||||
folderItem.classList.remove('drag-over');
|
||||
|
||||
const sourcePath = e.dataTransfer.getData('application/note-path') ||
|
||||
e.dataTransfer.getData('text/plain');
|
||||
const targetFolderPath = folderItem.dataset.path;
|
||||
|
||||
console.log('Drop event:', {
|
||||
sourcePath,
|
||||
targetFolderPath,
|
||||
dataTransfer: e.dataTransfer.types,
|
||||
folderItem: folderItem
|
||||
});
|
||||
|
||||
if (!sourcePath || !targetFolderPath) {
|
||||
console.error('Chemins invalides pour le drag & drop', {
|
||||
sourcePath,
|
||||
targetFolderPath,
|
||||
folderItemDataset: folderItem.dataset
|
||||
});
|
||||
alert(`Erreur: source='${sourcePath}', destination='${targetFolderPath}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ne pas déplacer si c'est le même dossier
|
||||
const sourceDir = sourcePath.includes('/') ?
|
||||
sourcePath.substring(0, sourcePath.lastIndexOf('/')) : '';
|
||||
if (sourceDir === targetFolderPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extraire le nom du fichier
|
||||
const fileName = sourcePath.includes('/') ?
|
||||
sourcePath.substring(sourcePath.lastIndexOf('/') + 1) :
|
||||
sourcePath;
|
||||
const destinationPath = targetFolderPath + '/' + fileName;
|
||||
|
||||
this.moveFile(sourcePath, destinationPath);
|
||||
}
|
||||
|
||||
async moveFile(sourcePath, destinationPath) {
|
||||
console.log('moveFile called:', { sourcePath, destinationPath });
|
||||
|
||||
try {
|
||||
const body = new URLSearchParams({
|
||||
source: sourcePath,
|
||||
destination: destinationPath
|
||||
});
|
||||
|
||||
console.log('FormData contents:', {
|
||||
source: body.get('source'),
|
||||
destination: body.get('destination')
|
||||
});
|
||||
|
||||
const response = await fetch('/api/files/move', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || 'Erreur lors du déplacement du fichier');
|
||||
}
|
||||
|
||||
// La réponse contient déjà le file-tree mis à jour avec hx-swap-oob
|
||||
const html = await response.text();
|
||||
|
||||
// Injecter la réponse dans le DOM (htmx le fera automatiquement avec oob)
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = html;
|
||||
|
||||
// Trouver l'élément avec hx-swap-oob
|
||||
const oobElement = temp.querySelector('[hx-swap-oob]');
|
||||
if (oobElement) {
|
||||
const targetId = oobElement.id;
|
||||
const target = document.getElementById(targetId);
|
||||
if (target) {
|
||||
target.innerHTML = oobElement.innerHTML;
|
||||
// Réinitialiser les event listeners
|
||||
this.setupFolderToggles();
|
||||
this.setupDragAndDrop();
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Fichier déplacé: ${sourcePath} -> ${destinationPath}`);
|
||||
} catch (error) {
|
||||
console.error('Erreur lors du déplacement:', error);
|
||||
alert('Erreur lors du déplacement du fichier: ' + error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gestion de la création de notes
|
||||
*/
|
||||
window.showNewNoteModal = function() {
|
||||
const modal = document.getElementById('new-note-modal');
|
||||
modal.style.display = 'flex';
|
||||
setTimeout(() => {
|
||||
document.getElementById('note-name').focus();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
window.hideNewNoteModal = function() {
|
||||
const modal = document.getElementById('new-note-modal');
|
||||
modal.style.display = 'none';
|
||||
document.getElementById('note-name').value = '';
|
||||
}
|
||||
|
||||
window.handleNewNote = function(event) {
|
||||
event.preventDefault();
|
||||
|
||||
let noteName = document.getElementById('note-name').value.trim();
|
||||
|
||||
if (!noteName) {
|
||||
alert('Veuillez entrer un nom de note');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ajouter .md si pas déjà présent
|
||||
if (!noteName.endsWith('.md')) {
|
||||
noteName += '.md';
|
||||
}
|
||||
|
||||
// Valider le nom (pas de caractères dangereux)
|
||||
if (noteName.includes('..') || noteName.includes('\\')) {
|
||||
alert('Nom de note invalide. Évitez les caractères \\ et ..');
|
||||
return;
|
||||
}
|
||||
|
||||
// Fermer la modale
|
||||
window.hideNewNoteModal();
|
||||
|
||||
// Utiliser HTMX pour charger la note (au lieu de fetch manuel)
|
||||
const editorContainer = document.getElementById('editor-container');
|
||||
|
||||
// Supprimer temporairement le trigger "load" pour éviter qu'il se redéclenche
|
||||
editorContainer.removeAttribute('hx-trigger');
|
||||
|
||||
// Utiliser l'API HTMX pour charger la note
|
||||
if (typeof htmx !== 'undefined') {
|
||||
htmx.ajax('GET', `/api/notes/${encodeURIComponent(noteName)}`, {
|
||||
target: '#editor-container',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gestion de la création de dossiers
|
||||
*/
|
||||
window.showNewFolderModal = function() {
|
||||
const modal = document.getElementById('new-folder-modal');
|
||||
modal.style.display = 'flex';
|
||||
setTimeout(() => {
|
||||
document.getElementById('folder-name').focus();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
window.hideNewFolderModal = function() {
|
||||
const modal = document.getElementById('new-folder-modal');
|
||||
modal.style.display = 'none';
|
||||
document.getElementById('folder-name').value = '';
|
||||
}
|
||||
|
||||
window.handleNewFolder = async function(event) {
|
||||
event.preventDefault();
|
||||
|
||||
let folderName = document.getElementById('folder-name').value.trim();
|
||||
|
||||
if (!folderName) {
|
||||
alert('Veuillez entrer un nom de dossier');
|
||||
return;
|
||||
}
|
||||
|
||||
// Valider le nom (pas de caractères dangereux)
|
||||
if (folderName.includes('..') || folderName.includes('\\')) {
|
||||
alert('Nom de dossier invalide. Évitez les caractères \ et ..');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const body = new URLSearchParams({
|
||||
path: folderName
|
||||
});
|
||||
|
||||
const response = await fetch('/api/folders/create', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(errorText || 'Erreur lors de la création du dossier');
|
||||
}
|
||||
|
||||
// La réponse contient déjà le file-tree mis à jour avec hx-swap-oob
|
||||
const html = await response.text();
|
||||
|
||||
// Injecter la réponse dans le DOM
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = html;
|
||||
|
||||
// Trouver l'élément avec hx-swap-oob
|
||||
const oobElement = temp.querySelector('[hx-swap-oob]');
|
||||
if (oobElement) {
|
||||
const targetId = oobElement.id;
|
||||
const target = document.getElementById(targetId);
|
||||
if (target) {
|
||||
target.innerHTML = oobElement.innerHTML;
|
||||
// Réinitialiser les event listeners
|
||||
window.fileTree.setupFolderToggles();
|
||||
window.fileTree.setupDragAndDrop();
|
||||
}
|
||||
}
|
||||
|
||||
window.hideNewFolderModal();
|
||||
} catch (error) {
|
||||
console.error('Erreur lors de la création du dossier:', error);
|
||||
alert('Erreur lors de la création du dossier: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Fermer les modales avec Escape
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
const folderModal = document.getElementById('new-folder-modal');
|
||||
const noteModal = document.getElementById('new-note-modal');
|
||||
|
||||
if (folderModal && folderModal.style.display === 'flex') {
|
||||
window.hideNewFolderModal();
|
||||
}
|
||||
if (noteModal && noteModal.style.display === 'flex') {
|
||||
window.hideNewNoteModal();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Initialisation automatique
|
||||
*/
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.fileTree = new FileTree();
|
||||
});
|
||||
4
frontend/src/main.js
Normal file
4
frontend/src/main.js
Normal file
@ -0,0 +1,4 @@
|
||||
import './editor.js';
|
||||
import './file-tree.js';
|
||||
import './ui.js';
|
||||
import './search.js';
|
||||
416
frontend/src/search.js
Normal file
416
frontend/src/search.js
Normal file
@ -0,0 +1,416 @@
|
||||
/**
|
||||
* 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">💡 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">💡 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">🔍</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">⚠️</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 };
|
||||
111
frontend/src/ui.js
Normal file
111
frontend/src/ui.js
Normal file
@ -0,0 +1,111 @@
|
||||
// Fonction pour détecter si on est sur mobile
|
||||
function isMobileDevice() {
|
||||
return window.innerWidth <= 768;
|
||||
}
|
||||
|
||||
// Fonction globale pour toggle la sidebar (appelée par le bouton et l'overlay)
|
||||
window.toggleSidebar = function() {
|
||||
const aside = document.querySelector('aside');
|
||||
const overlay = document.querySelector('.sidebar-overlay');
|
||||
|
||||
if (!aside) {
|
||||
console.error('Aside element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const isMobile = isMobileDevice();
|
||||
|
||||
if (isMobile) {
|
||||
// Sur mobile : toggle la classe sidebar-visible
|
||||
const isCurrentlyVisible = aside.classList.contains('sidebar-visible');
|
||||
|
||||
if (isCurrentlyVisible) {
|
||||
// Fermer
|
||||
aside.classList.remove('sidebar-visible');
|
||||
if (overlay) overlay.classList.remove('active');
|
||||
} else {
|
||||
// Ouvrir
|
||||
aside.classList.add('sidebar-visible');
|
||||
if (overlay) overlay.classList.add('active');
|
||||
}
|
||||
} else {
|
||||
// Sur desktop : toggle sidebar-hidden sur le layout
|
||||
const mainLayout = document.querySelector('.main-layout');
|
||||
if (mainLayout) {
|
||||
mainLayout.classList.toggle('sidebar-hidden');
|
||||
const isHidden = mainLayout.classList.contains('sidebar-hidden');
|
||||
localStorage.setItem('sidebarVisible', !isHidden);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Appliquer le mode preview-only par défaut sur mobile
|
||||
function applyMobileDefaults() {
|
||||
const isMobile = isMobileDevice();
|
||||
|
||||
if (isMobile) {
|
||||
// Sur mobile : mode preview-only par défaut SEULEMENT si aucun mode n'est défini ou si c'est split
|
||||
const viewMode = localStorage.getItem('viewMode');
|
||||
if (!viewMode || viewMode === 'split') {
|
||||
localStorage.setItem('viewMode', 'preview-only');
|
||||
}
|
||||
// Si l'utilisateur a choisi 'editor-only' ou 'preview-only', on le respecte
|
||||
|
||||
// S'assurer que la sidebar est masquée
|
||||
const aside = document.querySelector('aside');
|
||||
if (aside) {
|
||||
aside.classList.remove('sidebar-visible');
|
||||
}
|
||||
const overlay = document.querySelector('.sidebar-overlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const toggleBtn = document.getElementById('toggle-sidebar-btn');
|
||||
const mainLayout = document.querySelector('.main-layout');
|
||||
|
||||
// Appliquer les paramètres par défaut pour mobile
|
||||
applyMobileDefaults();
|
||||
|
||||
if (toggleBtn && mainLayout) {
|
||||
const isMobile = isMobileDevice();
|
||||
|
||||
if (!isMobile) {
|
||||
// Desktop : restaurer l'état depuis localStorage
|
||||
if (localStorage.getItem('sidebarVisible') === 'false') {
|
||||
mainLayout.classList.add('sidebar-hidden');
|
||||
}
|
||||
}
|
||||
|
||||
toggleBtn.addEventListener('click', toggleSidebar);
|
||||
}
|
||||
|
||||
// Réappliquer les paramètres mobile lors du resize
|
||||
let resizeTimer;
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
applyMobileDefaults();
|
||||
}, 250);
|
||||
});
|
||||
|
||||
// Fermer automatiquement la sidebar sur mobile après sélection d'une note
|
||||
document.body.addEventListener('htmx:afterSwap', (event) => {
|
||||
// Si c'est un swap sur l'editor-container (ouverture d'une note)
|
||||
if (event.detail.target.id === 'editor-container' || event.detail.target.id === 'main-content') {
|
||||
const isMobile = isMobileDevice();
|
||||
if (isMobile) {
|
||||
const aside = document.querySelector('aside');
|
||||
const overlay = document.querySelector('.sidebar-overlay');
|
||||
|
||||
if (aside && aside.classList.contains('sidebar-visible')) {
|
||||
aside.classList.remove('sidebar-visible');
|
||||
if (overlay) overlay.classList.remove('active');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user