bayan-api / src /js /autocomplete.js
youssefreda9's picture
fix: add undo support for autocomplete suggestions
252b187
Raw
History Blame Contribute Delete
15.5 kB
/**
* AutoComplete Module — Ghost Text + Dropdown for Arabic autocomplete.
*
* COMPLETELY INDEPENDENT from the correction pipeline.
* It only talks to: /api/autocomplete (its own endpoint)
*/
(function () {
'use strict';
// ─── Configuration ───────────────────────────────────────────────
const DEBOUNCE_MS = 400;
const MIN_CONTEXT_LEN = 3;
const MAX_SUGGESTIONS = 3;
const CONTEXT_CHARS = 200;
// ─── State ───────────────────────────────────────────────────────
let ghostEl = null;
let dropdownEl = null;
let selectedIndex = -1;
let currentSuggestions = [];
let debounceTimer = null;
let isComposing = false;
let editorEl = null;
let _lastFetchId = 0;
// ─── Initialization ──────────────────────────────────────────────
function init() {
editorEl = document.getElementById('editor-container');
if (!editorEl) {
setTimeout(init, 500);
return;
}
createGhostElement();
createDropdownElement();
bindEvents();
console.log('[AutoComplete] Initialized');
}
// ─── Ghost Text Element ──────────────────────────────────────────
function createGhostElement() {
ghostEl = document.createElement('div');
ghostEl.id = 'autocomplete-ghost';
ghostEl.setAttribute('aria-hidden', 'true');
var editorParent = editorEl.parentElement;
if (editorParent) {
editorParent.style.position = 'relative';
editorParent.appendChild(ghostEl);
}
}
// ─── Dropdown Element ────────────────────────────────────────────
function createDropdownElement() {
dropdownEl = document.createElement('div');
dropdownEl.id = 'autocomplete-dropdown';
dropdownEl.setAttribute('role', 'listbox');
dropdownEl.setAttribute('aria-label', 'اقتراحات الإكمال التلقائي');
dropdownEl.style.display = 'none';
document.body.appendChild(dropdownEl);
}
// ─── Event Binding ───────────────────────────────────────────────
function bindEvents() {
editorEl.addEventListener('input', onInput);
editorEl.addEventListener('compositionstart', function () { isComposing = true; });
editorEl.addEventListener('compositionend', function () { isComposing = false; });
editorEl.addEventListener('keydown', onKeyDown);
// Click outside → dismiss
document.addEventListener('mousedown', function (e) {
if (dropdownEl && !dropdownEl.contains(e.target) && e.target !== editorEl) {
dismiss();
}
});
// Scroll/resize → dismiss
editorEl.addEventListener('scroll', dismiss);
window.addEventListener('resize', dismiss);
// Focus lost → dismiss (with delay for dropdown clicks)
editorEl.addEventListener('blur', function () {
setTimeout(function () {
if (document.activeElement !== editorEl) dismiss();
}, 200);
});
}
// ─── Input Handler ───────────────────────────────────────────────
function onInput() {
if (isComposing) return;
clearTimeout(debounceTimer);
hideGhost();
debounceTimer = setTimeout(fetchSuggestions, DEBOUNCE_MS);
}
// ─── Keyboard Handler ───────────────────────────────────────────
function onKeyDown(e) {
if (!isVisible()) return;
switch (e.key) {
case 'Tab':
e.preventDefault();
e.stopPropagation();
acceptSuggestion();
break;
case 'Escape':
e.preventDefault();
dismiss();
break;
case 'ArrowDown':
e.preventDefault();
navigateDropdown(1);
break;
case 'ArrowUp':
e.preventDefault();
navigateDropdown(-1);
break;
case 'Enter':
// If dropdown is visible, accept on Enter too
if (isVisible() && selectedIndex >= 0) {
e.preventDefault();
e.stopPropagation();
acceptSuggestion();
}
break;
}
}
// ─── Fetch Suggestions ───────────────────────────────────────────
async function fetchSuggestions() {
var fetchId = ++_lastFetchId;
var sel = window.getSelection();
if (!sel || !sel.isCollapsed || !sel.rangeCount) {
dismiss();
return;
}
// CRITICAL: Only show autocomplete when cursor is at END of text
// or at the end of a word (after a space or at document end)
var textAfterCursor = getTextAfterCursor();
if (textAfterCursor.length > 0 && textAfterCursor[0] !== ' ' && textAfterCursor[0] !== '\n') {
// Cursor is in the MIDDLE of a word — don't show autocomplete
dismiss();
return;
}
// Get context (text before cursor)
var context = getTextBeforeCursor(CONTEXT_CHARS);
if (!context || context.trim().length < MIN_CONTEXT_LEN) {
dismiss();
return;
}
// CRITICAL: Only trigger when the user just typed a SPACE
// (meaning they finished a word). Partial words like "عا" produce garbage.
var lastChar = context[context.length - 1];
if (lastChar !== ' ' && lastChar !== '\u00A0') {
// User is still typing a word — don't send partial word to backend
dismiss();
return;
}
// Trim trailing spaces for the API call (backend expects clean context)
var trimmed = context.trimEnd();
if (!trimmed || trimmed.length < MIN_CONTEXT_LEN) {
dismiss();
return;
}
try {
var resp = await fetch('/api/autocomplete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ context: trimmed, n: MAX_SUGGESTIONS })
});
if (fetchId !== _lastFetchId) return;
if (!resp.ok) { dismiss(); return; }
var data = await resp.json();
if (fetchId !== _lastFetchId) return;
if (data.status !== 'success' || !data.suggestions || !data.suggestions.length) {
dismiss();
return;
}
console.log('[AutoComplete] Suggestions for last word:', data.suggestions);
showSuggestions(data.suggestions);
} catch (err) {
console.warn('[AutoComplete] Fetch error:', err);
if (fetchId === _lastFetchId) dismiss();
}
}
// ─── Get Text Before Cursor ──────────────────────────────────────
function getTextBeforeCursor(maxChars) {
var sel = window.getSelection();
if (!sel || !sel.rangeCount) return '';
try {
var range = sel.getRangeAt(0);
var preRange = document.createRange();
preRange.selectNodeContents(editorEl);
preRange.setEnd(range.startContainer, range.startOffset);
var text = preRange.toString();
preRange.detach();
if (text.length <= maxChars) return text;
return text.slice(-maxChars);
} catch (e) {
return '';
}
}
// ─── Get Text After Cursor ───────────────────────────────────────
function getTextAfterCursor() {
var sel = window.getSelection();
if (!sel || !sel.rangeCount) return '';
try {
var range = sel.getRangeAt(0);
var postRange = document.createRange();
postRange.selectNodeContents(editorEl);
postRange.setStart(range.endContainer, range.endOffset);
var text = postRange.toString();
postRange.detach();
return text;
} catch (e) {
return '';
}
}
// ─── Show Suggestions ────────────────────────────────────────────
function showSuggestions(suggestions) {
currentSuggestions = suggestions;
selectedIndex = 0;
// Build dropdown items
dropdownEl.innerHTML = '';
suggestions.forEach(function (word, idx) {
var item = document.createElement('div');
item.className = 'ac-dropdown-item' + (idx === 0 ? ' ac-selected' : '');
item.setAttribute('role', 'option');
item.textContent = word;
item.addEventListener('mousedown', function (e) {
e.preventDefault();
e.stopPropagation();
selectedIndex = idx;
acceptSuggestion();
});
item.addEventListener('mouseenter', function () {
selectedIndex = idx;
updateDropdownSelection();
});
dropdownEl.appendChild(item);
});
// Position and show dropdown BELOW the caret, aligned to caret position
positionDropdown();
dropdownEl.style.display = 'block';
// Show ghost text inline
showGhost(suggestions[0]);
}
// ─── Ghost Text ──────────────────────────────────────────────────
function showGhost(text) {
if (!ghostEl || !text) return;
var caretPos = getCaretCoordinates();
if (!caretPos) { hideGhost(); return; }
ghostEl.textContent = text;
ghostEl.style.display = 'block';
var parentRect = editorEl.parentElement.getBoundingClientRect();
// Position ghost at caret — for RTL, text appears to the LEFT of caret
ghostEl.style.top = (caretPos.top - parentRect.top) + 'px';
// Use left positioning (place ghost just left of the caret in RTL)
ghostEl.style.left = 'auto';
ghostEl.style.right = (parentRect.right - caretPos.right + 2) + 'px';
}
function hideGhost() {
if (ghostEl) {
ghostEl.style.display = 'none';
ghostEl.textContent = '';
}
}
// ─── Dropdown Position ───────────────────────────────────────────
function positionDropdown() {
var caretPos = getCaretCoordinates();
if (!caretPos) return;
// Use fixed positioning relative to viewport
dropdownEl.style.position = 'fixed';
// Place BELOW the caret line
var topPos = caretPos.bottom + 6;
dropdownEl.style.top = topPos + 'px';
// For RTL: align dropdown's RIGHT edge to the caret position
// Use LEFT positioning to place the dropdown starting at the caret X
var leftPos = caretPos.left - 160; // dropdown is ~160px wide, align right edge to caret
if (leftPos < 10) leftPos = 10;
dropdownEl.style.left = leftPos + 'px';
dropdownEl.style.right = 'auto';
// Check if dropdown goes off-screen bottom
requestAnimationFrame(function () {
var rect = dropdownEl.getBoundingClientRect();
if (rect.bottom > window.innerHeight - 20) {
dropdownEl.style.top = (caretPos.top - rect.height - 6) + 'px';
}
});
}
// ─── Dropdown Navigation ─────────────────────────────────────────
function navigateDropdown(direction) {
if (!currentSuggestions.length) return;
selectedIndex += direction;
if (selectedIndex < 0) selectedIndex = currentSuggestions.length - 1;
if (selectedIndex >= currentSuggestions.length) selectedIndex = 0;
updateDropdownSelection();
showGhost(currentSuggestions[selectedIndex]);
}
function updateDropdownSelection() {
var items = dropdownEl.querySelectorAll('.ac-dropdown-item');
items.forEach(function (item, idx) {
item.classList.toggle('ac-selected', idx === selectedIndex);
});
var selected = dropdownEl.querySelector('.ac-selected');
if (selected) selected.scrollIntoView({ block: 'nearest' });
}
// ─── Accept Suggestion ───────────────────────────────────────────
function acceptSuggestion() {
if (selectedIndex < 0 || selectedIndex >= currentSuggestions.length) {
dismiss();
return;
}
var word = currentSuggestions[selectedIndex];
var sel = window.getSelection();
if (!sel || !sel.rangeCount) {
dismiss();
return;
}
// Determine if we need a space before the word
var textBefore = getTextBeforeCursor(10);
var needsSpaceBefore = textBefore.length > 0 && !textBefore.endsWith(' ') && !textBefore.endsWith('\n');
// Build the text to insert: [optional space] + word + space
var textToInsert = (needsSpaceBefore ? ' ' : '') + word + ' ';
// Save undo state before inserting
if (typeof pushUndoState === 'function') pushUndoState();
// Use execCommand for reliable insertion in contenteditable
// This preserves undo history and handles cursor position correctly
document.execCommand('insertText', false, textToInsert);
dismiss();
}
// ─── Dismiss ─────────────────────────────────────────────────────
function dismiss() {
hideGhost();
currentSuggestions = [];
selectedIndex = -1;
if (dropdownEl) {
dropdownEl.style.display = 'none';
dropdownEl.innerHTML = '';
}
}
// ─── Helpers ─────────────────────────────────────────────────────
function isVisible() {
return dropdownEl && dropdownEl.style.display !== 'none';
}
/**
* Get caret coordinates using Range.getClientRects() — NO DOM mutation.
*/
function getCaretCoordinates() {
var sel = window.getSelection();
if (!sel || !sel.rangeCount) return null;
try {
var range = sel.getRangeAt(0).cloneRange();
range.collapse(true);
// Try getClientRects first
var rects = range.getClientRects();
if (rects.length > 0) {
var r = rects[0];
return { top: r.top, left: r.left, bottom: r.bottom, right: r.right };
}
// Fallback: use getBoundingClientRect
var bRect = range.getBoundingClientRect();
if (bRect && (bRect.top !== 0 || bRect.left !== 0)) {
return { top: bRect.top, left: bRect.left, bottom: bRect.bottom, right: bRect.right };
}
// Last resort: use editor position
var editorRect = editorEl.getBoundingClientRect();
return {
top: editorRect.top + 20,
left: editorRect.right - 20,
bottom: editorRect.top + 44,
right: editorRect.right
};
} catch (e) {
return null;
}
}
// ─── Initialize ──────────────────────────────────────────────────
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
setTimeout(init, 100);
}
})();