diff --git a/frontend/editor-poc.html b/frontend/editor-poc.html index 009dc01..69638bc 100644 --- a/frontend/editor-poc.html +++ b/frontend/editor-poc.html @@ -215,10 +215,32 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig /* Resize handle between panes */ .resize-handle { width: 4px; cursor: col-resize; background: var(--border); flex-shrink: 0; transition: background 150ms; } .resize-handle:hover { background: var(--accent); } + +/* Autocomplete dropdown */ +.ac-dropdown { + position: absolute; z-index: 200; + background: var(--bg2); border: 1px solid var(--border-md); + border-radius: 8px; box-shadow: 0 8px 30px rgba(0,0,0,0.5); + min-width: 220px; max-width: 380px; max-height: 260px; + overflow-y: auto; display: none; padding: 4px; +} +.ac-dropdown.active { display: block; } +.ac-item { + display: flex; align-items: center; gap: 8px; + padding: 5px 10px; border-radius: 4px; cursor: pointer; + font-size: 12px; color: var(--text); +} +.ac-item:hover, .ac-item.active { background: var(--bg-hover); } +.ac-item-label { font-family: var(--mono); font-weight: 500; white-space: nowrap; } +.ac-item-detail { font-size: 10.5px; color: var(--text3); margin-left: auto; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 160px; } +.ac-item-context { font-size: 9px; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; padding: 2px 10px 4px; } + +
+
@@ -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: '![image](url)', 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 };