youssefreda9 commited on
Commit
7f53fe2
·
1 Parent(s): 7394487

fix(autocomplete): fix dropdown not showing - 3 critical bugs fixed

Browse files
Files changed (1) hide show
  1. 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
- // Position relative to editor's parent
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
- // Composition events (IME)
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 → reposition
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
- // ─── Selection Change Dismiss ──────────────────────────────────
108
- function onSelectionChange() {
109
- const sel = window.getSelection();
110
- if (!sel || !sel.isCollapsed) {
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
- default:
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 the editor has text
160
- const text = editorEl.innerText || editorEl.textContent || '';
161
- if (text.trim().length < MIN_CONTEXT_LEN) {
162
  dismiss();
163
  return;
164
  }
165
 
166
- // Extract context: text before cursor (last N chars)
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; // Pre-select first
226
 
227
- // Show ghost text (best suggestion)
228
- showGhost(suggestions[0]);
229
-
230
- // Build dropdown
231
  dropdownEl.innerHTML = '';
232
  suggestions.forEach(function (word, idx) {
233
- const item = document.createElement('div');
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 dropdown near caret
250
  positionDropdown();
251
  dropdownEl.style.display = 'block';
 
 
 
252
  }
253
 
254
  // ─── Ghost Text ──────────────────────────────────────────────────
255
  function showGhost(text) {
256
  if (!ghostEl || !text) return;
257
 
258
- // Get caret position relative to editor
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
- // Position ghost at caret
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
- const caretPos = getCaretCoordinates();
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 + 4) + 'px';
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
- // Ensure dropdown doesn't go off-screen
301
- const rect = dropdownEl.getBoundingClientRect();
 
 
302
  if (rect.bottom > window.innerHeight - 20) {
303
- // Show above caret instead
304
- dropdownEl.style.top = (caretPos.top - rect.height - 4) + 'px';
 
 
 
 
 
305
  }
306
  }
307
 
@@ -318,13 +318,12 @@
318
  }
319
 
320
  function updateDropdownSelection() {
321
- const items = dropdownEl.querySelectorAll('.ac-dropdown-item');
322
  items.forEach(function (item, idx) {
323
  item.classList.toggle('ac-selected', idx === selectedIndex);
324
  });
325
 
326
- // Scroll selected item into view
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
- const word = currentSuggestions[selectedIndex];
341
 
342
- // Insert the word at cursor position
343
- const sel = window.getSelection();
344
  if (!sel || !sel.rangeCount) {
345
  dismiss();
346
  return;
347
  }
348
 
349
- // Insert with a space before the word
350
- const textToInsert = word + ' ';
351
- const range = sel.getRangeAt(0);
352
  range.deleteContents();
353
- const textNode = document.createTextNode(textToInsert);
354
  range.insertNode(textNode);
355
 
356
- // Move caret to end of inserted text
357
  range.setStartAfter(textNode);
358
  range.setEndAfter(textNode);
359
  sel.removeAllRanges();
@@ -361,7 +359,7 @@
361
 
362
  dismiss();
363
 
364
- // Trigger input event so the editor knows text changed
365
  editorEl.dispatchEvent(new Event('input', { bubbles: true }));
366
  }
367
 
@@ -381,35 +379,40 @@
381
  return dropdownEl && dropdownEl.style.display !== 'none';
382
  }
383
 
384
- function getCaretCoordinates() {
385
- const sel = window.getSelection();
 
 
 
 
 
386
  if (!sel || !sel.rangeCount) return null;
387
 
388
  try {
389
- const range = sel.getRangeAt(0).cloneRange();
390
  range.collapse(true);
391
 
392
- // Use a zero-width space to get coordinates
393
- const span = document.createElement('span');
394
- span.textContent = '\u200B';
395
- range.insertNode(span);
396
-
397
- const rect = span.getBoundingClientRect();
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
- // Restore selection
409
- sel.removeAllRanges();
410
- sel.addRange(range);
 
 
411
 
412
- return coords;
 
 
 
 
 
 
 
413
  } catch (e) {
414
  return null;
415
  }
@@ -419,7 +422,9 @@
419
  if (document.readyState === 'loading') {
420
  document.addEventListener('DOMContentLoaded', init);
421
  } else {
422
- init();
 
 
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/resizedismiss
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
  })();