Files
emoji-chrome-extension/content.js
2025-08-29 23:36:07 +07:00

212 lines
6.9 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)
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();
document.addEventListener('focusin', captureCaret, true);
document.addEventListener('keyup', captureCaret, true);
document.addEventListener('mouseup', captureCaret, true);
document.addEventListener('selectionchange', 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
}
});
}