Commit ·
7f53fe2
1
Parent(s): 7394487
fix(autocomplete): fix dropdown not showing - 3 critical bugs fixed
Browse files- src/js/autocomplete.js +101 -96
src/js/autocomplete.js
CHANGED
|
@@ -28,12 +28,13 @@
|
|
| 28 |
let debounceTimer = null;
|
| 29 |
let isComposing = false;
|
| 30 |
let editorEl = null;
|
|
|
|
|
|
|
| 31 |
|
| 32 |
// ─── Initialization ──────────────────────────────────────────────
|
| 33 |
function init() {
|
| 34 |
editorEl = document.getElementById('editor-container');
|
| 35 |
if (!editorEl) {
|
| 36 |
-
// Retry after DOM is ready
|
| 37 |
setTimeout(init, 500);
|
| 38 |
return;
|
| 39 |
}
|
|
@@ -41,7 +42,7 @@
|
|
| 41 |
createGhostElement();
|
| 42 |
createDropdownElement();
|
| 43 |
bindEvents();
|
| 44 |
-
console.log('[AutoComplete] Initialized');
|
| 45 |
}
|
| 46 |
|
| 47 |
// ─── Ghost Text Element ──────────────────────────────────────────
|
|
@@ -49,7 +50,7 @@
|
|
| 49 |
ghostEl = document.createElement('div');
|
| 50 |
ghostEl.id = 'autocomplete-ghost';
|
| 51 |
ghostEl.setAttribute('aria-hidden', 'true');
|
| 52 |
-
//
|
| 53 |
const editorParent = editorEl.parentElement;
|
| 54 |
if (editorParent) {
|
| 55 |
editorParent.style.position = 'relative';
|
|
@@ -69,19 +70,11 @@
|
|
| 69 |
|
| 70 |
// ─── Event Binding ───────────────────────────────────────────────
|
| 71 |
function bindEvents() {
|
| 72 |
-
// Typing → debounced autocomplete
|
| 73 |
editorEl.addEventListener('input', onInput);
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
editorEl.addEventListener('compositionstart', () => { isComposing = true; });
|
| 77 |
-
editorEl.addEventListener('compositionend', () => { isComposing = false; });
|
| 78 |
-
|
| 79 |
-
// Keyboard: TAB accept, ESC dismiss, arrow navigation
|
| 80 |
editorEl.addEventListener('keydown', onKeyDown);
|
| 81 |
|
| 82 |
-
// Cursor movement / selection change → dismiss
|
| 83 |
-
document.addEventListener('selectionchange', onSelectionChange);
|
| 84 |
-
|
| 85 |
// Click outside → dismiss
|
| 86 |
document.addEventListener('mousedown', function (e) {
|
| 87 |
if (dropdownEl && !dropdownEl.contains(e.target) && e.target !== editorEl) {
|
|
@@ -89,30 +82,32 @@
|
|
| 89 |
}
|
| 90 |
});
|
| 91 |
|
| 92 |
-
// Scroll →
|
| 93 |
editorEl.addEventListener('scroll', dismiss);
|
| 94 |
-
|
| 95 |
-
// Window resize → dismiss
|
| 96 |
window.addEventListener('resize', dismiss);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
}
|
| 98 |
|
| 99 |
// ─── Input Handler ───────────────────────────────────────────────
|
| 100 |
function onInput() {
|
| 101 |
if (isComposing) return;
|
| 102 |
|
|
|
|
| 103 |
clearTimeout(debounceTimer);
|
| 104 |
-
debounceTimer = setTimeout(fetchSuggestions, DEBOUNCE_MS);
|
| 105 |
-
}
|
| 106 |
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
dismiss();
|
| 112 |
-
return;
|
| 113 |
-
}
|
| 114 |
-
// If cursor moved (not from typing), dismiss
|
| 115 |
-
// We rely on the debounce to re-trigger if user is still typing
|
| 116 |
}
|
| 117 |
|
| 118 |
// ─── Keyboard Handler ───────────────────────────────────────────
|
|
@@ -122,6 +117,7 @@
|
|
| 122 |
switch (e.key) {
|
| 123 |
case 'Tab':
|
| 124 |
e.preventDefault();
|
|
|
|
| 125 |
acceptSuggestion();
|
| 126 |
break;
|
| 127 |
|
|
@@ -140,30 +136,28 @@
|
|
| 140 |
navigateDropdown(-1);
|
| 141 |
break;
|
| 142 |
|
| 143 |
-
|
| 144 |
-
// Any other key → will trigger onInput → new debounce
|
| 145 |
-
// Dismiss current ghost immediately for responsiveness
|
| 146 |
-
hideGhost();
|
| 147 |
-
break;
|
| 148 |
}
|
| 149 |
}
|
| 150 |
|
| 151 |
// ─── Fetch Suggestions ───────────────────────────────────────────
|
| 152 |
async function fetchSuggestions() {
|
|
|
|
|
|
|
| 153 |
const sel = window.getSelection();
|
| 154 |
if (!sel || !sel.isCollapsed || !sel.rangeCount) {
|
| 155 |
dismiss();
|
| 156 |
return;
|
| 157 |
}
|
| 158 |
|
| 159 |
-
// Check
|
| 160 |
-
const
|
| 161 |
-
if (
|
| 162 |
dismiss();
|
| 163 |
return;
|
| 164 |
}
|
| 165 |
|
| 166 |
-
// Extract context
|
| 167 |
const context = getTextBeforeCursor(CONTEXT_CHARS);
|
| 168 |
if (!context || context.trim().length < MIN_CONTEXT_LEN) {
|
| 169 |
dismiss();
|
|
@@ -174,28 +168,31 @@
|
|
| 174 |
const resp = await fetch('/api/autocomplete', {
|
| 175 |
method: 'POST',
|
| 176 |
headers: { 'Content-Type': 'application/json' },
|
| 177 |
-
body: JSON.stringify({
|
| 178 |
-
context: context,
|
| 179 |
-
n: MAX_SUGGESTIONS
|
| 180 |
-
})
|
| 181 |
});
|
| 182 |
|
|
|
|
|
|
|
|
|
|
| 183 |
if (!resp.ok) {
|
| 184 |
dismiss();
|
| 185 |
return;
|
| 186 |
}
|
| 187 |
|
| 188 |
const data = await resp.json();
|
|
|
|
|
|
|
| 189 |
if (data.status !== 'success' || !data.suggestions || !data.suggestions.length) {
|
| 190 |
dismiss();
|
| 191 |
return;
|
| 192 |
}
|
| 193 |
|
|
|
|
| 194 |
showSuggestions(data.suggestions);
|
| 195 |
|
| 196 |
} catch (err) {
|
| 197 |
console.warn('[AutoComplete] Fetch error:', err);
|
| 198 |
-
dismiss();
|
| 199 |
}
|
| 200 |
}
|
| 201 |
|
|
@@ -222,20 +219,18 @@
|
|
| 222 |
// ─── Show Suggestions ────────────────────────────────────────────
|
| 223 |
function showSuggestions(suggestions) {
|
| 224 |
currentSuggestions = suggestions;
|
| 225 |
-
selectedIndex = 0;
|
| 226 |
|
| 227 |
-
//
|
| 228 |
-
showGhost(suggestions[0]);
|
| 229 |
-
|
| 230 |
-
// Build dropdown
|
| 231 |
dropdownEl.innerHTML = '';
|
| 232 |
suggestions.forEach(function (word, idx) {
|
| 233 |
-
|
| 234 |
item.className = 'ac-dropdown-item' + (idx === 0 ? ' ac-selected' : '');
|
| 235 |
item.setAttribute('role', 'option');
|
| 236 |
item.textContent = word;
|
| 237 |
item.addEventListener('mousedown', function (e) {
|
| 238 |
e.preventDefault();
|
|
|
|
| 239 |
selectedIndex = idx;
|
| 240 |
acceptSuggestion();
|
| 241 |
});
|
|
@@ -246,17 +241,19 @@
|
|
| 246 |
dropdownEl.appendChild(item);
|
| 247 |
});
|
| 248 |
|
| 249 |
-
// Position
|
| 250 |
positionDropdown();
|
| 251 |
dropdownEl.style.display = 'block';
|
|
|
|
|
|
|
|
|
|
| 252 |
}
|
| 253 |
|
| 254 |
// ─── Ghost Text ──────────────────────────────────────────────────
|
| 255 |
function showGhost(text) {
|
| 256 |
if (!ghostEl || !text) return;
|
| 257 |
|
| 258 |
-
|
| 259 |
-
const caretPos = getCaretCoordinates();
|
| 260 |
if (!caretPos) {
|
| 261 |
hideGhost();
|
| 262 |
return;
|
|
@@ -265,9 +262,7 @@
|
|
| 265 |
ghostEl.textContent = text;
|
| 266 |
ghostEl.style.display = 'block';
|
| 267 |
|
| 268 |
-
|
| 269 |
-
const editorRect = editorEl.getBoundingClientRect();
|
| 270 |
-
const parentRect = editorEl.parentElement.getBoundingClientRect();
|
| 271 |
|
| 272 |
// RTL: ghost appears to the LEFT of the caret
|
| 273 |
ghostEl.style.top = (caretPos.top - parentRect.top) + 'px';
|
|
@@ -284,24 +279,29 @@
|
|
| 284 |
|
| 285 |
// ─── Dropdown Position ───────────────────────────────────────────
|
| 286 |
function positionDropdown() {
|
| 287 |
-
|
| 288 |
if (!caretPos) return;
|
| 289 |
|
| 290 |
-
const lineHeight = parseInt(getComputedStyle(editorEl).lineHeight) || 24;
|
| 291 |
-
|
| 292 |
// Position below caret
|
| 293 |
dropdownEl.style.position = 'fixed';
|
| 294 |
-
dropdownEl.style.top = (caretPos.bottom +
|
| 295 |
|
| 296 |
// RTL: align to the right of caret
|
| 297 |
dropdownEl.style.right = (window.innerWidth - caretPos.left) + 'px';
|
| 298 |
dropdownEl.style.left = 'auto';
|
| 299 |
|
| 300 |
-
//
|
| 301 |
-
|
|
|
|
|
|
|
| 302 |
if (rect.bottom > window.innerHeight - 20) {
|
| 303 |
-
|
| 304 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 305 |
}
|
| 306 |
}
|
| 307 |
|
|
@@ -318,13 +318,12 @@
|
|
| 318 |
}
|
| 319 |
|
| 320 |
function updateDropdownSelection() {
|
| 321 |
-
|
| 322 |
items.forEach(function (item, idx) {
|
| 323 |
item.classList.toggle('ac-selected', idx === selectedIndex);
|
| 324 |
});
|
| 325 |
|
| 326 |
-
|
| 327 |
-
const selected = dropdownEl.querySelector('.ac-selected');
|
| 328 |
if (selected) {
|
| 329 |
selected.scrollIntoView({ block: 'nearest' });
|
| 330 |
}
|
|
@@ -337,23 +336,22 @@
|
|
| 337 |
return;
|
| 338 |
}
|
| 339 |
|
| 340 |
-
|
| 341 |
|
| 342 |
-
|
| 343 |
-
const sel = window.getSelection();
|
| 344 |
if (!sel || !sel.rangeCount) {
|
| 345 |
dismiss();
|
| 346 |
return;
|
| 347 |
}
|
| 348 |
|
| 349 |
-
// Insert
|
| 350 |
-
|
| 351 |
-
|
| 352 |
range.deleteContents();
|
| 353 |
-
|
| 354 |
range.insertNode(textNode);
|
| 355 |
|
| 356 |
-
// Move caret
|
| 357 |
range.setStartAfter(textNode);
|
| 358 |
range.setEndAfter(textNode);
|
| 359 |
sel.removeAllRanges();
|
|
@@ -361,7 +359,7 @@
|
|
| 361 |
|
| 362 |
dismiss();
|
| 363 |
|
| 364 |
-
//
|
| 365 |
editorEl.dispatchEvent(new Event('input', { bubbles: true }));
|
| 366 |
}
|
| 367 |
|
|
@@ -381,35 +379,40 @@
|
|
| 381 |
return dropdownEl && dropdownEl.style.display !== 'none';
|
| 382 |
}
|
| 383 |
|
| 384 |
-
|
| 385 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
if (!sel || !sel.rangeCount) return null;
|
| 387 |
|
| 388 |
try {
|
| 389 |
-
|
| 390 |
range.collapse(true);
|
| 391 |
|
| 392 |
-
//
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
const coords = {
|
| 399 |
-
top: rect.top,
|
| 400 |
-
left: rect.left,
|
| 401 |
-
bottom: rect.bottom,
|
| 402 |
-
right: rect.right
|
| 403 |
-
};
|
| 404 |
-
|
| 405 |
-
// Clean up
|
| 406 |
-
span.parentNode.removeChild(span);
|
| 407 |
|
| 408 |
-
//
|
| 409 |
-
|
| 410 |
-
|
|
|
|
|
|
|
| 411 |
|
| 412 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
} catch (e) {
|
| 414 |
return null;
|
| 415 |
}
|
|
@@ -419,7 +422,9 @@
|
|
| 419 |
if (document.readyState === 'loading') {
|
| 420 |
document.addEventListener('DOMContentLoaded', init);
|
| 421 |
} else {
|
| 422 |
-
|
|
|
|
|
|
|
| 423 |
}
|
| 424 |
|
| 425 |
})();
|
|
|
|
| 28 |
let debounceTimer = null;
|
| 29 |
let isComposing = false;
|
| 30 |
let editorEl = null;
|
| 31 |
+
let _suppressSelectionChange = false;
|
| 32 |
+
let _lastFetchId = 0;
|
| 33 |
|
| 34 |
// ─── Initialization ──────────────────────────────────────────────
|
| 35 |
function init() {
|
| 36 |
editorEl = document.getElementById('editor-container');
|
| 37 |
if (!editorEl) {
|
|
|
|
| 38 |
setTimeout(init, 500);
|
| 39 |
return;
|
| 40 |
}
|
|
|
|
| 42 |
createGhostElement();
|
| 43 |
createDropdownElement();
|
| 44 |
bindEvents();
|
| 45 |
+
console.log('[AutoComplete] Initialized — editor element found');
|
| 46 |
}
|
| 47 |
|
| 48 |
// ─── Ghost Text Element ──────────────────────────────────────────
|
|
|
|
| 50 |
ghostEl = document.createElement('div');
|
| 51 |
ghostEl.id = 'autocomplete-ghost';
|
| 52 |
ghostEl.setAttribute('aria-hidden', 'true');
|
| 53 |
+
// Append to editor's parent for relative positioning
|
| 54 |
const editorParent = editorEl.parentElement;
|
| 55 |
if (editorParent) {
|
| 56 |
editorParent.style.position = 'relative';
|
|
|
|
| 70 |
|
| 71 |
// ─── Event Binding ───────────────────────────────────────────────
|
| 72 |
function bindEvents() {
|
|
|
|
| 73 |
editorEl.addEventListener('input', onInput);
|
| 74 |
+
editorEl.addEventListener('compositionstart', function () { isComposing = true; });
|
| 75 |
+
editorEl.addEventListener('compositionend', function () { isComposing = false; });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
editorEl.addEventListener('keydown', onKeyDown);
|
| 77 |
|
|
|
|
|
|
|
|
|
|
| 78 |
// Click outside → dismiss
|
| 79 |
document.addEventListener('mousedown', function (e) {
|
| 80 |
if (dropdownEl && !dropdownEl.contains(e.target) && e.target !== editorEl) {
|
|
|
|
| 82 |
}
|
| 83 |
});
|
| 84 |
|
| 85 |
+
// Scroll/resize → dismiss
|
| 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 |
}
|
| 99 |
|
| 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 |
|
| 113 |
// ─── Keyboard Handler ───────────────────────────────────────────
|
|
|
|
| 117 |
switch (e.key) {
|
| 118 |
case 'Tab':
|
| 119 |
e.preventDefault();
|
| 120 |
+
e.stopPropagation();
|
| 121 |
acceptSuggestion();
|
| 122 |
break;
|
| 123 |
|
|
|
|
| 136 |
navigateDropdown(-1);
|
| 137 |
break;
|
| 138 |
|
| 139 |
+
// Don't dismiss on other keys — let onInput handle the debounce cycle
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
}
|
| 141 |
}
|
| 142 |
|
| 143 |
// ─── Fetch Suggestions ───────────────────────────────────────────
|
| 144 |
async function fetchSuggestions() {
|
| 145 |
+
const fetchId = ++_lastFetchId;
|
| 146 |
+
|
| 147 |
const sel = window.getSelection();
|
| 148 |
if (!sel || !sel.isCollapsed || !sel.rangeCount) {
|
| 149 |
dismiss();
|
| 150 |
return;
|
| 151 |
}
|
| 152 |
|
| 153 |
+
// Check editor has enough text
|
| 154 |
+
const fullText = editorEl.innerText || editorEl.textContent || '';
|
| 155 |
+
if (fullText.trim().length < MIN_CONTEXT_LEN) {
|
| 156 |
dismiss();
|
| 157 |
return;
|
| 158 |
}
|
| 159 |
|
| 160 |
+
// Extract context before cursor
|
| 161 |
const context = getTextBeforeCursor(CONTEXT_CHARS);
|
| 162 |
if (!context || context.trim().length < MIN_CONTEXT_LEN) {
|
| 163 |
dismiss();
|
|
|
|
| 168 |
const resp = await fetch('/api/autocomplete', {
|
| 169 |
method: 'POST',
|
| 170 |
headers: { 'Content-Type': 'application/json' },
|
| 171 |
+
body: JSON.stringify({ context: context, n: MAX_SUGGESTIONS })
|
|
|
|
|
|
|
|
|
|
| 172 |
});
|
| 173 |
|
| 174 |
+
// Stale response check — if another fetch started, ignore this one
|
| 175 |
+
if (fetchId !== _lastFetchId) return;
|
| 176 |
+
|
| 177 |
if (!resp.ok) {
|
| 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) {
|
| 186 |
dismiss();
|
| 187 |
return;
|
| 188 |
}
|
| 189 |
|
| 190 |
+
console.log('[AutoComplete] Showing suggestions:', data.suggestions);
|
| 191 |
showSuggestions(data.suggestions);
|
| 192 |
|
| 193 |
} catch (err) {
|
| 194 |
console.warn('[AutoComplete] Fetch error:', err);
|
| 195 |
+
if (fetchId === _lastFetchId) dismiss();
|
| 196 |
}
|
| 197 |
}
|
| 198 |
|
|
|
|
| 219 |
// ─── Show Suggestions ────────────────────────────────────────────
|
| 220 |
function showSuggestions(suggestions) {
|
| 221 |
currentSuggestions = suggestions;
|
| 222 |
+
selectedIndex = 0;
|
| 223 |
|
| 224 |
+
// Build dropdown items
|
|
|
|
|
|
|
|
|
|
| 225 |
dropdownEl.innerHTML = '';
|
| 226 |
suggestions.forEach(function (word, idx) {
|
| 227 |
+
var item = document.createElement('div');
|
| 228 |
item.className = 'ac-dropdown-item' + (idx === 0 ? ' ac-selected' : '');
|
| 229 |
item.setAttribute('role', 'option');
|
| 230 |
item.textContent = word;
|
| 231 |
item.addEventListener('mousedown', function (e) {
|
| 232 |
e.preventDefault();
|
| 233 |
+
e.stopPropagation();
|
| 234 |
selectedIndex = idx;
|
| 235 |
acceptSuggestion();
|
| 236 |
});
|
|
|
|
| 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 |
|
| 252 |
// ─── Ghost Text ──────────────────────────────────────────────────
|
| 253 |
function showGhost(text) {
|
| 254 |
if (!ghostEl || !text) return;
|
| 255 |
|
| 256 |
+
var caretPos = getCaretCoordinatesSimple();
|
|
|
|
| 257 |
if (!caretPos) {
|
| 258 |
hideGhost();
|
| 259 |
return;
|
|
|
|
| 262 |
ghostEl.textContent = text;
|
| 263 |
ghostEl.style.display = 'block';
|
| 264 |
|
| 265 |
+
var parentRect = editorEl.parentElement.getBoundingClientRect();
|
|
|
|
|
|
|
| 266 |
|
| 267 |
// RTL: ghost appears to the LEFT of the caret
|
| 268 |
ghostEl.style.top = (caretPos.top - parentRect.top) + 'px';
|
|
|
|
| 279 |
|
| 280 |
// ─── Dropdown Position ───────────────────────────────────────────
|
| 281 |
function positionDropdown() {
|
| 282 |
+
var caretPos = getCaretCoordinatesSimple();
|
| 283 |
if (!caretPos) return;
|
| 284 |
|
|
|
|
|
|
|
| 285 |
// Position below caret
|
| 286 |
dropdownEl.style.position = 'fixed';
|
| 287 |
+
dropdownEl.style.top = (caretPos.bottom + 6) + 'px';
|
| 288 |
|
| 289 |
// RTL: align to the right of caret
|
| 290 |
dropdownEl.style.right = (window.innerWidth - caretPos.left) + 'px';
|
| 291 |
dropdownEl.style.left = 'auto';
|
| 292 |
|
| 293 |
+
// Force layout to get actual dimensions
|
| 294 |
+
var rect = dropdownEl.getBoundingClientRect();
|
| 295 |
+
|
| 296 |
+
// If dropdown goes off-screen bottom, show above caret
|
| 297 |
if (rect.bottom > window.innerHeight - 20) {
|
| 298 |
+
dropdownEl.style.top = (caretPos.top - rect.height - 6) + 'px';
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
// If dropdown goes off-screen right (RTL), adjust
|
| 302 |
+
if (rect.left < 10) {
|
| 303 |
+
dropdownEl.style.right = 'auto';
|
| 304 |
+
dropdownEl.style.left = '10px';
|
| 305 |
}
|
| 306 |
}
|
| 307 |
|
|
|
|
| 318 |
}
|
| 319 |
|
| 320 |
function updateDropdownSelection() {
|
| 321 |
+
var items = dropdownEl.querySelectorAll('.ac-dropdown-item');
|
| 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 |
}
|
|
|
|
| 336 |
return;
|
| 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 |
+
// Insert word + space at cursor
|
| 348 |
+
var textToInsert = word + ' ';
|
| 349 |
+
var range = sel.getRangeAt(0);
|
| 350 |
range.deleteContents();
|
| 351 |
+
var textNode = document.createTextNode(textToInsert);
|
| 352 |
range.insertNode(textNode);
|
| 353 |
|
| 354 |
+
// Move caret after inserted text
|
| 355 |
range.setStartAfter(textNode);
|
| 356 |
range.setEndAfter(textNode);
|
| 357 |
sel.removeAllRanges();
|
|
|
|
| 359 |
|
| 360 |
dismiss();
|
| 361 |
|
| 362 |
+
// Notify editor that content changed
|
| 363 |
editorEl.dispatchEvent(new Event('input', { bubbles: true }));
|
| 364 |
}
|
| 365 |
|
|
|
|
| 379 |
return dropdownEl && dropdownEl.style.display !== 'none';
|
| 380 |
}
|
| 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 getCaretCoordinatesSimple() {
|
| 388 |
+
var sel = window.getSelection();
|
| 389 |
if (!sel || !sel.rangeCount) return null;
|
| 390 |
|
| 391 |
try {
|
| 392 |
+
var range = sel.getRangeAt(0).cloneRange();
|
| 393 |
range.collapse(true);
|
| 394 |
|
| 395 |
+
// Try getClientRects first (works when caret is inside a text node)
|
| 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 the range's bounding rect
|
| 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 + some offset
|
| 409 |
+
var editorRect = editorEl.getBoundingClientRect();
|
| 410 |
+
return {
|
| 411 |
+
top: editorRect.top + 20,
|
| 412 |
+
left: editorRect.right - 20,
|
| 413 |
+
bottom: editorRect.top + 44,
|
| 414 |
+
right: editorRect.right
|
| 415 |
+
};
|
| 416 |
} catch (e) {
|
| 417 |
return null;
|
| 418 |
}
|
|
|
|
| 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 |
|
| 430 |
})();
|