// Prevent double-install (if injected by manifest + programmatically) if (window.__dewemoji_cs_installed) { // already installed in this page context // Optional: console.debug('[Dewemoji] content.js already installed'); } else { window.__dewemoji_cs_installed = true; // (keep the rest of your file below as-is) // Throttle to animation frame to avoid excessive work on rapid events function rafThrottle(fn){ let scheduled = false; return function(...args){ if (scheduled) return; scheduled = true; requestAnimationFrame(() => { scheduled = false; fn.apply(this,args); }); }; } let lastEditable = null; // HTMLElement (input/textarea/contenteditable root) let lastRange = null; // Range for contenteditable let lastStart = 0, lastEnd = 0; let lastInsertOK = null; // true | false | null let lastInsertMessage = ""; // short note like "Inserted into input" let _insertingLock = false; // Find nearest contenteditable root function nearestEditableRoot(node) { let el = node instanceof Element ? node : node?.parentElement; while (el) { if (el.isContentEditable) return el; el = el.parentElement; } return null; } function isTextInput(el) { return el && ( el.tagName === 'TEXTAREA' || (el.tagName === 'INPUT' && /^(text|search|email|url|tel|password|number)?$/i.test(el.type)) ); } function captureCaret() { const active = document.activeElement; if (isTextInput(active)) { lastEditable = active; lastStart = active.selectionStart ?? active.value.length; lastEnd = active.selectionEnd ?? active.value.length; lastRange = null; // not used for inputs return; } const root = nearestEditableRoot(active); if (root) { lastEditable = root; const sel = window.getSelection(); if (sel && sel.rangeCount > 0) { // clone so we can restore after panel steals focus lastRange = sel.getRangeAt(0).cloneRange(); } return; } // Not in an editable element lastEditable = null; lastRange = null; } captureCaret(); // Use a non-throttled handler for focusin so we snapshot *before* focus leaves to the side panel. document.addEventListener('focusin', captureCaret, true); // Throttled handlers for noisy updates const captureCaretThrottled = rafThrottle(captureCaret); document.addEventListener('keyup', captureCaretThrottled, true); document.addEventListener('mouseup', captureCaretThrottled, true); document.addEventListener('selectionchange', captureCaretThrottled, true); // Extra safety: when the page is about to hide (e.g., side panel opens), keep the last known caret document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') captureCaret(); }, true); // Attempt to insert text at the last saved position function insertAtCaret(text) { if (!text) return false; // guard: ignore if a prior insert is still finishing if (_insertingLock) return false; _insertingLock = true; setTimeout(() => { _insertingLock = false; }, 180); // --- RECOVERY: if we don't have a saved target, try to rebuild from current selection if (!lastEditable || !document.contains(lastEditable)) { const sel = window.getSelection && window.getSelection(); if (sel && sel.rangeCount > 0) { const root = nearestEditableRoot(sel.anchorNode); if (root) { lastEditable = root; lastRange = sel.getRangeAt(0).cloneRange(); } } } if (!lastEditable || !document.contains(lastEditable)){ lastInsertOK = false; lastInsertMessage = "No editable target"; return false; }; // Re-focus target first (side panel blurred it) lastEditable.focus(); // (A) Plain inputs / textareas if (isTextInput(lastEditable)) { const el = lastEditable; const val = el.value ?? ''; const start = el.selectionStart ?? lastStart ?? val.length; const end = el.selectionEnd ?? lastEnd ?? start; el.value = val.slice(0, start) + text + val.slice(end); const pos = start + text.length; try { el.setSelectionRange(pos, pos); } catch {} el.dispatchEvent(new Event('input', { bubbles: true })); // console.log('[Dewemoji DEBUG] Inserted into input/textarea'); // when insertion succeeds in input/textarea lastInsertOK = true; lastInsertMessage = "Inserted into input/textarea"; return true; } // (B) contenteditable roots (Lexical/Draft/React) const sel = window.getSelection(); sel.removeAllRanges(); if (lastRange) { // restore exact caret const r = lastRange.cloneRange(); sel.addRange(r); } else { // fallback: caret at end of root const r = document.createRange(); r.selectNodeContents(lastEditable); r.collapse(false); sel.addRange(r); } // First try execCommand (still supported by many editors) let insertedCE = document.execCommand('insertText', false, text); if (!insertedCE) { // Manual Range insertion if (sel.rangeCount > 0) { const r = sel.getRangeAt(0); r.deleteContents(); r.insertNode(document.createTextNode(text)); // move caret to end r.setStart(r.endContainer, r.endOffset); r.collapse(true); sel.removeAllRanges(); sel.addRange(r); insertedCE = true; } } try { lastEditable.dispatchEvent(new InputEvent('input', { data: text, inputType: 'insertText', bubbles: true, cancelable: true, composed: true })); } catch { lastEditable.dispatchEvent(new Event('input', { bubbles: true })); } captureCaret(); // refresh snapshot // mark success if we inserted either way if (insertedCE) { lastInsertOK = true; lastInsertMessage = "Inserted into contenteditable"; return true; } // if we got here, we didn't manage to insert lastInsertOK = false; lastInsertMessage = "Insert failed in contenteditable"; return false; } // Message bridge chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => { if (msg?.type === 'dewemoji_ping') { sendResponse({ ok: true }); return; } if (msg?.type === 'dewemoji_insert') { const ok = insertAtCaret(msg.text); sendResponse({ ok }); return; } if (msg?.type === 'dewemoji_diag') { // Report what we currently know const active = document.activeElement; const type = active && (active.tagName === 'TEXTAREA' || active.tagName === 'INPUT') ? active.tagName.toLowerCase() : (active && active.isContentEditable) ? 'contenteditable' : null; let hasRange = false; if (type === 'contenteditable') { const sel = window.getSelection(); hasRange = !!(sel && sel.rangeCount > 0); } else if (type) { hasRange = true; // inputs/textarea use selectionStart/End } sendResponse({ ok: true, activeType: type, // 'input' | 'textarea' | 'contenteditable' | null hasRange, // whether we have a caret/selection lastInsertOK, // last insertion result (or null if none yet) lastInsertMessage // text summary }); return; // sync } }); }