feat: autocomplete intelligent partage (Forge + CodeMirror)
- 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:
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
// ---- Init ----
|
||||
updateLineNumbers();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user