Commit ·
612978c
1
Parent(s): 7f53fe2
fix(autocomplete): 3 user-reported issues - last word only, dropdown position, cursor after accept
Browse files- src/js/autocomplete.js +98 -107
src/js/autocomplete.js
CHANGED
|
@@ -2,12 +2,6 @@
|
|
| 2 |
* AutoComplete Module — Ghost Text + Dropdown for Arabic autocomplete.
|
| 3 |
*
|
| 4 |
* COMPLETELY INDEPENDENT from the correction pipeline.
|
| 5 |
-
* This module has ZERO interaction with:
|
| 6 |
-
* - editor.js correction/highlight logic
|
| 7 |
-
* - renderer.js span rendering
|
| 8 |
-
* - ui.js suggestion sidebar
|
| 9 |
-
* - /api/analyze
|
| 10 |
-
*
|
| 11 |
* It only talks to: /api/autocomplete (its own endpoint)
|
| 12 |
*/
|
| 13 |
|
|
@@ -28,7 +22,6 @@
|
|
| 28 |
let debounceTimer = null;
|
| 29 |
let isComposing = false;
|
| 30 |
let editorEl = null;
|
| 31 |
-
let _suppressSelectionChange = false;
|
| 32 |
let _lastFetchId = 0;
|
| 33 |
|
| 34 |
// ─── Initialization ──────────────────────────────────────────────
|
|
@@ -42,7 +35,7 @@
|
|
| 42 |
createGhostElement();
|
| 43 |
createDropdownElement();
|
| 44 |
bindEvents();
|
| 45 |
-
console.log('[AutoComplete] Initialized
|
| 46 |
}
|
| 47 |
|
| 48 |
// ─── Ghost Text Element ──────────────────────────────────────────
|
|
@@ -50,8 +43,7 @@
|
|
| 50 |
ghostEl = document.createElement('div');
|
| 51 |
ghostEl.id = 'autocomplete-ghost';
|
| 52 |
ghostEl.setAttribute('aria-hidden', 'true');
|
| 53 |
-
|
| 54 |
-
const editorParent = editorEl.parentElement;
|
| 55 |
if (editorParent) {
|
| 56 |
editorParent.style.position = 'relative';
|
| 57 |
editorParent.appendChild(ghostEl);
|
|
@@ -86,13 +78,10 @@
|
|
| 86 |
editorEl.addEventListener('scroll', dismiss);
|
| 87 |
window.addEventListener('resize', dismiss);
|
| 88 |
|
| 89 |
-
// Focus lost → dismiss
|
| 90 |
editorEl.addEventListener('blur', function () {
|
| 91 |
-
// Small delay to allow dropdown click to register
|
| 92 |
setTimeout(function () {
|
| 93 |
-
if (document.activeElement !== editorEl)
|
| 94 |
-
dismiss();
|
| 95 |
-
}
|
| 96 |
}, 200);
|
| 97 |
});
|
| 98 |
}
|
|
@@ -100,13 +89,8 @@
|
|
| 100 |
// ─── Input Handler ───────────────────────────────────────────────
|
| 101 |
function onInput() {
|
| 102 |
if (isComposing) return;
|
| 103 |
-
|
| 104 |
-
// Clear previous debounce
|
| 105 |
clearTimeout(debounceTimer);
|
| 106 |
-
|
| 107 |
-
// Hide ghost immediately while typing (but keep dropdown state for debounce)
|
| 108 |
hideGhost();
|
| 109 |
-
|
| 110 |
debounceTimer = setTimeout(fetchSuggestions, DEBOUNCE_MS);
|
| 111 |
}
|
| 112 |
|
|
@@ -120,66 +104,73 @@
|
|
| 120 |
e.stopPropagation();
|
| 121 |
acceptSuggestion();
|
| 122 |
break;
|
| 123 |
-
|
| 124 |
case 'Escape':
|
| 125 |
e.preventDefault();
|
| 126 |
dismiss();
|
| 127 |
break;
|
| 128 |
-
|
| 129 |
case 'ArrowDown':
|
| 130 |
e.preventDefault();
|
| 131 |
navigateDropdown(1);
|
| 132 |
break;
|
| 133 |
-
|
| 134 |
case 'ArrowUp':
|
| 135 |
e.preventDefault();
|
| 136 |
navigateDropdown(-1);
|
| 137 |
break;
|
| 138 |
-
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
}
|
| 141 |
}
|
| 142 |
|
| 143 |
// ─── Fetch Suggestions ───────────────────────────────────────────
|
| 144 |
async function fetchSuggestions() {
|
| 145 |
-
|
| 146 |
|
| 147 |
-
|
| 148 |
if (!sel || !sel.isCollapsed || !sel.rangeCount) {
|
| 149 |
dismiss();
|
| 150 |
return;
|
| 151 |
}
|
| 152 |
|
| 153 |
-
//
|
| 154 |
-
|
| 155 |
-
|
|
|
|
|
|
|
| 156 |
dismiss();
|
| 157 |
return;
|
| 158 |
}
|
| 159 |
|
| 160 |
-
//
|
| 161 |
-
|
| 162 |
if (!context || context.trim().length < MIN_CONTEXT_LEN) {
|
| 163 |
dismiss();
|
| 164 |
return;
|
| 165 |
}
|
| 166 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
try {
|
| 168 |
-
|
| 169 |
method: 'POST',
|
| 170 |
headers: { 'Content-Type': 'application/json' },
|
| 171 |
-
body: JSON.stringify({ context:
|
| 172 |
});
|
| 173 |
|
| 174 |
-
// Stale response check — if another fetch started, ignore this one
|
| 175 |
if (fetchId !== _lastFetchId) return;
|
|
|
|
| 176 |
|
| 177 |
-
|
| 178 |
-
dismiss();
|
| 179 |
-
return;
|
| 180 |
-
}
|
| 181 |
-
|
| 182 |
-
const data = await resp.json();
|
| 183 |
if (fetchId !== _lastFetchId) return;
|
| 184 |
|
| 185 |
if (data.status !== 'success' || !data.suggestions || !data.suggestions.length) {
|
|
@@ -187,7 +178,7 @@
|
|
| 187 |
return;
|
| 188 |
}
|
| 189 |
|
| 190 |
-
console.log('[AutoComplete]
|
| 191 |
showSuggestions(data.suggestions);
|
| 192 |
|
| 193 |
} catch (err) {
|
|
@@ -198,17 +189,16 @@
|
|
| 198 |
|
| 199 |
// ─── Get Text Before Cursor ──────────────────────────────────────
|
| 200 |
function getTextBeforeCursor(maxChars) {
|
| 201 |
-
|
| 202 |
if (!sel || !sel.rangeCount) return '';
|
| 203 |
|
| 204 |
try {
|
| 205 |
-
|
| 206 |
-
|
| 207 |
preRange.selectNodeContents(editorEl);
|
| 208 |
preRange.setEnd(range.startContainer, range.startOffset);
|
| 209 |
-
|
| 210 |
preRange.detach();
|
| 211 |
-
|
| 212 |
if (text.length <= maxChars) return text;
|
| 213 |
return text.slice(-maxChars);
|
| 214 |
} catch (e) {
|
|
@@ -216,6 +206,24 @@
|
|
| 216 |
}
|
| 217 |
}
|
| 218 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
// ─── Show Suggestions ────────────────────────────────────────────
|
| 220 |
function showSuggestions(suggestions) {
|
| 221 |
currentSuggestions = suggestions;
|
|
@@ -241,11 +249,11 @@
|
|
| 241 |
dropdownEl.appendChild(item);
|
| 242 |
});
|
| 243 |
|
| 244 |
-
// Position and show dropdown
|
| 245 |
positionDropdown();
|
| 246 |
dropdownEl.style.display = 'block';
|
| 247 |
|
| 248 |
-
// Show ghost text
|
| 249 |
showGhost(suggestions[0]);
|
| 250 |
}
|
| 251 |
|
|
@@ -253,21 +261,19 @@
|
|
| 253 |
function showGhost(text) {
|
| 254 |
if (!ghostEl || !text) return;
|
| 255 |
|
| 256 |
-
var caretPos =
|
| 257 |
-
if (!caretPos) {
|
| 258 |
-
hideGhost();
|
| 259 |
-
return;
|
| 260 |
-
}
|
| 261 |
|
| 262 |
ghostEl.textContent = text;
|
| 263 |
ghostEl.style.display = 'block';
|
| 264 |
|
| 265 |
var parentRect = editorEl.parentElement.getBoundingClientRect();
|
| 266 |
|
| 267 |
-
//
|
| 268 |
ghostEl.style.top = (caretPos.top - parentRect.top) + 'px';
|
| 269 |
-
|
| 270 |
ghostEl.style.left = 'auto';
|
|
|
|
| 271 |
}
|
| 272 |
|
| 273 |
function hideGhost() {
|
|
@@ -279,40 +285,38 @@
|
|
| 279 |
|
| 280 |
// ─── Dropdown Position ───────────────────────────────────────────
|
| 281 |
function positionDropdown() {
|
| 282 |
-
var caretPos =
|
| 283 |
if (!caretPos) return;
|
| 284 |
|
| 285 |
-
//
|
| 286 |
dropdownEl.style.position = 'fixed';
|
| 287 |
-
dropdownEl.style.top = (caretPos.bottom + 6) + 'px';
|
| 288 |
|
| 289 |
-
//
|
| 290 |
-
|
| 291 |
-
dropdownEl.style.
|
| 292 |
-
|
| 293 |
-
//
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
|
|
|
| 306 |
}
|
| 307 |
|
| 308 |
// ─── Dropdown Navigation ─────────────────────────────────────────
|
| 309 |
function navigateDropdown(direction) {
|
| 310 |
if (!currentSuggestions.length) return;
|
| 311 |
-
|
| 312 |
selectedIndex += direction;
|
| 313 |
if (selectedIndex < 0) selectedIndex = currentSuggestions.length - 1;
|
| 314 |
if (selectedIndex >= currentSuggestions.length) selectedIndex = 0;
|
| 315 |
-
|
| 316 |
updateDropdownSelection();
|
| 317 |
showGhost(currentSuggestions[selectedIndex]);
|
| 318 |
}
|
|
@@ -322,11 +326,8 @@
|
|
| 322 |
items.forEach(function (item, idx) {
|
| 323 |
item.classList.toggle('ac-selected', idx === selectedIndex);
|
| 324 |
});
|
| 325 |
-
|
| 326 |
var selected = dropdownEl.querySelector('.ac-selected');
|
| 327 |
-
if (selected) {
|
| 328 |
-
selected.scrollIntoView({ block: 'nearest' });
|
| 329 |
-
}
|
| 330 |
}
|
| 331 |
|
| 332 |
// ─── Accept Suggestion ───────────────────────────────────────────
|
|
@@ -337,30 +338,24 @@
|
|
| 337 |
}
|
| 338 |
|
| 339 |
var word = currentSuggestions[selectedIndex];
|
| 340 |
-
|
| 341 |
var sel = window.getSelection();
|
| 342 |
if (!sel || !sel.rangeCount) {
|
| 343 |
dismiss();
|
| 344 |
return;
|
| 345 |
}
|
| 346 |
|
| 347 |
-
//
|
| 348 |
-
var
|
| 349 |
-
var
|
| 350 |
-
range.deleteContents();
|
| 351 |
-
var textNode = document.createTextNode(textToInsert);
|
| 352 |
-
range.insertNode(textNode);
|
| 353 |
|
| 354 |
-
//
|
| 355 |
-
|
| 356 |
-
range.setEndAfter(textNode);
|
| 357 |
-
sel.removeAllRanges();
|
| 358 |
-
sel.addRange(range);
|
| 359 |
|
| 360 |
-
|
|
|
|
|
|
|
| 361 |
|
| 362 |
-
|
| 363 |
-
editorEl.dispatchEvent(new Event('input', { bubbles: true }));
|
| 364 |
}
|
| 365 |
|
| 366 |
// ─── Dismiss ─────────────────────────────────────────────────────
|
|
@@ -381,10 +376,8 @@
|
|
| 381 |
|
| 382 |
/**
|
| 383 |
* Get caret coordinates using Range.getClientRects() — NO DOM mutation.
|
| 384 |
-
* This avoids triggering input/selectionchange events that would dismiss
|
| 385 |
-
* the dropdown immediately.
|
| 386 |
*/
|
| 387 |
-
function
|
| 388 |
var sel = window.getSelection();
|
| 389 |
if (!sel || !sel.rangeCount) return null;
|
| 390 |
|
|
@@ -392,20 +385,20 @@
|
|
| 392 |
var range = sel.getRangeAt(0).cloneRange();
|
| 393 |
range.collapse(true);
|
| 394 |
|
| 395 |
-
// Try getClientRects first
|
| 396 |
var rects = range.getClientRects();
|
| 397 |
if (rects.length > 0) {
|
| 398 |
var r = rects[0];
|
| 399 |
return { top: r.top, left: r.left, bottom: r.bottom, right: r.right };
|
| 400 |
}
|
| 401 |
|
| 402 |
-
// Fallback: use
|
| 403 |
var bRect = range.getBoundingClientRect();
|
| 404 |
-
if (bRect && bRect.top !== 0) {
|
| 405 |
return { top: bRect.top, left: bRect.left, bottom: bRect.bottom, right: bRect.right };
|
| 406 |
}
|
| 407 |
|
| 408 |
-
// Last resort: use editor position
|
| 409 |
var editorRect = editorEl.getBoundingClientRect();
|
| 410 |
return {
|
| 411 |
top: editorRect.top + 20,
|
|
@@ -418,12 +411,10 @@
|
|
| 418 |
}
|
| 419 |
}
|
| 420 |
|
| 421 |
-
// ─── Initialize
|
| 422 |
if (document.readyState === 'loading') {
|
| 423 |
document.addEventListener('DOMContentLoaded', init);
|
| 424 |
} else {
|
| 425 |
-
// Script runs in <head>, editor might not exist yet
|
| 426 |
-
// Wait for DOM to be fully ready
|
| 427 |
setTimeout(init, 100);
|
| 428 |
}
|
| 429 |
|
|
|
|
| 2 |
* AutoComplete Module — Ghost Text + Dropdown for Arabic autocomplete.
|
| 3 |
*
|
| 4 |
* COMPLETELY INDEPENDENT from the correction pipeline.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
* It only talks to: /api/autocomplete (its own endpoint)
|
| 6 |
*/
|
| 7 |
|
|
|
|
| 22 |
let debounceTimer = null;
|
| 23 |
let isComposing = false;
|
| 24 |
let editorEl = null;
|
|
|
|
| 25 |
let _lastFetchId = 0;
|
| 26 |
|
| 27 |
// ─── Initialization ──────────────────────────────────────────────
|
|
|
|
| 35 |
createGhostElement();
|
| 36 |
createDropdownElement();
|
| 37 |
bindEvents();
|
| 38 |
+
console.log('[AutoComplete] Initialized');
|
| 39 |
}
|
| 40 |
|
| 41 |
// ─── Ghost Text Element ──────────────────────────────────────────
|
|
|
|
| 43 |
ghostEl = document.createElement('div');
|
| 44 |
ghostEl.id = 'autocomplete-ghost';
|
| 45 |
ghostEl.setAttribute('aria-hidden', 'true');
|
| 46 |
+
var editorParent = editorEl.parentElement;
|
|
|
|
| 47 |
if (editorParent) {
|
| 48 |
editorParent.style.position = 'relative';
|
| 49 |
editorParent.appendChild(ghostEl);
|
|
|
|
| 78 |
editorEl.addEventListener('scroll', dismiss);
|
| 79 |
window.addEventListener('resize', dismiss);
|
| 80 |
|
| 81 |
+
// Focus lost → dismiss (with delay for dropdown clicks)
|
| 82 |
editorEl.addEventListener('blur', function () {
|
|
|
|
| 83 |
setTimeout(function () {
|
| 84 |
+
if (document.activeElement !== editorEl) dismiss();
|
|
|
|
|
|
|
| 85 |
}, 200);
|
| 86 |
});
|
| 87 |
}
|
|
|
|
| 89 |
// ─── Input Handler ───────────────────────────────────────────────
|
| 90 |
function onInput() {
|
| 91 |
if (isComposing) return;
|
|
|
|
|
|
|
| 92 |
clearTimeout(debounceTimer);
|
|
|
|
|
|
|
| 93 |
hideGhost();
|
|
|
|
| 94 |
debounceTimer = setTimeout(fetchSuggestions, DEBOUNCE_MS);
|
| 95 |
}
|
| 96 |
|
|
|
|
| 104 |
e.stopPropagation();
|
| 105 |
acceptSuggestion();
|
| 106 |
break;
|
|
|
|
| 107 |
case 'Escape':
|
| 108 |
e.preventDefault();
|
| 109 |
dismiss();
|
| 110 |
break;
|
|
|
|
| 111 |
case 'ArrowDown':
|
| 112 |
e.preventDefault();
|
| 113 |
navigateDropdown(1);
|
| 114 |
break;
|
|
|
|
| 115 |
case 'ArrowUp':
|
| 116 |
e.preventDefault();
|
| 117 |
navigateDropdown(-1);
|
| 118 |
break;
|
| 119 |
+
case 'Enter':
|
| 120 |
+
// If dropdown is visible, accept on Enter too
|
| 121 |
+
if (isVisible() && selectedIndex >= 0) {
|
| 122 |
+
e.preventDefault();
|
| 123 |
+
e.stopPropagation();
|
| 124 |
+
acceptSuggestion();
|
| 125 |
+
}
|
| 126 |
+
break;
|
| 127 |
}
|
| 128 |
}
|
| 129 |
|
| 130 |
// ─── Fetch Suggestions ───────────────────────────────────────────
|
| 131 |
async function fetchSuggestions() {
|
| 132 |
+
var fetchId = ++_lastFetchId;
|
| 133 |
|
| 134 |
+
var sel = window.getSelection();
|
| 135 |
if (!sel || !sel.isCollapsed || !sel.rangeCount) {
|
| 136 |
dismiss();
|
| 137 |
return;
|
| 138 |
}
|
| 139 |
|
| 140 |
+
// CRITICAL: Only show autocomplete when cursor is at END of text
|
| 141 |
+
// or at the end of a word (after a space or at document end)
|
| 142 |
+
var textAfterCursor = getTextAfterCursor();
|
| 143 |
+
if (textAfterCursor.length > 0 && textAfterCursor[0] !== ' ' && textAfterCursor[0] !== '\n') {
|
| 144 |
+
// Cursor is in the MIDDLE of a word — don't show autocomplete
|
| 145 |
dismiss();
|
| 146 |
return;
|
| 147 |
}
|
| 148 |
|
| 149 |
+
// Get context (text before cursor)
|
| 150 |
+
var context = getTextBeforeCursor(CONTEXT_CHARS);
|
| 151 |
if (!context || context.trim().length < MIN_CONTEXT_LEN) {
|
| 152 |
dismiss();
|
| 153 |
return;
|
| 154 |
}
|
| 155 |
|
| 156 |
+
// Must end with a word (not just spaces)
|
| 157 |
+
var trimmed = context.trimEnd();
|
| 158 |
+
if (!trimmed || trimmed.length < MIN_CONTEXT_LEN) {
|
| 159 |
+
dismiss();
|
| 160 |
+
return;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
try {
|
| 164 |
+
var resp = await fetch('/api/autocomplete', {
|
| 165 |
method: 'POST',
|
| 166 |
headers: { 'Content-Type': 'application/json' },
|
| 167 |
+
body: JSON.stringify({ context: trimmed, n: MAX_SUGGESTIONS })
|
| 168 |
});
|
| 169 |
|
|
|
|
| 170 |
if (fetchId !== _lastFetchId) return;
|
| 171 |
+
if (!resp.ok) { dismiss(); return; }
|
| 172 |
|
| 173 |
+
var data = await resp.json();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
if (fetchId !== _lastFetchId) return;
|
| 175 |
|
| 176 |
if (data.status !== 'success' || !data.suggestions || !data.suggestions.length) {
|
|
|
|
| 178 |
return;
|
| 179 |
}
|
| 180 |
|
| 181 |
+
console.log('[AutoComplete] Suggestions for last word:', data.suggestions);
|
| 182 |
showSuggestions(data.suggestions);
|
| 183 |
|
| 184 |
} catch (err) {
|
|
|
|
| 189 |
|
| 190 |
// ─── Get Text Before Cursor ──────────────────────────────────────
|
| 191 |
function getTextBeforeCursor(maxChars) {
|
| 192 |
+
var sel = window.getSelection();
|
| 193 |
if (!sel || !sel.rangeCount) return '';
|
| 194 |
|
| 195 |
try {
|
| 196 |
+
var range = sel.getRangeAt(0);
|
| 197 |
+
var preRange = document.createRange();
|
| 198 |
preRange.selectNodeContents(editorEl);
|
| 199 |
preRange.setEnd(range.startContainer, range.startOffset);
|
| 200 |
+
var text = preRange.toString();
|
| 201 |
preRange.detach();
|
|
|
|
| 202 |
if (text.length <= maxChars) return text;
|
| 203 |
return text.slice(-maxChars);
|
| 204 |
} catch (e) {
|
|
|
|
| 206 |
}
|
| 207 |
}
|
| 208 |
|
| 209 |
+
// ─── Get Text After Cursor ───────────────────────────────────────
|
| 210 |
+
function getTextAfterCursor() {
|
| 211 |
+
var sel = window.getSelection();
|
| 212 |
+
if (!sel || !sel.rangeCount) return '';
|
| 213 |
+
|
| 214 |
+
try {
|
| 215 |
+
var range = sel.getRangeAt(0);
|
| 216 |
+
var postRange = document.createRange();
|
| 217 |
+
postRange.selectNodeContents(editorEl);
|
| 218 |
+
postRange.setStart(range.endContainer, range.endOffset);
|
| 219 |
+
var text = postRange.toString();
|
| 220 |
+
postRange.detach();
|
| 221 |
+
return text;
|
| 222 |
+
} catch (e) {
|
| 223 |
+
return '';
|
| 224 |
+
}
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
// ─── Show Suggestions ────────────────────────────────────────────
|
| 228 |
function showSuggestions(suggestions) {
|
| 229 |
currentSuggestions = suggestions;
|
|
|
|
| 249 |
dropdownEl.appendChild(item);
|
| 250 |
});
|
| 251 |
|
| 252 |
+
// Position and show dropdown BELOW the caret, aligned to caret position
|
| 253 |
positionDropdown();
|
| 254 |
dropdownEl.style.display = 'block';
|
| 255 |
|
| 256 |
+
// Show ghost text inline
|
| 257 |
showGhost(suggestions[0]);
|
| 258 |
}
|
| 259 |
|
|
|
|
| 261 |
function showGhost(text) {
|
| 262 |
if (!ghostEl || !text) return;
|
| 263 |
|
| 264 |
+
var caretPos = getCaretCoordinates();
|
| 265 |
+
if (!caretPos) { hideGhost(); return; }
|
|
|
|
|
|
|
|
|
|
| 266 |
|
| 267 |
ghostEl.textContent = text;
|
| 268 |
ghostEl.style.display = 'block';
|
| 269 |
|
| 270 |
var parentRect = editorEl.parentElement.getBoundingClientRect();
|
| 271 |
|
| 272 |
+
// Position ghost at caret — for RTL, text appears to the LEFT of caret
|
| 273 |
ghostEl.style.top = (caretPos.top - parentRect.top) + 'px';
|
| 274 |
+
// Use left positioning (place ghost just left of the caret in RTL)
|
| 275 |
ghostEl.style.left = 'auto';
|
| 276 |
+
ghostEl.style.right = (parentRect.right - caretPos.right + 2) + 'px';
|
| 277 |
}
|
| 278 |
|
| 279 |
function hideGhost() {
|
|
|
|
| 285 |
|
| 286 |
// ─── Dropdown Position ───────────────────────────────────────────
|
| 287 |
function positionDropdown() {
|
| 288 |
+
var caretPos = getCaretCoordinates();
|
| 289 |
if (!caretPos) return;
|
| 290 |
|
| 291 |
+
// Use fixed positioning relative to viewport
|
| 292 |
dropdownEl.style.position = 'fixed';
|
|
|
|
| 293 |
|
| 294 |
+
// Place BELOW the caret line
|
| 295 |
+
var topPos = caretPos.bottom + 6;
|
| 296 |
+
dropdownEl.style.top = topPos + 'px';
|
| 297 |
+
|
| 298 |
+
// For RTL: align dropdown's RIGHT edge to the caret position
|
| 299 |
+
// Use LEFT positioning to place the dropdown starting at the caret X
|
| 300 |
+
var leftPos = caretPos.left - 160; // dropdown is ~160px wide, align right edge to caret
|
| 301 |
+
if (leftPos < 10) leftPos = 10;
|
| 302 |
+
dropdownEl.style.left = leftPos + 'px';
|
| 303 |
+
dropdownEl.style.right = 'auto';
|
| 304 |
+
|
| 305 |
+
// Check if dropdown goes off-screen bottom
|
| 306 |
+
requestAnimationFrame(function () {
|
| 307 |
+
var rect = dropdownEl.getBoundingClientRect();
|
| 308 |
+
if (rect.bottom > window.innerHeight - 20) {
|
| 309 |
+
dropdownEl.style.top = (caretPos.top - rect.height - 6) + 'px';
|
| 310 |
+
}
|
| 311 |
+
});
|
| 312 |
}
|
| 313 |
|
| 314 |
// ─── Dropdown Navigation ─────────────────────────────────────────
|
| 315 |
function navigateDropdown(direction) {
|
| 316 |
if (!currentSuggestions.length) return;
|
|
|
|
| 317 |
selectedIndex += direction;
|
| 318 |
if (selectedIndex < 0) selectedIndex = currentSuggestions.length - 1;
|
| 319 |
if (selectedIndex >= currentSuggestions.length) selectedIndex = 0;
|
|
|
|
| 320 |
updateDropdownSelection();
|
| 321 |
showGhost(currentSuggestions[selectedIndex]);
|
| 322 |
}
|
|
|
|
| 326 |
items.forEach(function (item, idx) {
|
| 327 |
item.classList.toggle('ac-selected', idx === selectedIndex);
|
| 328 |
});
|
|
|
|
| 329 |
var selected = dropdownEl.querySelector('.ac-selected');
|
| 330 |
+
if (selected) selected.scrollIntoView({ block: 'nearest' });
|
|
|
|
|
|
|
| 331 |
}
|
| 332 |
|
| 333 |
// ─── Accept Suggestion ───────────────────────────────────────────
|
|
|
|
| 338 |
}
|
| 339 |
|
| 340 |
var word = currentSuggestions[selectedIndex];
|
|
|
|
| 341 |
var sel = window.getSelection();
|
| 342 |
if (!sel || !sel.rangeCount) {
|
| 343 |
dismiss();
|
| 344 |
return;
|
| 345 |
}
|
| 346 |
|
| 347 |
+
// Determine if we need a space before the word
|
| 348 |
+
var textBefore = getTextBeforeCursor(10);
|
| 349 |
+
var needsSpaceBefore = textBefore.length > 0 && !textBefore.endsWith(' ') && !textBefore.endsWith('\n');
|
|
|
|
|
|
|
|
|
|
| 350 |
|
| 351 |
+
// Build the text to insert: [optional space] + word + space
|
| 352 |
+
var textToInsert = (needsSpaceBefore ? ' ' : '') + word + ' ';
|
|
|
|
|
|
|
|
|
|
| 353 |
|
| 354 |
+
// Use execCommand for reliable insertion in contenteditable
|
| 355 |
+
// This preserves undo history and handles cursor position correctly
|
| 356 |
+
document.execCommand('insertText', false, textToInsert);
|
| 357 |
|
| 358 |
+
dismiss();
|
|
|
|
| 359 |
}
|
| 360 |
|
| 361 |
// ─── Dismiss ─────────────────────────────────────────────────────
|
|
|
|
| 376 |
|
| 377 |
/**
|
| 378 |
* Get caret coordinates using Range.getClientRects() — NO DOM mutation.
|
|
|
|
|
|
|
| 379 |
*/
|
| 380 |
+
function getCaretCoordinates() {
|
| 381 |
var sel = window.getSelection();
|
| 382 |
if (!sel || !sel.rangeCount) return null;
|
| 383 |
|
|
|
|
| 385 |
var range = sel.getRangeAt(0).cloneRange();
|
| 386 |
range.collapse(true);
|
| 387 |
|
| 388 |
+
// Try getClientRects first
|
| 389 |
var rects = range.getClientRects();
|
| 390 |
if (rects.length > 0) {
|
| 391 |
var r = rects[0];
|
| 392 |
return { top: r.top, left: r.left, bottom: r.bottom, right: r.right };
|
| 393 |
}
|
| 394 |
|
| 395 |
+
// Fallback: use getBoundingClientRect
|
| 396 |
var bRect = range.getBoundingClientRect();
|
| 397 |
+
if (bRect && (bRect.top !== 0 || bRect.left !== 0)) {
|
| 398 |
return { top: bRect.top, left: bRect.left, bottom: bRect.bottom, right: bRect.right };
|
| 399 |
}
|
| 400 |
|
| 401 |
+
// Last resort: use editor position
|
| 402 |
var editorRect = editorEl.getBoundingClientRect();
|
| 403 |
return {
|
| 404 |
top: editorRect.top + 20,
|
|
|
|
| 411 |
}
|
| 412 |
}
|
| 413 |
|
| 414 |
+
// ─── Initialize ──────────────────────────────────────────────────
|
| 415 |
if (document.readyState === 'loading') {
|
| 416 |
document.addEventListener('DOMContentLoaded', init);
|
| 417 |
} else {
|
|
|
|
|
|
|
| 418 |
setTimeout(init, 100);
|
| 419 |
}
|
| 420 |
|