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

fix(autocomplete): 3 user-reported issues - last word only, dropdown position, cursor after accept

Browse files
Files changed (1) hide show
  1. 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 — editor element found');
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
- // Append to editor's parent for relative positioning
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
- // 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();
164
  return;
165
  }
166
 
 
 
 
 
 
 
 
167
  try {
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) {
@@ -187,7 +178,7 @@
187
  return;
188
  }
189
 
190
- console.log('[AutoComplete] Showing suggestions:', data.suggestions);
191
  showSuggestions(data.suggestions);
192
 
193
  } catch (err) {
@@ -198,17 +189,16 @@
198
 
199
  // ─── Get Text Before Cursor ──────────────────────────────────────
200
  function getTextBeforeCursor(maxChars) {
201
- const sel = window.getSelection();
202
  if (!sel || !sel.rangeCount) return '';
203
 
204
  try {
205
- const range = sel.getRangeAt(0);
206
- const preRange = document.createRange();
207
  preRange.selectNodeContents(editorEl);
208
  preRange.setEnd(range.startContainer, range.startOffset);
209
- const text = preRange.toString();
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 = getCaretCoordinatesSimple();
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
- // RTL: ghost appears to the LEFT of the caret
268
  ghostEl.style.top = (caretPos.top - parentRect.top) + 'px';
269
- ghostEl.style.right = (parentRect.right - caretPos.left + 4) + 'px';
270
  ghostEl.style.left = 'auto';
 
271
  }
272
 
273
  function hideGhost() {
@@ -279,40 +285,38 @@
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
 
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
- // 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();
358
- sel.addRange(range);
359
 
360
- dismiss();
 
 
361
 
362
- // Notify editor that content changed
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 getCaretCoordinatesSimple() {
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 (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,
@@ -418,12 +411,10 @@
418
  }
419
  }
420
 
421
- // ─── Initialize on DOM ready ─────────────────────────────────────
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