1023 lines
36 KiB
JavaScript
1023 lines
36 KiB
JavaScript
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';
|
|
import { LinkInserter } from './link-inserter.js';
|
|
|
|
// Import du mode Vim
|
|
let vimExtension = null;
|
|
(async () => {
|
|
try {
|
|
const { vim } = await import('@replit/codemirror-vim');
|
|
vimExtension = vim;
|
|
console.log('✅ Vim extension loaded and ready');
|
|
} catch (error) {
|
|
console.warn('⚠️ Vim extension not available:', error.message);
|
|
}
|
|
})();
|
|
|
|
/**
|
|
* 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 l'éditeur (avec ou sans Vim)
|
|
this.initEditor();
|
|
}
|
|
|
|
getExtensions() {
|
|
const 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...';
|
|
}
|
|
// Synchroniser le contenu de CodeMirror vers le textarea
|
|
this.syncToTextarea();
|
|
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) {
|
|
// Synchroniser le contenu de CodeMirror vers le textarea
|
|
this.syncToTextarea();
|
|
form.requestSubmit();
|
|
}
|
|
return true;
|
|
}
|
|
}])
|
|
];
|
|
|
|
// Ajouter l'extension Vim si activée et disponible
|
|
if (window.vimModeManager && window.vimModeManager.isEnabled()) {
|
|
if (vimExtension) {
|
|
extensions.push(vimExtension());
|
|
console.log('✅ Vim mode enabled in editor');
|
|
} else {
|
|
console.warn('⚠️ Vim mode requested but extension not loaded yet');
|
|
}
|
|
}
|
|
|
|
return extensions;
|
|
}
|
|
|
|
initEditor() {
|
|
const currentContent = this.editorView
|
|
? this.editorView.state.doc.toString()
|
|
: this.textarea.value;
|
|
|
|
const extensions = this.getExtensions();
|
|
|
|
// Détruire l'ancien éditeur si il existe
|
|
if (this.editorView) {
|
|
this.editorView.destroy();
|
|
}
|
|
|
|
// Initialiser CodeMirror 6
|
|
const startState = EditorState.create({
|
|
doc: currentContent,
|
|
extensions
|
|
});
|
|
|
|
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();
|
|
|
|
// Initialiser les SlashCommands si ce n'est pas déjà fait
|
|
if (this.editorView && !window.currentSlashCommands) {
|
|
window.currentSlashCommands = new SlashCommands({
|
|
editorView: this.editorView
|
|
});
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// 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);
|
|
});
|
|
}
|
|
} else {
|
|
this.preview.textContent = contentWithoutFrontMatter;
|
|
}
|
|
}
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
async reloadWithVimMode() {
|
|
console.log('Reloading editor with Vim mode...');
|
|
await this.initEditor();
|
|
}
|
|
}
|
|
|
|
// 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: 'ilink', isModal: true, handler: () => this.openLinkInserter() },
|
|
{ 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: var(--bg-secondary);
|
|
background-color: var(--bg-secondary) !important;
|
|
border: 1px solid var(--border-primary);
|
|
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);
|
|
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: var(--text-muted); margin-right: 0.5rem;">⌘</span>/${cmd.name}`;
|
|
|
|
const isSelected = index === this.selectedIndex;
|
|
li.style.cssText = `
|
|
padding: 0.5rem 0.75rem;
|
|
cursor: pointer;
|
|
color: ${isSelected ? 'var(--text-primary)' : 'var(--text-secondary)'};
|
|
background: ${isSelected ? 'var(--accent-primary)' : '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;
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
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) {
|
|
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;
|
|
|
|
// Note: SlashCommands sera créé automatiquement dans initEditor() qui est async
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|