feat: autocomplete intelligent partage (Forge + CodeMirror)
CI / lint (push) Failing after 1s
CI / test (push) Has been skipped
CI / build (push) Has been skipped
CI / e2e (push) Has been skipped
CI / security (push) Failing after -7s

- autocomplete.js: detection de contexte (mermaid, code, frontmatter, wikilink, markdown)
- Suggestions Mermaid: 15 types + syntaxe par type (flowchart, sequence, class, etc.)
- Suggestions Code: snippets Python/JS/Bash/SQL + 15 langages
- Suggestions Frontmatter: 9 champs YAML
- Suggestions Markdown: titres, formatting, callouts, tableaux
- Wikilinks: [[ cherche dans l'index via API /search/advanced
- Integration Forge: Ctrl+Space, auto-trigger [[ et ```, Tab/Enter pour accepter
- Dropdown flottant positionne au curseur, navigation fleches
This commit is contained in:
2026-06-16 09:12:21 -04:00
parent a002acb2b2
commit 06a986d01c
2 changed files with 535 additions and 0 deletions
+185
View File
@@ -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; }
</style>
</head>
<body>
<!-- Autocomplete dropdown -->
<div class="ac-dropdown" id="ac-dropdown"></div>
<!-- Bar -->
<div class="bar">
<div class="bar-left">
@@ -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 ? '<div class="ac-item-context">' + context + '</div>' : '';
items.forEach(function(item, i) {
html += '<div class="ac-item" data-idx="' + i + '">' +
'<span class="ac-item-label">' + escHtml(item.label) + '</span>' +
(item.detail ? '<span class="ac-item-detail">' + escHtml(item.detail) + '</span>' : '') +
'</div>';
});
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
// ---- Init ----
updateLineNumbers();