fix(forge): autocompletion Tab naturelle et fiable (BUG-055)
CI / lint (push) Successful in 1m34s
CI / security (push) Successful in 1m0s
CI / test (push) Successful in 3m30s
CI / build (push) Successful in 57s
CI / e2e (push) Successful in 10m54s

This commit is contained in:
2026-09-17 08:48:03 -04:00
parent 3131277b19
commit 61347e0f0b
15 changed files with 524 additions and 121 deletions
+176 -103
View File
@@ -212,18 +212,14 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
.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; }
/* Ghost text overlay */
/* Ghost text overlay (AI inline prediction, positioned at the caret) */
.ghost-overlay {
position: absolute; top: 0; left: 0; right: 0; bottom: 0;
position: absolute; top: 0; left: 0;
pointer-events: none; z-index: 1;
padding: 24px 16px 24px 12px;
font-family: var(--mono); font-size: 13.5px; line-height: 1.75;
white-space: pre-wrap; word-wrap: break-word;
overflow: hidden; color: transparent;
}
.ghost-prediction {
color: var(--text3); opacity: 0.5;
white-space: pre; color: var(--text3); opacity: 0.55;
}
.ghost-prediction { color: inherit; }
</style>
</head>
<body>
@@ -390,7 +386,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
<tr><td>/</td><td>Menu de commandes (en debut de ligne)</td></tr>
<tr><td>Alt+I</td><td>Insertion rapide (Quick Insert)</td></tr>
<tr><td>F1</td><td>Ce panneau d'aide</td></tr>
<tr><td>Tab</td><td>Indenter la ligne / element de liste</td></tr>
<tr><td>Tab</td><td>Valider la suggestion / completer le mot / indenter</td></tr>
<tr><td>Shift+Tab</td><td>Desindenter</td></tr>
</table>
<h3>Formatage</h3>
@@ -480,6 +476,11 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
var saveTimer = null;
var aiConfigured = null;
// Shared autocomplete helpers (pure functions from autocomplete.js). Loaded
// once at startup so the « Tab » handler can use them synchronously.
var ac = null;
import('/static/js/autocomplete.js').then(function(m) { ac = m; }).catch(function() {});
// Parse URL params
(function() {
var p = new URLSearchParams(window.location.search);
@@ -853,7 +854,27 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
})();
// ---- Caret position to pixel (for monospace textarea) ----
function caretToPixel(pos) {
// Measured once so the caret math matches the actual font metrics.
var _charW = 0;
function getCharWidth() {
if (_charW) return _charW;
var style = getComputedStyle(ta);
var span = document.createElement('span');
span.style.fontFamily = style.fontFamily;
span.style.fontSize = style.fontSize;
span.style.fontWeight = style.fontWeight;
span.style.fontStyle = style.fontStyle;
span.style.position = 'absolute';
span.style.visibility = 'hidden';
span.style.whiteSpace = 'pre';
span.textContent = '0123456789';
document.body.appendChild(span);
_charW = (span.getBoundingClientRect().width / 10) || (parseFloat(style.fontSize) * 0.615);
span.remove();
return _charW;
}
function caretToPixel(pos, raw) {
if (pos == null) pos = getPos().s;
var v = val();
var before = v.substring(0, pos);
@@ -864,20 +885,40 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
var fontSize = parseFloat(style.fontSize);
var lineH = parseFloat(style.lineHeight);
if (isNaN(lineH)) lineH = fontSize * 1.75;
var charW = fontSize * 0.615;
var charW = getCharWidth();
var wrapRect = editorWrap.getBoundingClientRect();
var taRect = ta.getBoundingClientRect();
// Position relative to editorWrap
var x = taRect.left - wrapRect.left + 2 + colNum * charW;
// Position relative to editorWrap (top of the line below the caret).
var x = taRect.left - wrapRect.left + colNum * charW;
var y = taRect.top - wrapRect.top + (lineNum + 1) * lineH - ta.scrollTop;
// Clamp
if (x < 4) x = 4;
if (x > wrapRect.width - 310) x = wrapRect.width - 310;
if (y > wrapRect.height - 360) y = wrapRect.height - 360;
if (y < 20) y = 20;
if (!raw) {
// Clamp for the popup menus (never the inline ghost, which is exact).
if (x < 4) x = 4;
if (x > wrapRect.width - 310) x = wrapRect.width - 310;
if (y > wrapRect.height - 360) y = wrapRect.height - 360;
if (y < 20) y = 20;
}
return { x: x, y: y };
}
// Position of the caret itself (same line), used by the inline ghost text.
function caretLinePixel(pos) {
if (pos == null) pos = getPos().s;
var v = val();
var before = v.substring(0, pos);
var lineNum = before.split('\n').length - 1;
var colNum = pos - v.lastIndexOf('\n', pos - 1) - 1;
if (colNum < 0) colNum = 0;
var style = getComputedStyle(ta);
var lineH = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.75;
var wrapRect = editorWrap.getBoundingClientRect();
var taRect = ta.getBoundingClientRect();
return {
x: taRect.left - wrapRect.left + colNum * getCharWidth(),
y: taRect.top - wrapRect.top + lineNum * lineH - ta.scrollTop
};
}
// ---- Slash menu ----
function initSlashItems() { slashItems = Array.from(slashMenu.querySelectorAll('.slash-item')); }
@@ -1275,11 +1316,6 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
if (e.ctrlKey && e.key === 'b') { e.preventDefault(); wrapSelection('**'); }
if (e.ctrlKey && e.key === 'i') { e.preventDefault(); wrapSelection('*'); }
if (e.ctrlKey && e.key === 'k') { e.preventDefault(); promptLink(); }
// Tab: indent list items
if (e.key === 'Tab') {
e.preventDefault();
if (e.shiftKey) { dedentLine(); } else { indentLine(); }
}
// Quick Insert: Alt+I
if (e.altKey && e.key === 'i') { e.preventDefault(); showQI(); }
});
@@ -1502,10 +1538,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
if (lnGutter) {
document.getElementById('ln-gutter').scrollTop = editorWrap.scrollTop;
}
// Sync ghost overlay scroll
if (ghostOverlay) {
ghostOverlay.scrollTop = editorWrap.scrollTop;
}
clearGhost();
});
// Update line numbers on input
@@ -1524,32 +1557,61 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
if (e.key === 'Escape' && helpOverlay.classList.contains('on')) { helpOverlay.classList.remove('on'); }
});
// ---- Autocomplete (Tab completion from existing words) ----
var _acTimer = null;
// ---- Autocomplete : gestion unifiée de la touche Tab ----
// Priorité : liste ouverte > prédiction IA (ghost) > complétion de mot du
// document > indentation. Une seule action par appui (avant ce correctif,
// l'indentation s'exécutait *en plus* de la complétion et ajoutait des espaces).
ta.addEventListener('keydown', function(e) {
if (e.key === 'Tab' && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey && !slashVisible && !qiMenu.classList.contains('on')) {
var p = getPos();
if (p.s !== p.e) return; // Don't autocomplete when selection exists (let indent handle it)
var v = val();
// Find the word fragment before cursor
var start = p.s;
while (start > 0 && /[\w\-\.\/]/.test(v.charAt(start - 1))) start--;
var fragment = v.substring(start, p.s);
if (fragment.length < 2) return; // Need at least 2 chars
// Find matching words in the document
var re = new RegExp('\\b' + fragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '[\\w\\-\\.\\/]+', 'gi');
var matches = [];
var m;
while ((m = re.exec(v)) !== null) {
if (matches.indexOf(m[0]) === -1) matches.push(m[0]);
}
if (matches.length === 1) {
e.preventDefault();
insertAtCursor(matches[0].substring(fragment.length), 0);
if (e.key !== 'Tab' || e.ctrlKey || e.altKey || e.metaKey) return;
if (slashVisible || qiMenu.classList.contains('on')) return; // ces menus gèrent Tab
// 1. Liste d'autocomplétion ouverte → valider l'élément surligné
if (acDropdown.classList.contains('active')) {
e.preventDefault();
if (acIdx >= 0 && acItems[acIdx]) applyAutocomplete(acItems[acIdx]);
return;
}
// 2. Prédiction IA affichée → l'accepter
if (_ghostText) {
e.preventDefault();
acceptGhost();
return;
}
// 3. Complétion à partir des mots du document
var p = getPos();
if (!e.shiftKey && p.s === p.e && ac) {
var frag = ac.getWordFragment(val(), p.s);
if (frag.fragment.length >= 2) {
var candidates = ac.findWordCompletions(val(), p.s, frag.fragment, 8);
var action = ac.chooseTabAction({ candidates: candidates });
if (action === 'word') {
e.preventDefault();
insertAtCursor(candidates[0].slice(frag.fragment.length), 0);
return;
}
if (action === 'word-list') {
e.preventDefault();
showWordCompletions(candidates, frag);
return;
}
}
}
// 4. Défaut : indenter / désindenter
e.preventDefault();
if (e.shiftKey) { dedentLine(); } else { indentLine(); }
});
// Suggestion list for several document words sharing the typed prefix.
function showWordCompletions(words, frag) {
var items = words.map(function(w) {
return { label: w, detail: 'mot du document', insert: w.slice(frag.fragment.length), kind: 'word' };
});
showAutocomplete(items, 'Mots du document');
}
// ---- Title sync: first heading line <-> title bar (NO rename) ----
function syncTitleFromContent() {
var v = val();
@@ -1651,13 +1713,24 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
acItems = items;
acIdx = -1;
if (!items.length) { hideAutocomplete(); return; }
// Position: centered below the textarea (simple, reliable)
var rect = ta.getBoundingClientRect();
acDropdown.style.left = (rect.left + rect.width / 2) + 'px';
acDropdown.style.top = (rect.bottom + 6) + 'px';
acDropdown.style.transform = 'translateX(-50%)';
// Keep dropdown within viewport
acDropdown.style.maxWidth = Math.min(380, rect.width - 32) + 'px';
// Position: just below the caret, clamped to the viewport (natural place
// for a completion list instead of a fixed centered dropdown).
var pt = caretToPixel(ta.selectionStart, true);
var wr = editorWrap.getBoundingClientRect();
var width = Math.min(380, Math.max(220, editorWrap.clientWidth - 32));
var estH = Math.min(items.length * 26 + 30, 260);
var left = wr.left + pt.x;
var top = wr.top + pt.y + 4;
if (left + width > window.innerWidth - 8) left = window.innerWidth - width - 8;
if (left < 8) left = 8;
if (top + estH > window.innerHeight - 8) {
top = Math.max(8, wr.top + pt.y - estH - 2);
}
acDropdown.style.width = width + 'px';
acDropdown.style.maxWidth = width + 'px';
acDropdown.style.left = left + 'px';
acDropdown.style.top = top + 'px';
acDropdown.style.transform = 'none';
var html = context ? '<div class="ac-item-context">' + context + '</div>' : '';
items.forEach(function(item, i) {
@@ -1698,6 +1771,23 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
function applyAutocomplete(item) {
if (!item || !item.insert) return;
// Document word completions are inserted plainly at the caret — no
// context-aware replacement (which would wipe the line in frontmatter).
if (item.kind === 'word') {
var wp = getPos();
ta.value = val().slice(0, wp.s) + item.insert + val().slice(wp.e);
var wnp = wp.s + item.insert.length;
ta.setSelectionRange(wnp, wnp);
hideAutocomplete();
clearGhost();
ta.focus();
markDirty();
autoHeight();
updateLineNumbers();
return;
}
var start = ta.selectionStart;
var end = ta.selectionEnd;
var before = ta.value.slice(0, start);
@@ -1727,6 +1817,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
}
hideAutocomplete();
clearGhost();
ta.focus();
markDirty();
autoHeight();
@@ -1768,15 +1859,18 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
function clearGhost() {
_ghostText = '';
ghostOverlay.innerHTML = '';
if (ghostPred) ghostPred.textContent = '';
}
function showGhost(prediction) {
if (!prediction) { clearGhost(); return; }
_ghostText = prediction;
var before = ta.value.slice(0, ta.selectionStart);
// Show: existing text (transparent) + prediction (visible faded)
ghostOverlay.innerHTML = escHtml(before) + '<span class="ghost-prediction">' + escHtml(prediction) + '</span>';
// Only the prediction is rendered, positioned exactly at the caret: no
// mirror of the whole document, so no misalignment and no phantom spaces.
ghostPred.textContent = prediction;
var pt = caretLinePixel(ta.selectionStart);
ghostOverlay.style.left = pt.x + 'px';
ghostOverlay.style.top = pt.y + 'px';
}
function acceptGhost() {
@@ -1784,9 +1878,8 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
var start = ta.selectionStart;
var before = ta.value.slice(0, start);
var after = ta.value.slice(start);
// Trim to avoid double spaces
var insert = _ghostText.replace(/^\s+/, '');
if (!insert) return;
if (!insert) { clearGhost(); return; }
ta.value = before + insert + after;
ta.setSelectionRange(start + insert.length, start + insert.length);
clearGhost();
@@ -1797,32 +1890,16 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
}
function requestGhostCompletion() {
if (acDropdown.classList.contains('active')) return; // list open: don't compete
if (ta.selectionStart !== ta.selectionEnd) return; // no prediction over a selection
var fullText = ta.value.slice(0, ta.selectionStart);
if (fullText.trim().length < 1) return;
if (!fullText.trim()) return;
var lastChar = fullText.slice(-1);
var midWord = /[a-zA-Z0-9\u00C0-\u024F]$/.test(lastChar);
var midWord = /[\w\u00C0-\u024F]$/.test(lastChar);
var inputText = midWord ? (fullText.match(/([\w\u00C0-\u024F]+)$/) || [''])[0] : fullText;
// Get user's preferred language
var lang = 'fr';
try { lang = localStorage.getItem('obsigate-lang') || 'fr'; } catch(e) {}
var langNames = { fr: 'French', en: 'English', es: 'Spanish', de: 'German' };
var langName = langNames[lang] || 'French';
var prompt, inputText;
if (midWord) {
var wordMatch = fullText.match(/([\w\u00C0-\u024F]+)$/);
var partialWord = wordMatch ? wordMatch[1] : fullText;
inputText = partialWord;
var context = fullText.slice(0, -partialWord.length).trim();
prompt = 'Complete this ' + langName + ' word. The word is: "' + partialWord +
'". Context: "' + (context || '(start of line)') +
'". Return ONLY the remaining letters. Do NOT add spaces. Example: "famil" → "ial" for "familial".';
} else {
inputText = fullText;
prompt = 'Continue this ' + langName + ' text naturally. Return ONLY the new text (do NOT repeat):\n' + fullText;
}
var ghostBody = { text: prompt };
// The backend turns this text into a short « continue this text » prompt.
var ghostBody = { text: fullText };
// #101 — follow the AI Assistant's configured provider/model; fall back to
// the local Ollama model when the assistant has no explicit selection.
var ghostPick = aiPickerSelection();
@@ -1836,19 +1913,16 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
})
.then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function(data) {
var raw = (data.result || '').trim();
if (!raw || raw.length < 1) return;
var prediction = raw;
if (midWord && prediction.toLowerCase().startsWith(inputText.toLowerCase())) {
prediction = prediction.slice(inputText.length);
} else if (!midWord && prediction.startsWith(inputText)) {
prediction = prediction.slice(inputText.length);
}
prediction = prediction.trim();
var prediction = ac
? ac.normalizeGhost(data.result || '', inputText, midWord)
: String(data.result || '').trim();
if (!prediction) return;
// Don't suggest text that is already present after the cursor.
var alreadyThere = ta.value.slice(ta.selectionStart).trimStart();
if (prediction && prediction.length > 0 && !alreadyThere.startsWith(prediction.slice(0, Math.min(6, prediction.length)))) {
showGhost(prediction);
}
if (alreadyThere.startsWith(prediction.slice(0, Math.min(6, prediction.length)))) return;
// Ignore a stale response if the caret moved while we were waiting.
if (ta.selectionStart !== ta.selectionEnd) return;
showGhost(prediction);
})
.catch(function() { /* silent */ });
}
@@ -1860,15 +1934,14 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
_ghostTimer = setTimeout(requestGhostCompletion, 600);
});
// Clear the prediction as soon as the caret moves elsewhere.
document.addEventListener('selectionchange', function() {
if (document.activeElement === ta) clearGhost();
});
function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
ta.addEventListener('keydown', function(e) {
// Tab: accept ghost text prediction
if (e.key === 'Tab' && _ghostText && !acDropdown.classList.contains('active')) {
e.preventDefault();
acceptGhost();
return;
}
// Escape: clear ghost text
if (e.key === 'Escape' && _ghostText && !acDropdown.classList.contains('active')) {
clearGhost();
@@ -1877,7 +1950,7 @@ body { font-family: var(--sans); background: var(--bg); color: var(--text); heig
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') {
else if (e.key === 'Enter') {
e.preventDefault();
if (acIdx >= 0 && acItems[acIdx]) applyAutocomplete(acItems[acIdx]);
}
+94 -1
View File
@@ -347,4 +347,97 @@ function filterFiles(partial) {
});
}
export { suggest, searchWikilinks, detectContext, MERMAID_TYPES, CODE_LANGUAGES, FRONTMATTER_FIELDS, MARKDOWN_FORMAT };
// ── Word completion (Forge « Tab » completion) ────────────────────────
// A word character matches the same set used by the editor: letters, digits,
// underscore, dash, dot and slash (so paths like `docs/guide` complete too).
var _WORD_CHAR = /[\w\-.\/]/;
function isWordChar(ch) {
return !!ch && _WORD_CHAR.test(ch);
}
/** Return the word fragment immediately before the cursor. */
function getWordFragment(text, cursorPos) {
var start = cursorPos;
while (start > 0 && isWordChar(text.charAt(start - 1))) start--;
return { start: start, fragment: text.slice(start, cursorPos) };
}
/**
* Collect unique words of `text` that start with `fragment` (case-insensitive).
* The occurrence currently being typed at the cursor is ignored. `limit` caps
* the number of candidates (0 = no limit).
*/
function findWordCompletions(text, cursorPos, fragment, limit) {
if (!fragment || fragment.length < 2) return [];
var lower = fragment.toLowerCase();
var re = /[\w\-.\/]+/g;
var seen = Object.create(null);
var out = [];
var m;
while ((m = re.exec(text)) !== null) {
var word = m[0];
if (word.length <= fragment.length) continue;
if (word.toLowerCase().indexOf(lower) !== 0) continue;
// Skip the word currently being typed (it starts exactly at the cursor).
if (m.index === cursorPos - fragment.length) continue;
var key = word.toLowerCase();
if (seen[key]) continue;
seen[key] = true;
out.push(word);
if (limit && out.length >= limit) break;
}
return out;
}
/**
* Clean up a raw AI prediction before displaying/inserting it.
*
* - strips the echoed input prefix (mid-word or full context),
* - for a mid-word completion, keeps only the first token so a word completion
* can never introduce a stray space,
* - trims any remaining leading whitespace.
*/
function normalizeGhost(raw, inputText, midWord) {
var p = raw == null ? '' : String(raw).trim();
if (!p) return '';
if (inputText) {
var inp = String(inputText);
if (midWord && p.toLowerCase().indexOf(inp.toLowerCase()) === 0) {
p = p.slice(inp.length);
} else if (!midWord && p.indexOf(inp) === 0) {
p = p.slice(inp.length);
}
}
if (midWord) p = p.split(/\s+/)[0];
return p.replace(/^\s+/, '');
}
/**
* Decide what the « Tab » key should do. Kept pure so the editor and the tests
* share the exact same priority rules.
*
* dropdown → an autocomplete list is open: accept the highlighted item
* ghost → an AI inline prediction is displayed: accept it
* dedent → Shift+Tab: remove indentation
* word → a single document word matches: insert its suffix
* word-list → several words match: open the suggestion list
* indent → default: indent the current line
*/
function chooseTabAction(state) {
state = state || {};
if (state.dropdownOpen) return 'dropdown';
if (state.ghost) return 'ghost';
if (state.shiftKey) return 'dedent';
if (state.hasSelection) return 'indent';
var candidates = state.candidates || [];
if (candidates.length === 1) return 'word';
if (candidates.length > 1) return 'word-list';
return 'indent';
}
export {
suggest, searchWikilinks, detectContext,
MERMAID_TYPES, CODE_LANGUAGES, FRONTMATTER_FIELDS, MARKDOWN_FORMAT,
isWordChar, getWordFragment, findWordCompletions, normalizeGhost, chooseTabAction,
};