229 lines
7.7 KiB
JavaScript
229 lines
7.7 KiB
JavaScript
// 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
|
|
}
|
|
});
|
|
} |