@@ -1619,6 +1641,169 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
}, 300);
};
+ // ---- Autocomplete (Ctrl+Space or smart trigger) ----
+ var acDropdown = document.getElementById('ac-dropdown');
+ var acItems = [];
+ var acIdx = -1;
+
+ function showAutocomplete(items, context) {
+ acItems = items;
+ acIdx = -1;
+ if (!items.length) { hideAutocomplete(); return; }
+ // Position near cursor
+ var pos = getCaretCoordinates(ta);
+ var rect = ta.getBoundingClientRect();
+ acDropdown.style.left = (rect.left + pos.left - ta.scrollLeft + 20) + 'px';
+ acDropdown.style.top = (rect.top + pos.top - ta.scrollTop + 24) + 'px';
+
+ var html = context ? '
' + context + '
' : '';
+ items.forEach(function(item, i) {
+ html += '
' +
+ '' + escHtml(item.label) + '' +
+ (item.detail ? '' + escHtml(item.detail) + '' : '') +
+ '
';
+ });
+ acDropdown.innerHTML = html;
+ acDropdown.classList.add('active');
+ acIdx = 0;
+ highlightAcItem();
+ // Wire clicks
+ acDropdown.querySelectorAll('.ac-item').forEach(function(el) {
+ el.addEventListener('mousedown', function(e) {
+ e.preventDefault();
+ var idx = parseInt(el.dataset.idx);
+ if (!isNaN(idx) && acItems[idx]) applyAutocomplete(acItems[idx]);
+ });
+ });
+ }
+
+ function hideAutocomplete() {
+ acDropdown.classList.remove('active');
+ acItems = [];
+ acIdx = -1;
+ }
+
+ function highlightAcItem() {
+ acDropdown.querySelectorAll('.ac-item').forEach(function(el, i) {
+ el.classList.toggle('active', i === acIdx);
+ });
+ if (acIdx >= 0) {
+ var active = acDropdown.querySelector('.ac-item.active');
+ if (active) active.scrollIntoView({ block: 'nearest' });
+ }
+ }
+
+ function applyAutocomplete(item) {
+ if (!item || !item.insert) return;
+ var start = ta.selectionStart;
+ var end = ta.selectionEnd;
+ var before = ta.value.slice(0, start);
+ var after = ta.value.slice(end);
+
+ // Smart insertion: if we matched a prefix, replace it
+ var lineStart = before.lastIndexOf('\n') + 1;
+ var lineBefore = before.slice(lineStart);
+ var ctx = detectContext(ta.value, start);
+
+ if (ctx.type === 'wikilink') {
+ // Replace [[partial with [[filename]]
+ var bracketPos = before.lastIndexOf('[[');
+ if (bracketPos >= 0) {
+ before = before.slice(0, bracketPos) + '[[' + item.insert;
+ ta.value = before + after;
+ var newPos = before.length;
+ ta.setSelectionRange(newPos, newPos);
+ }
+ } else if (ctx.type === 'frontmatter') {
+ // Replace current line with the field
+ before = before.slice(0, lineStart) + item.insert;
+ ta.value = before + after;
+ ta.setSelectionRange(before.length, before.length);
+ } else {
+ // Standard insert at cursor
+ ta.value = before + item.insert + after;
+ var newPos = start + item.insert.length;
+ ta.setSelectionRange(newPos, newPos);
+ }
+
+ hideAutocomplete();
+ ta.focus();
+ markDirty();
+ autoHeight();
+ updateLineNumbers();
+ }
+
+ function triggerAutocomplete() {
+ var result = suggest(ta.value, ta.selectionStart);
+ if (result.async) {
+ // Wikilink: fetch file list
+ import('/static/js/autocomplete.js').then(function(m) {
+ m.searchWikilinks(result.partial || '', function(items) {
+ showAutocomplete(items, 'Wikilinks');
+ });
+ });
+ } else if (result.items.length > 0) {
+ var ctxLabels = {
+ mermaid: 'Mermaid', code: 'Code', fence_open: 'Langage',
+ frontmatter: 'Frontmatter', markdown: 'Markdown'
+ };
+ showAutocomplete(result.items, ctxLabels[result.context] || result.context);
+ }
+ }
+
+ function getCaretCoordinates(textarea) {
+ var div = document.createElement('div');
+ var style = div.style;
+ var computed = window.getComputedStyle(textarea);
+ for (var key of computed) {
+ try { style[key] = computed[key]; } catch(e) {}
+ }
+ style.position = 'absolute';
+ style.visibility = 'hidden';
+ style.whiteSpace = 'pre-wrap';
+ style.overflow = 'hidden';
+ style.width = textarea.clientWidth + 'px';
+ style.height = 'auto';
+ var pos = textarea.selectionStart;
+ div.textContent = textarea.value.slice(0, pos) + '.';
+ document.body.appendChild(div);
+ var rect = div.getBoundingClientRect();
+ document.body.removeChild(div);
+ return { left: rect.width, top: rect.height };
+ }
+
+ ta.addEventListener('keydown', function(e) {
+ if (acDropdown.classList.contains('active')) {
+ if (e.key === 'ArrowDown') { e.preventDefault(); acIdx = Math.min(acIdx + 1, acItems.length - 1); highlightAcItem(); }
+ else if (e.key === 'ArrowUp') { e.preventDefault(); acIdx = Math.max(acIdx - 1, 0); highlightAcItem(); }
+ else if (e.key === 'Enter' || e.key === 'Tab') {
+ e.preventDefault();
+ if (acIdx >= 0 && acItems[acIdx]) applyAutocomplete(acItems[acIdx]);
+ }
+ else if (e.key === 'Escape') { e.preventDefault(); hideAutocomplete(); }
+ return;
+ }
+ // Ctrl+Space: force autocomplete
+ if ((e.ctrlKey || e.metaKey) && e.key === ' ') {
+ e.preventDefault();
+ triggerAutocomplete();
+ }
+ // Auto-trigger on [[ for wikilinks
+ if (e.key === '[' && ta.selectionStart > 0 && ta.value[ta.selectionStart - 1] === '[') {
+ setTimeout(triggerAutocomplete, 50);
+ }
+ });
+
+ // Trigger on triple backtick
+ ta.addEventListener('input', function() {
+ var before = ta.value.slice(0, ta.selectionStart);
+ if (before.endsWith('``')) {
+ setTimeout(triggerAutocomplete, 50);
+ }
+ });
+
+ function escHtml(s) { return String(s).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); }
+
// ---- Init ----
updateLineNumbers();
diff --git a/frontend/js/autocomplete.js b/frontend/js/autocomplete.js
new file mode 100644
index 0000000..d8c6525
--- /dev/null
+++ b/frontend/js/autocomplete.js
@@ -0,0 +1,350 @@
+/* ObsiGate — Autocomplete intelligent
+ * Partagé entre éditeur Forge (textarea) et CodeMirror.
+ * Détecte le contexte autour du curseur et suggère des complétions.
+ *
+ * Contexte supporté : mermaid, code fence, frontmatter, markdown, wikilink, table, texte libre.
+ *
+ * API :
+ * suggest(text, cursorPos) → { context, items: [{label, detail, insert, from, to}] }
+ * CompletionSource pour CodeMirror : codeMirrorCompletionSource(context)
+ */
+
+// ── Suggestion Data ──────────────────────────────────────────────────
+var MERMAID_TYPES = [
+ { label: 'flowchart TD', detail: 'Diagramme de flux (top-down)', insert: 'flowchart TD\n ' },
+ { label: 'flowchart LR', detail: 'Diagramme de flux (gauche-droite)', insert: 'flowchart LR\n ' },
+ { label: 'sequenceDiagram', detail: 'Sequence (acteurs + messages)', insert: 'sequenceDiagram\n ' },
+ { label: 'classDiagram', detail: 'Diagramme de classes UML', insert: 'classDiagram\n ' },
+ { label: 'stateDiagram-v2', detail: 'Diagramme d\'etats', insert: 'stateDiagram-v2\n ' },
+ { label: 'erDiagram', detail: 'Diagramme Entite-Relation', insert: 'erDiagram\n ' },
+ { label: 'journey', detail: 'Parcours utilisateur', insert: 'journey\n ' },
+ { label: 'gantt', detail: 'Diagramme de Gantt', insert: 'gantt\n ' },
+ { label: 'pie', detail: 'Diagramme circulaire', insert: 'pie\n ' },
+ { label: 'gitGraph', detail: 'Historique Git', insert: 'gitGraph\n ' },
+ { label: 'mindmap', detail: 'Carte mentale', insert: 'mindmap\n ' },
+ { label: 'timeline', detail: 'Ligne du temps', insert: 'timeline\n ' },
+ { label: 'quadrantChart', detail: 'Graphique quadrants', insert: 'quadrantChart\n ' },
+ { label: 'xychart-beta', detail: 'Graphique XY', insert: 'xychart-beta\n ' },
+ { label: 'block-beta', detail: 'Diagramme de blocs', insert: 'block-beta\n ' }
+];
+
+var MERMAID_SYNTAX = {
+ 'flowchart': [
+ { label: 'A-->B', detail: 'Fleche', insert: '-->' },
+ { label: 'A--text-->B', detail: 'Fleche avec texte', insert: '-- texte -->' },
+ { label: 'A-.->B', detail: 'Fleche pointillee', insert: '-.->' },
+ { label: 'A==>B', detail: 'Fleche epaisse', insert: '==>' },
+ { label: 'subgraph', detail: 'Sous-graphe', insert: 'subgraph titre\n \nend' },
+ { label: 'style A fill:#xxx', detail: 'Style de noeud', insert: 'style A fill:#333,stroke:#58a6ff' },
+ { label: 'classDef', detail: 'Classe CSS', insert: 'classDef default fill:#333,stroke:#58a6ff' }
+ ],
+ 'sequenceDiagram': [
+ { label: 'participant X', detail: 'Declarer acteur', insert: 'participant Nom' },
+ { label: 'A->>B: msg', detail: 'Message', insert: '->> : ' },
+ { label: 'A-->>B: msg', detail: 'Message asynchrone', insert: '-->> : ' },
+ { label: 'Note over A,B', detail: 'Note', insert: 'Note over A,B: ' },
+ { label: 'loop text', detail: 'Boucle', insert: 'loop description\n \nend' },
+ { label: 'alt/else', detail: 'Alternative', insert: 'alt condition\n \nelse\n \nend' },
+ { label: 'activate A', detail: 'Activer ligne de vie', insert: 'activate A' }
+ ],
+ 'classDiagram': [
+ { label: 'class Nom {', detail: 'Declarer classe', insert: 'class Nom {\n \n}' },
+ { label: '+methode()', detail: 'Methode publique', insert: '+nom() Type' },
+ { label: '-attribut', detail: 'Attribut prive', insert: '-nom Type' },
+ { label: 'Heritage', detail: 'Relation heritage', insert: ' <|-- ' },
+ { label: 'Composition', detail: 'Relation composition', insert: ' *-- ' }
+ ],
+ 'gantt': [
+ { label: 'dateFormat', detail: 'Format de date', insert: 'dateFormat YYYY-MM-DD' },
+ { label: 'section', detail: 'Section Gantt', insert: 'section Nom' },
+ { label: 'tache : apres', detail: 'Tache avec dependance', insert: ' : after tache1, 3d' }
+ ],
+ 'pie': [
+ { label: 'title', detail: 'Titre du pie', insert: 'title Titre du graphique' },
+ { label: '"Label" : val', detail: 'Valeur', insert: '"Label" : 50' }
+ ],
+ 'mindmap': [
+ { label: 'root', detail: 'Racine', insert: 'root((Sujet))' },
+ { label: 'branche', detail: 'Sous-sujet', insert: '\n Sous-sujet' },
+ { label: 'leaf', detail: 'Feuille', insert: '\n Detail' }
+ ]
+};
+
+var CODE_LANGUAGES = [
+ { label: 'python', detail: 'Python', insert: 'python' },
+ { label: 'javascript', detail: 'JavaScript', insert: 'javascript' },
+ { label: 'typescript', detail: 'TypeScript', insert: 'typescript' },
+ { label: 'bash', detail: 'Bash / Shell', insert: 'bash' },
+ { label: 'sql', detail: 'SQL', insert: 'sql' },
+ { label: 'json', detail: 'JSON', insert: 'json' },
+ { label: 'yaml', detail: 'YAML', insert: 'yaml' },
+ { label: 'html', detail: 'HTML', insert: 'html' },
+ { label: 'css', detail: 'CSS', insert: 'css' },
+ { label: 'dockerfile', detail: 'Dockerfile', insert: 'dockerfile' },
+ { label: 'mermaid', detail: 'Mermaid (diagrammes)', insert: 'mermaid' },
+ { label: 'markdown', detail: 'Markdown', insert: 'markdown' },
+ { label: 'rust', detail: 'Rust', insert: 'rust' },
+ { label: 'go', detail: 'Go', insert: 'go' },
+ { label: 'java', detail: 'Java', insert: 'java' }
+];
+
+var CODE_SNIPPETS = {
+ python: [
+ { label: 'def', detail: 'Fonction', insert: 'def nom(params):\n ' },
+ { label: 'class', detail: 'Classe', insert: 'class Nom:\n def __init__(self):\n ' },
+ { label: 'if/else', detail: 'Condition', insert: 'if condition:\n \nelse:\n ' },
+ { label: 'for', detail: 'Boucle for', insert: 'for item in iterable:\n ' },
+ { label: 'try/except', detail: 'Try-except', insert: 'try:\n \nexcept Exception as e:\n ' },
+ { label: 'import', detail: 'Import', insert: 'import ' },
+ { label: 'print', detail: 'Print', insert: 'print()' },
+ { label: 'lambda', detail: 'Lambda', insert: 'lambda x: ' }
+ ],
+ javascript: [
+ { label: 'function', detail: 'Fonction', insert: 'function nom(params) {\n \n}' },
+ { label: 'const', detail: 'Constante', insert: 'const nom = ' },
+ { label: '=>', detail: 'Fleche', insert: '(params) => {\n \n}' },
+ { label: 'if/else', detail: 'Condition', insert: 'if (cond) {\n \n} else {\n \n}' },
+ { label: 'for', detail: 'Boucle for', insert: 'for (let i = 0; i < n; i++) {\n \n}' },
+ { label: 'try/catch', detail: 'Try-catch', insert: 'try {\n \n} catch (e) {\n \n}' },
+ { label: 'console.log', detail: 'Console log', insert: 'console.log()' },
+ { label: 'async/await', detail: 'Async function', insert: 'async function nom() {\n await \n}' }
+ ],
+ bash: [
+ { label: '#!/bin/bash', detail: 'Shebang', insert: '#!/bin/bash' },
+ { label: 'if/fi', detail: 'Condition', insert: 'if [ condition ]; then\n \nfi' },
+ { label: 'for', detail: 'Boucle for', insert: 'for item in list; do\n \ndone' },
+ { label: 'case/esac', detail: 'Case', insert: 'case var in\n pattern)\n ;;\nesac' },
+ { label: 'function', detail: 'Fonction', insert: 'nom() {\n \n}' }
+ ],
+ sql: [
+ { label: 'SELECT', detail: 'Select', insert: 'SELECT colonnes\nFROM table' },
+ { label: 'JOIN', detail: 'Join', insert: 'JOIN table2 ON table1.id = table2.id' },
+ { label: 'WHERE', detail: 'Where', insert: 'WHERE condition' },
+ { label: 'GROUP BY', detail: 'Group by', insert: 'GROUP BY colonne' },
+ { label: 'ORDER BY', detail: 'Order by', insert: 'ORDER BY colonne DESC' },
+ { label: 'INSERT INTO', detail: 'Insert', insert: 'INSERT INTO table (cols) VALUES (vals)' },
+ { label: 'CREATE TABLE', detail: 'Create table', insert: 'CREATE TABLE nom (\n id INTEGER PRIMARY KEY,\n \n)' }
+ ]
+};
+
+var FRONTMATTER_FIELDS = [
+ { label: 'title:', detail: 'Titre du document', insert: 'title: ' },
+ { label: 'tags:', detail: 'Tags (liste)', insert: 'tags:\n - ' },
+ { label: 'date:', detail: 'Date', insert: 'date: ' + new Date().toISOString().slice(0,10) },
+ { label: 'status:', detail: 'Statut', insert: 'status: en cours' },
+ { label: 'category:', detail: 'Categorie', insert: 'category: ' },
+ { label: 'publish:', detail: 'Publier (true/false)', insert: 'publish: true' },
+ { label: 'favoris:', detail: 'Favoris (true/false)', insert: 'favoris: true' },
+ { label: 'author:', detail: 'Auteur', insert: 'author: ' },
+ { label: 'description:', detail: 'Description', insert: 'description: ' }
+];
+
+var MARKDOWN_FORMAT = [
+ { label: '# H1', detail: 'Titre niveau 1', insert: '# ' },
+ { label: '## H2', detail: 'Titre niveau 2', insert: '## ' },
+ { label: '### H3', detail: 'Titre niveau 3', insert: '### ' },
+ { label: '**gras**', detail: 'Texte en gras', insert: '****' },
+ { label: '*italique*', detail: 'Texte en italique', insert: '**' },
+ { label: '`code`', detail: 'Code inline', insert: '``' },
+ { label: '[lien](url)', detail: 'Lien', insert: '[]()' },
+ { label: '', detail: 'Image', insert: '![]()' },
+ { label: '- liste', detail: 'Liste a puces', insert: '- ' },
+ { label: '1. liste', detail: 'Liste numerotee', insert: '1. ' },
+ { label: '> citation', detail: 'Citation', insert: '> ' },
+ { label: '---', detail: 'Separateur horizontal', insert: '\n---\n' },
+ { label: '> [!note]', detail: 'Callout Note', insert: '> [!note] Titre\n> Contenu' },
+ { label: '> [!warning]', detail: 'Callout Warning', insert: '> [!warning] Titre\n> Contenu' },
+ { label: '> [!tip]', detail: 'Callout Tip', insert: '> [!tip] Titre\n> Contenu' },
+ { label: '> [!danger]', detail: 'Callout Danger', insert: '> [!danger] Titre\n> Contenu' }
+];
+
+// ── Context Detection ─────────────────────────────────────────────────
+function detectContext(text, cursorPos) {
+ var before = text.slice(0, cursorPos);
+ var after = text.slice(cursorPos);
+ var lines = before.split('\n');
+ var currentLine = lines[lines.length - 1] || '';
+ var lineNum = lines.length;
+
+ // Check for code fence context (look backwards for unclosed ```)
+ var inFence = null;
+ var fenceLang = '';
+ var fenceStart = 0;
+ for (var i = lines.length - 1; i >= 0; i--) {
+ var l = lines[i].trim();
+ var m = l.match(/^```(\w*)/);
+ if (m) {
+ if (inFence === null) {
+ inFence = true;
+ fenceLang = m[1] || '';
+ fenceStart = i + 1;
+ } else {
+ inFence = false;
+ break;
+ }
+ }
+ }
+
+ if (inFence) {
+ if (fenceLang === 'mermaid') return { type: 'mermaid', lang: '', line: currentLine };
+ return { type: 'code', lang: fenceLang, line: currentLine };
+ }
+
+ // Check for frontmatter context (between first and second ---)
+ if (lineNum >= 2) {
+ var firstSep = -1, secondSep = -1;
+ for (var j = 0; j < lines.length; j++) {
+ if (lines[j].trim() === '---') {
+ if (firstSep === -1) firstSep = j;
+ else if (secondSep === -1) { secondSep = j; break; }
+ }
+ }
+ if (firstSep === 0 && secondSep === -1) {
+ return { type: 'frontmatter', line: currentLine };
+ }
+ }
+
+ // Check for wikilink
+ if (/\[\[$/.test(currentLine) || /\[\[[^\]]*$/.test(currentLine)) {
+ var partial = (currentLine.match(/\[\[([^\]]*)$/) || ['',''])[1];
+ return { type: 'wikilink', partial: partial, line: currentLine };
+ }
+
+ // Check for markdown link
+ if (/\[.*\]\($/.test(currentLine)) {
+ return { type: 'link', line: currentLine };
+ }
+
+ // Check for triple backtick (about to open fence)
+ if (/```$/.test(before) || /^```\s*$/.test(currentLine)) {
+ return { type: 'fence_open', line: currentLine };
+ }
+
+ // Check if inside a markdown table
+ if (/^\|.*\|$/.test(currentLine.trim())) {
+ return { type: 'table', line: currentLine };
+ }
+
+ // Plain text / markdown
+ return { type: 'markdown', line: currentLine };
+}
+
+// ── Suggestion Engine ─────────────────────────────────────────────────
+function suggest(text, cursorPos) {
+ var ctx = detectContext(text, cursorPos);
+ var items = [];
+ var prefix = '';
+
+ var currentLine = ctx.line;
+
+ switch (ctx.type) {
+ case 'mermaid':
+ // Try to match mermaid type already declared
+ var mermaidMatch = text.match(/```mermaid\s*\n(\w+)/);
+ if (mermaidMatch) {
+ var mmType = mermaidMatch[1];
+ items = MERMAID_SYNTAX[mmType] || [];
+ }
+ if (items.length === 0) {
+ items = MERMAID_TYPES;
+ }
+ break;
+
+ case 'code':
+ items = CODE_SNIPPETS[ctx.lang] || [];
+ break;
+
+ case 'fence_open':
+ items = CODE_LANGUAGES;
+ break;
+
+ case 'frontmatter':
+ prefix = currentLine.trim();
+ items = FRONTMATTER_FIELDS.filter(function(f) {
+ return f.label.startsWith(prefix) || prefix === '';
+ }).map(function(f) {
+ return { label: f.label, detail: f.detail, insert: prefix ? f.insert : f.insert };
+ });
+ return {
+ context: ctx.type,
+ items: items.length > 0 ? items : FRONTMATTER_FIELDS.map(function(f) {
+ return { label: f.label, detail: f.detail, insert: f.insert };
+ })
+ };
+
+ case 'wikilink':
+ return {
+ context: 'wikilink',
+ items: [],
+ partial: ctx.partial,
+ async: true // requires file index lookup
+ };
+
+ case 'markdown':
+ default:
+ // At start of line: markdown formatting
+ if (currentLine.trim().length < 3) {
+ items = MARKDOWN_FORMAT;
+ } else {
+ items = [];
+ }
+ break;
+ }
+
+ var filtered = items.filter(function(item) {
+ return !prefix || item.label.toLowerCase().indexOf(prefix.toLowerCase()) !== -1;
+ });
+
+ return {
+ context: ctx.type,
+ items: filtered.length > 0 ? filtered : items
+ };
+}
+
+// ── Wikilink async completion (fetches file list from server) ─────────
+var _fileCache = null;
+var _fileCacheTime = 0;
+
+function searchWikilinks(partial, callback) {
+ var now = Date.now();
+ // Use cached file list if fresh (< 30s)
+ if (_fileCache && (now - _fileCacheTime) < 30000) {
+ callback(filterFiles(partial));
+ return;
+ }
+
+ try {
+ fetch('/api/search/advanced?q=*&vault=all&limit=500')
+ .then(function(r) { return r.json(); })
+ .then(function(data) {
+ _fileCache = (data.results || []).map(function(r) {
+ return {
+ path: r.path,
+ title: r.title || r.path.replace(/\.md$/i, ''),
+ vault: r.vault,
+ name: r.path.split('/').pop().replace(/\.md$/i, '')
+ };
+ });
+ _fileCacheTime = now;
+ callback(filterFiles(partial));
+ })
+ .catch(function() { callback([]); });
+ } catch(e) { callback([]); }
+}
+
+function filterFiles(partial) {
+ if (!_fileCache) return [];
+ var q = (partial || '').toLowerCase();
+ return _fileCache.filter(function(f) {
+ return f.name.toLowerCase().indexOf(q) !== -1 || f.path.toLowerCase().indexOf(q) !== -1;
+ }).slice(0, 20).map(function(f) {
+ return {
+ label: f.name,
+ detail: f.path,
+ insert: f.path + ']]',
+ vault: f.vault
+ };
+ });
+}
+
+export { suggest, searchWikilinks, detectContext, MERMAID_TYPES, CODE_LANGUAGES, FRONTMATTER_FIELDS, MARKDOWN_FORMAT };