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

989 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// === License & mode (stub) ===
// Flip this to true for local testing of Pro features if you want:
let licenseValid = false; // <- default: Free
// Persistent settings
let actionMode = 'copy'; // 'copy' | 'insert' | 'auto'
// Tone settings (persisted in sync storage)
let toneLock = false; // if true, always use preferred tone
let preferredToneSlug = null; // 'light' | 'medium-light' | 'medium' | 'medium-dark' | 'dark'
// Elements
const settingsBtn = document.getElementById('settings');
const sheet = document.getElementById('settings-sheet');
const sheetClose = document.getElementById('sheet-close');
const backdrop = document.getElementById('sheet-backdrop');
const modeGroup = document.getElementById('mode-group');
const licenseKeyEl = document.getElementById('license-key');
const licenseActivateBtn = document.getElementById('license-activate');
const licenseStatusEl = document.getElementById('license-status');
const licenseDeactivateBtn = document.getElementById('license-deactivate');
const licenseEditBtn = document.getElementById('license-edit');
const licenseCancelEditBtn = document.getElementById('license-cancel-edit');
const diagRunBtn = document.getElementById('diag-run');
const diagSpin = document.getElementById('diag-spin');
const diagOut = document.getElementById('diag-out');
const API = {
base: "https://emoji.dewe.pw/api",
list: "/emojis",
limit: 20 // lighter for extensions; your API caps at 50, docs try-it at 10
};
API.cats = "/categories"; // endpoint for categories map
let CAT_MAP = null; // { "Category": ["sub1","sub2", ...], ... }
// Preferred, human-first category order (fallback: alphabetical)
const PREFERRED_CATEGORY_ORDER = [
"Smileys & Emotion",
"People & Body",
"Animals & Nature",
"Food & Drink",
"Travel & Places",
"Activities",
"Objects",
"Symbols",
"Flags"
];
const CAT_INDEX = Object.fromEntries(PREFERRED_CATEGORY_ORDER.map((c, i) => [c, i]));
function categoryComparator(a, b) {
const ia = CAT_INDEX[a];
const ib = CAT_INDEX[b];
if (ia !== undefined && ib !== undefined) return ia - ib; // both in preferred list
if (ia !== undefined) return -1; // a preferred, b not
if (ib !== undefined) return 1; // b preferred, a not
return a.localeCompare(b, undefined, { sensitivity: "base" }); // both not preferred → AZ
}
async function loadCategories() {
try {
const res = await fetch(`${API.base}${API.cats}`);
if (!res.ok) throw new Error(`cats ${res.status}`);
CAT_MAP = await res.json();
populateCategorySelect();
} catch (e) {
console.warn("Failed to load categories", e);
}
}
function populateCategorySelect() {
const catSel = document.getElementById('cat');
const subSel = document.getElementById('sub');
if (!catSel || !CAT_MAP) return;
// reset
catSel.innerHTML = `<option value="">All categories</option>`;
Object.keys(CAT_MAP).sort(categoryComparator).forEach(cat => {
const opt = document.createElement('option');
opt.value = cat;
opt.textContent = cat;
catSel.appendChild(opt);
});
// clear subs initially
subSel.innerHTML = `<option value="">All subcategories</option>`;
subSel.disabled = true;
}
function populateSubcategorySelect(category) {
const subSel = document.getElementById('sub');
if (!subSel) return;
subSel.innerHTML = `<option value="">All subcategories</option>`;
const subs = (CAT_MAP?.[category] || []).slice().sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base", numeric: true }));
subs.forEach(sc => {
const opt = document.createElement('option');
opt.value = sc;
opt.textContent = sc;
subSel.appendChild(opt);
});
subSel.disabled = subs.length === 0;
}
let page = 1, total = 0, items = [];
const q = document.getElementById('q');
const cat = document.getElementById('cat');
const sub = document.getElementById('sub');
const clearBtn = document.getElementById('clear');
const list = document.getElementById('list');
const more = document.getElementById('more');
const count = document.getElementById('count');
const verEl = document.getElementById('ver');
function setVersionBadge() {
try {
const { name, version } = chrome.runtime.getManifest();
if (verEl) {
verEl.textContent = `v${version}`;
verEl.title = `${name} v${version}`;
}
} catch (e) {
// ignore if unavailable
}
}
setVersionBadge();
// --- Loading spinner (flip-flop with Load More) ---
let spinnerEl = null;
function showSpinner() {
if (spinnerEl) return;
// Hide Load More while loading
if (more) more.classList.add('hidden');
spinnerEl = document.createElement('div');
spinnerEl.id = 'grid-spinner';
spinnerEl.className = 'w-full flex justify-center py-6';
spinnerEl.innerHTML = '<div class="animate-spin inline-block w-8 h-8 border-4 border-current border-t-transparent text-blue-600 rounded-full" role="status" aria-label="loading"></div>';
// place under the grid
(list?.parentElement || document.body).appendChild(spinnerEl);
}
function hideSpinner() {
if (spinnerEl) { spinnerEl.remove(); spinnerEl = null; }
// Recompute Load More visibility after loading completes
if (typeof updateFooter === 'function') {
try { updateFooter(); } catch(_) {}
} else if (more) {
more.classList.remove('hidden');
}
}
// THEME — two states only: 'light' | 'dark'
const themeBtn = document.getElementById('theme');
const toastEl = document.getElementById('toast');
async function initTheme() {
const stored = (await chrome.storage.local.get('theme')).theme;
const prefersDark = window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches;
const initial = stored || (prefersDark ? 'dark' : 'light');
await chrome.storage.local.set({ theme: initial });
applyTheme(initial);
}
function applyTheme(theme) {
const clsLight = 'theme-light';
const clsDark = 'theme-dark';
document.body.classList.remove(clsLight, clsDark);
document.documentElement.classList.remove(clsLight, clsDark); // <-- add
const cls = (theme === 'dark') ? clsDark : clsLight;
document.body.classList.add(cls);
document.documentElement.classList.add(cls); // <-- add
// Button shows the opposite icon so it's clear what tapping will do
themeBtn.textContent = theme === 'dark' ? '🌞' : '🌙';
themeBtn.dataset.theme = theme;
}
async function toggleTheme() {
const curr = themeBtn.dataset.theme === 'dark' ? 'dark' : 'light';
const next = curr === 'dark' ? 'light' : 'dark';
await chrome.storage.local.set({ theme: next });
applyTheme(next);
}
themeBtn?.addEventListener('click', toggleTheme);
initTheme();
updateClearButtonIcon();
// --- Skin tone (Pro) -------------------------------------------------------
const SKIN_TONES = [
{ key: '1f3fb', ch: '\u{1F3FB}', slug: 'light', label: 'Light' },
{ key: '1f3fc', ch: '\u{1F3FC}', slug: 'medium-light', label: 'Medium-Light' },
{ key: '1f3fd', ch: '\u{1F3FD}', slug: 'medium', label: 'Medium' },
{ key: '1f3fe', ch: '\u{1F3FE}', slug: 'medium-dark', label: 'Medium-Dark' },
{ key: '1f3ff', ch: '\u{1F3FF}', slug: 'dark', label: 'Dark' },
];
const TONE_SLUGS = SKIN_TONES.map(t => t.slug);
const STRIP_TONE_RE = /[\u{1F3FB}-\u{1F3FF}]/gu;
function stripSkinTone(s){ return (s||'').replace(STRIP_TONE_RE,''); }
function withSkinTone(base, ch){ return `${base||''}${ch||''}`; }
// Tone helpers: keep globals in sync and expose helpers for slug
async function getPreferredToneIndex(){
return new Promise(res => {
chrome.storage.sync.get(['preferredSkinTone'], v => {
preferredToneSlug = v?.preferredSkinTone || null;
const idx = TONE_SLUGS.indexOf(preferredToneSlug);
res(idx >= 0 ? idx : -1);
});
});
}
async function setPreferredToneIndex(i){
preferredToneSlug = TONE_SLUGS[i] || null;
return new Promise(res => chrome.storage.sync.set({ preferredSkinTone: preferredToneSlug }, () => res()));
}
async function getToneLock(){
return new Promise(res => chrome.storage.sync.get(['toneLock'], v => { toneLock = !!v.toneLock; res(toneLock); }));
}
async function setToneLock(val){
toneLock = !!val;
return new Promise(res => chrome.storage.sync.set({ toneLock }, () => res()));
}
// debounce
let timer;
function debounced(fn, delay=250){ clearTimeout(timer); timer = setTimeout(fn, delay); }
async function fetchPage(reset=false) {
more.disabled = true;
if (reset) { page = 1; items = []; list.innerHTML = ""; }
try {
showSpinner();
const params = new URLSearchParams({
page: String(page),
limit: String(API.limit)
});
if (q.value.trim()) params.set('q', q.value.trim());
if (cat.value.trim()) params.set('category', cat.value.trim());
if (sub.value.trim()) params.set('subcategory', sub.value.trim());
const url = `${API.base}${API.list}?${params.toString()}`;
const res = await fetch(url, { cache: 'no-store' });
if (!res.ok) throw new Error(`API ${res.status}`);
const data = await res.json();
total = data.total || 0;
// append
for (const e of (data.items || [])) {
items.push(e);
renderCard(e);
}
updateFooter();
} catch (err) {
console.error('Fetch failed', err);
// Show a small notice
const msg = document.createElement('div');
msg.className = 'w-full text-center text-red-500 py-2';
msg.textContent = 'Failed to load. Please try again.';
list?.appendChild(msg);
} finally {
hideSpinner();
}
}
function updateFooter() {
count.textContent = `${items.length} / ${total}`;
const loading = !!spinnerEl;
if (items.length < total) {
more.textContent = 'Load more';
more.disabled = loading;
// Only show button when not loading
more.classList.toggle('hidden', loading);
} else {
more.textContent = 'All loaded';
more.disabled = true;
// If fully loaded, keep it visible but disabled, or hide if you prefer:
// more.classList.add('hidden');
}
}
async function ensureContentScript(tabId) {
// try ping
try {
const pong = await chrome.tabs.sendMessage(tabId, { type: 'dewemoji_ping' });
if (pong?.ok) return true;
} catch {}
// inject then ping again
try {
await chrome.scripting.executeScript({ target: { tabId }, files: ['content.js'] });
const pong2 = await chrome.tabs.sendMessage(tabId, { type: 'dewemoji_ping' });
return !!pong2?.ok;
} catch {
return false;
}
}
async function insert(text, opts = {}) {
const strict = !!opts.strict; // if true, do NOT fallback to copy
if (!text) return false;
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) { if (!strict) { await navigator.clipboard.writeText(text); } return false; }
// A) Try inline injection (fast path)
const [{ result }] = await chrome.scripting.executeScript({
target: { tabId: tab.id },
func: (txt) => {
const el = document.activeElement;
const isEditable = el && (
el.tagName === 'TEXTAREA' ||
(el.tagName === 'INPUT' && /^(text|search|email|url|tel|number|password)?$/i.test(el.type)) ||
el.isContentEditable
);
if (!isEditable) return false;
if (el.tagName === 'TEXTAREA' || el.tagName === 'INPUT') {
const start = el.selectionStart ?? el.value.length;
const end = el.selectionEnd ?? start;
const val = el.value ?? '';
el.value = val.slice(0, start) + txt + val.slice(end);
const pos = start + txt.length;
try { el.setSelectionRange(pos, pos); } catch {}
el.dispatchEvent(new Event('input', { bubbles: true }));
return true;
}
try { document.execCommand('insertText', false, txt); return true; } catch { return false; }
},
args: [text]
});
if (result) return true; // inserted via inline
// B) Ensure content.js present, then ask it (robust path)
if (await ensureContentScript(tab.id)) {
const res = await chrome.tabs.sendMessage(tab.id, { type: 'dewemoji_insert', text });
if (res?.ok) return true; // inserted via content script
}
// C) Fallback
if (!strict) { await navigator.clipboard.writeText(text); }
return false;
} catch {
if (!strict) { await navigator.clipboard.writeText(text); }
return false;
}
}
function updateClearButtonIcon() {
if (!clearBtn) return;
const hasText = q.value.trim().length > 0;
if (hasText) {
clearBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M18 6L6 18M6 6l12 12"/></svg>`;
clearBtn.title = 'Clear';
clearBtn.setAttribute('aria-label', 'Clear');
clearBtn.classList.add('nm');
clearBtn.dataset.mode = 'clear';
} else {
clearBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="M21.074 12.154a.75.75 0 0 1 .672.82c-.49 4.93-4.658 8.776-9.724 8.776c-2.724 0-5.364-.933-7.238-2.68L3 20.85a.75.75 0 0 1-.75-.75v-3.96c0-.714.58-1.29 1.291-1.29h3.97a.75.75 0 0 1 .75.75l-2.413 2.407c1.558 1.433 3.78 2.243 6.174 2.243c4.29 0 7.817-3.258 8.232-7.424a.75.75 0 0 1 .82-.672m-18.82-1.128c.49-4.93 4.658-8.776 9.724-8.776c2.724 0 5.364.933 7.238 2.68L21 3.15a.75.75 0 0 1 .75.75v3.96c0 .714-.58 1.29-1.291 1.29h-3.97a.75.75 0 0 1-.75-.75l2.413-2.408c-1.558-1.432-3.78-2.242-6.174-2.242c-4.29 0-7.817 3.258-8.232 7.424a.75.75 0 1 1-1.492-.148"/></svg>`; // refresh icon
clearBtn.title = 'Refresh';
clearBtn.setAttribute('aria-label', 'Refresh');
clearBtn.classList.add('nm');
clearBtn.dataset.mode = 'refresh';
}
}
// events
q.addEventListener('input', () => {
updateClearButtonIcon();
debounced(() => fetchPage(true));
});
cat.addEventListener('change', () => {
populateSubcategorySelect(cat.value);
sub.value = '';
fetchPage(true).catch(console.error);
});
sub.addEventListener('change', () => {
fetchPage(true).catch(console.error);
});
clearBtn.addEventListener('click', () => {
const mode = clearBtn.dataset.mode;
if (mode === 'clear') {
q.value = '';
updateClearButtonIcon();
fetchPage(true);
} else {
// refresh when empty
fetchPage(true);
}
});
more.addEventListener('click', () => {
more.disabled = true;
page += 1;
fetchPage(false);
});
function showToast(text='Done') {
if (!toastEl) return;
toastEl.textContent = text;
toastEl.classList.add('show');
clearTimeout(showToast._t);
showToast._t = setTimeout(() => toastEl.classList.remove('show'), 1400);
}
// (obsolete) placeholder; real renderCard defined later with Windows support + Pro tone UI
function renderCard(e) {}
// --- Popover helpers for skin tone picker ---
function showPopoverNear(anchorEl, popEl){
const r = anchorEl.getBoundingClientRect();
Object.assign(popEl.style, {
position: 'fixed',
left: `${Math.min(window.innerWidth - 8, Math.max(8, r.left))}px`,
top: `${Math.min(window.innerHeight - 8, r.bottom + 6)}px`,
zIndex: 9999,
});
document.body.appendChild(popEl);
}
function removePopover(){
const p = document.getElementById('dewemoji-tone-popover');
if (p && p.parentNode) p.parentNode.removeChild(p);
document.removeEventListener('click', handleOutsideClose, true);
}
function handleOutsideClose(e){
const p = document.getElementById('dewemoji-tone-popover');
if (p && !p.contains(e.target)) removePopover();
}
function buildTonePopover(base, activeIdx, onPick){
const el = document.createElement('div');
el.id = 'dewemoji-tone-popover';
el.setAttribute('role','dialog');
el.style.cssText = 'background: var(--c-bg, #fff); border: 1px solid var(--c-border, rgba(0,0,0,.12)); padding:6px; border-radius:8px; box-shadow:0 6px 18px rgba(0,0,0,.15); display:flex; gap:6px;';
SKIN_TONES.forEach((t,i) => {
const b = document.createElement('button');
b.className = 'tone-btn';
b.textContent = withSkinTone(base, t.ch);
b.title = t.label;
b.style.cssText = 'min-width:32px;height:32px;border-radius:6px;border:1px solid transparent;display:flex;align-items:center;justify-content:center;background:var(--c-chip,#f3f4f6)';
if (i === activeIdx) b.classList.add('selected');
b.addEventListener('click', () => onPick(i, t));
el.appendChild(b);
});
setTimeout(() => document.addEventListener('click', handleOutsideClose, true), 0);
return el;
}
// initial
loadCategories().catch(console.error);
fetchPage(true).catch(console.error);
async function loadSettings() {
const data = await chrome.storage.local.get(['licenseValid', 'licenseKey', 'actionMode']);
licenseValid = !!data.licenseValid;
actionMode = data.actionMode || 'copy';
if (data.licenseKey) licenseKeyEl && (licenseKeyEl.value = data.licenseKey);
applyLicenseUI();
applyModeUI();
setStatusTag();
// tone settings from sync storage
await getPreferredToneIndex();
await getToneLock();
renderToneSettingsSection();
// ensure license field starts in non-edit mode
setLicenseEditMode(false);
}
async function saveSettings() {
await chrome.storage.local.set({ licenseValid, actionMode, licenseKey: licenseKeyEl?.value || '' });
}
function setLicenseEditMode(on) {
if (!licenseKeyEl) return;
const isOn = !!on;
// Input enabled only when editing OR not licensed
licenseKeyEl.disabled = licenseValid ? !isOn : false;
if (licenseValid) {
// Licensed: default view hides Activate; editing shows Save/Cancel
if (licenseActivateBtn) {
licenseActivateBtn.style.display = isOn ? '' : 'none';
licenseActivateBtn.textContent = isOn ? 'Save' : 'Activate';
}
if (licenseDeactivateBtn) licenseDeactivateBtn.style.display = licenseActivateBtn.textContent == 'Save' ? 'none' : '';
if (licenseEditBtn) licenseEditBtn.style.display = isOn ? 'none' : '';
if (licenseCancelEditBtn) licenseCancelEditBtn.style.display = isOn ? '' : 'none';
} else {
// Free: show Activate only
if (licenseActivateBtn) { licenseActivateBtn.style.display = ''; licenseActivateBtn.textContent = 'Activate'; }
if (licenseDeactivateBtn) licenseDeactivateBtn.style.display = 'none';
if (licenseEditBtn) licenseEditBtn.style.display = 'none';
if (licenseCancelEditBtn) licenseCancelEditBtn.style.display = 'none';
}
}
function applyLicenseUI() {
// enable/disable Pro radios
const radios = modeGroup.querySelectorAll('input[type="radio"][name="actionMode"]');
radios.forEach(r => {
const isPro = (r.value === 'insert' || r.value === 'auto');
if (isPro) {
r.disabled = !licenseValid;
r.closest('.radio').setAttribute('aria-disabled', !licenseValid ? 'true' : 'false');
}
});
if (!licenseValid) {
// Force visible selection to 'copy' for clarity
const copy = modeGroup.querySelector('input[type="radio"][value="copy"]');
if (copy) copy.checked = true;
licenseStatusEl && (licenseStatusEl.textContent = 'Free mode — Pro features locked.');
} else {
// Restore selected mode if Pro; if current actionMode is pro, check it
const target = modeGroup.querySelector(`input[type="radio"][value="${actionMode}"]`) ||
modeGroup.querySelector('input[type="radio"][value="auto"]');
if (target) target.checked = true;
licenseStatusEl && (licenseStatusEl.textContent = 'Pro active');
}
// Reset to non-edit view per license state
if (licenseValid) {
if (licenseKeyEl) licenseKeyEl.disabled = true;
if (licenseActivateBtn) { licenseActivateBtn.style.display = 'none'; licenseActivateBtn.textContent = 'Activate'; }
if (licenseDeactivateBtn) licenseDeactivateBtn.style.display = '';
if (licenseEditBtn) licenseEditBtn.style.display = '';
if (licenseCancelEditBtn) licenseCancelEditBtn.style.display = 'none';
} else {
if (licenseKeyEl) licenseKeyEl.disabled = false;
if (licenseActivateBtn) { licenseActivateBtn.style.display = ''; licenseActivateBtn.textContent = 'Activate'; }
if (licenseDeactivateBtn) licenseDeactivateBtn.style.display = 'none';
if (licenseEditBtn) licenseEditBtn.style.display = 'none';
if (licenseCancelEditBtn) licenseCancelEditBtn.style.display = 'none';
}
setStatusTag();
}
function applyModeUI() {
// Ensure the selected radio matches actionMode (if not locked)
const input = modeGroup.querySelector(`input[type="radio"][value="${actionMode}"]`);
if (input && !input.disabled) input.checked = true;
}
// --- License account id helper (best practice: hash + label) ---
async function accountId() {
// Return a stable, privacy-preserving identifier and a masked label for UI
// { id: <hashed id>, label: <masked email or short id> }
// Try cache first
const cached = await chrome.storage.local.get(['accountId','accountLabel']);
if (cached.accountId && cached.accountLabel) {
return { id: cached.accountId, label: cached.accountLabel };
}
let raw = '';
let label = '';
try {
if (chrome.identity && chrome.identity.getProfileUserInfo) {
const info = await new Promise(res => chrome.identity.getProfileUserInfo(res));
// info: { email, id }
if (info?.email) {
raw = (info.email || '').trim().toLowerCase();
label = maskEmail(info.email);
} else if (info?.id) {
raw = String(info.id);
label = shortId(info.id);
}
}
} catch {}
if (!raw) {
// Fallback to a per-profile UUID
const got = await chrome.storage.local.get(['profileUUID']);
if (got?.profileUUID) {
raw = got.profileUUID; label = shortId(raw);
} else {
raw = crypto.randomUUID(); label = shortId(raw);
await chrome.storage.local.set({ profileUUID: raw });
}
}
const hashed = await sha256Base64Url(raw);
await chrome.storage.local.set({ accountId: hashed, accountLabel: label });
return { id: hashed, label };
}
function maskEmail(email){
const [u, d] = String(email).split('@');
if (!d) return shortId(email);
const head = u.slice(0,1);
const tail = u.slice(-1);
return `${head}${u.length>2?'***':''}${tail}@${d}`;
}
function shortId(s){
const x = String(s);
return x.length <= 8 ? x : `${x.slice(0,4)}${x.slice(-2)}`;
}
async function sha256Base64Url(input){
const enc = new TextEncoder();
const buf = await crypto.subtle.digest('SHA-256', enc.encode(input));
const bytes = new Uint8Array(buf);
let bin = '';
for (let i=0;i<bytes.length;i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
}
// --- Tone Settings UI (in Settings modal) ---
function renderToneSettingsSection(){
if (!sheet) return;
let sec = document.getElementById('tone-settings');
if (!sec) {
sec = document.createElement('section');
sec.id = 'tone-settings';
sec.innerHTML = `
<h3 class="h">Preferred skin tone</h3>
<label class="toggle"><input type="checkbox" id="tone-lock"> <span>Lock preferred tone (use automatically)</span></label>
<div id="tone-palette" class="tone-palette" style="display:flex;gap:8px;margin:8px 0 4px 0;"></div>
<div id="tone-note" class="note"></div>
`;
sheet.querySelector('#tab-pro')?.appendChild(sec) || sheet.appendChild(sec);
}
const pal = sec.querySelector('#tone-palette');
pal.innerHTML = '';
const lockEl = sec.querySelector('#tone-lock');
lockEl.checked = !!toneLock;
lockEl.onchange = async (e)=>{ await setToneLock(!!e.target.checked); showToast(toneLock ? 'Tone lock enabled' : 'Tone lock disabled'); };
const idx = TONE_SLUGS.indexOf(preferredToneSlug);
const basePreview = '✋';
SKIN_TONES.forEach((t,i)=>{
const b = document.createElement('button');
b.className = 'tone-chip';
b.title = t.label;
b.textContent = basePreview + t.ch; // small preview
b.style.cssText = 'height:32px;border-radius:8px;padding:0 8px;border:1px solid var(--c-border, rgba(0,0,0,.12));background:var(--c-chip,#f3f4f6)';
if (i === idx) b.classList.add('selected');
b.addEventListener('click', async ()=>{
await setPreferredToneIndex(i);
showToast('Preferred tone set');
renderToneSettingsSection();
});
pal.appendChild(b);
});
const note = sec.querySelector('#tone-note');
if (!preferredToneSlug) {
note.textContent = 'Tip: choosing a tone will lock it for next picks if the toggle is on.';
} else {
note.textContent = toneLock ? `Using ${preferredToneSlug.replace('-', ' ')} tone by default.` : 'Lock is off — we will ask via the palette.';
}
}
function openSheet() {
sheet.removeAttribute('hidden'); backdrop.removeAttribute('hidden');
requestAnimationFrame(() => { sheet.classList.add('show'); backdrop.classList.add('show'); });
}
function closeSheet() {
sheet.classList.remove('show'); backdrop.classList.remove('show');
setTimeout(() => { sheet.setAttribute('hidden',''); backdrop.setAttribute('hidden',''); }, 180);
}
settingsBtn?.addEventListener('click', openSheet);
sheetClose?.addEventListener('click', closeSheet);
backdrop?.addEventListener('click', closeSheet);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeSheet(); });
// Tabs logic for settings sheet
const tabsWrap = document.querySelector('#settings-sheet .tabs');
if (tabsWrap) {
tabsWrap.addEventListener('click', (e) => {
const btn = e.target.closest('.tab');
if (!btn) return;
const target = btn.dataset.tab;
document.querySelectorAll('#settings-sheet .tab').forEach(b => b.classList.toggle('active', b === btn));
document.querySelectorAll('#settings-sheet .tabpane').forEach(p => p.classList.toggle('active', p.id === `tab-${target}`));
});
}
modeGroup?.addEventListener('change', async (e) => {
if (e.target && e.target.name === 'actionMode') {
const val = e.target.value;
// If not licensed and user clicked a Pro option, bounce back to copy
if (!licenseValid && (val === 'insert' || val === 'auto')) {
showToast('Pro required for Insert/Automatic');
const copy = modeGroup.querySelector('input[value="copy"]');
if (copy) copy.checked = true;
actionMode = 'copy';
} else {
actionMode = val;
}
await saveSettings();
}
});
licenseActivateBtn?.addEventListener('click', async () => {
const key = (licenseKeyEl?.value || '').trim();
if (!key) { showToast('Enter a license key'); return; }
// TODO: replace with server activation; for now treat as success in dev
// Example:
// const acct = await accountId();
// const res = await fetch('https://YOUR-API/license/activate', {
// method: 'POST', headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({ key, account_id: acct.id, version: chrome.runtime.getManifest().version })
// });
// const data = await res.json(); if (!res.ok || !data.ok) throw new Error(data.message || 'Invalid license');
try {
licenseValid = true;
await chrome.storage.local.set({ licenseValid: true, licenseKey: key, lastLicenseCheck: Date.now() });
applyLicenseUI();
setLicenseEditMode(false);
showToast('License activated ✓');
} catch (e) {
licenseValid = false;
await chrome.storage.local.set({ licenseValid: false });
applyLicenseUI();
showToast('Activation failed');
}
});
licenseEditBtn?.addEventListener('click', () => {
setLicenseEditMode(true);
});
licenseCancelEditBtn?.addEventListener('click', async () => {
const data = await chrome.storage.local.get(['licenseKey']);
if (licenseKeyEl) licenseKeyEl.value = data.licenseKey || '';
setLicenseEditMode(false);
});
licenseDeactivateBtn?.addEventListener('click', async () => {
if (!licenseValid) return;
const ok = confirm('Deactivate Pro on this device?');
if (!ok) return;
try {
// (Optional) TODO: call your API /license/deactivate here, e.g.:
// const acct = await accountId();
// await fetch('https://YOUR-API/license/deactivate', {
// method: 'POST', headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({ key: (licenseKeyEl?.value || '').trim(), account_id: acct.id })
// });
licenseValid = false;
await chrome.storage.local.set({ licenseValid: false, licenseKey: '' });
if (licenseKeyEl) licenseKeyEl.value = '';
applyLicenseUI();
setLicenseEditMode(false);
showToast('License deactivated');
} catch (e) {
showToast('Could not deactivate');
}
});
function renderDiag(obj) {
const lines = [];
lines.push(`Content script loaded: ${obj ? 'yes' : 'no'}`);
if (!obj) return lines.join('\n');
lines.push(`Active editable type: ${obj.activeType ?? 'none'}`);
lines.push(`Has caret/selection: ${obj.hasRange ? 'yes' : 'no'}`);
lines.push(`Last insert result: ${obj.lastInsertOK === null ? 'n/a' : obj.lastInsertOK ? 'success' : 'failed'}`);
if (obj.lastInsertMessage) lines.push(`Note: ${obj.lastInsertMessage}`);
return lines.join('\n');
}
diagRunBtn?.addEventListener('click', async () => {
diagOut.textContent = '';
diagSpin.style.display = 'inline';
try {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) { diagOut.textContent = 'No active tab.'; return; }
const ready = await ensureContentScript(tab.id);
if (!ready) { diagOut.textContent = renderDiag(null); return; }
const info = await chrome.tabs.sendMessage(tab.id, { type: 'dewemoji_diag' });
diagOut.textContent = renderDiag(info || null);
} catch (e) {
diagOut.textContent = `Error: ${e?.message || e}`;
} finally {
diagSpin.style.display = 'none';
}
});
async function performEmojiAction(glyph) {
// Free users always copy
const mode = licenseValid ? actionMode : 'copy';
if (mode === 'copy') {
await navigator.clipboard.writeText(glyph);
showToast('Copied ✅');
return;
}
if (mode === 'insert') {
const ok = await insert(glyph, { strict: true }); // no copy fallback
showToast(ok ? 'Inserted ✅' : '❗ No editable field');
return;
}
// mode === 'auto' (insert else copy)
{
const ok = await insert(glyph, { strict: false });
showToast(ok ? 'Inserted ✅' : 'Copied ✅');
return;
}
}
async function setStatusTag(){
const dewemojiStatusTag = document.getElementById('dewemoji-status');
if(licenseValid){
dewemojiStatusTag.innerText = 'Pro';
dewemojiStatusTag.classList.add('pro');
dewemojiStatusTag.classList.remove('free');
}else{
dewemojiStatusTag.innerText = 'Free';
dewemojiStatusTag.classList.add('free');
dewemojiStatusTag.classList.remove('pro');
}
}
setStatusTag().catch(() => {});
const IS_WINDOWS = navigator.userAgent.includes('Windows');
// Build Twemoji SVG url from codepoints
function twemojiSrcFromCodepoints(cp) {
return `https://twemoji.maxcdn.com/v/latest/svg/${cp}.svg`;
}
// OPTIONAL: as a secondary image fallback if Twemoji doesn't have it
function notoSrcFromCodepoints(cp) {
// Twemoji uses "1f469-200d-1f4bb.svg"
// Noto repo names use underscores: "emoji_u1f469_200d_1f4bb.svg"
const underscored = cp.split('-').join('_');
return `https://cdn.jsdelivr.net/gh/googlefonts/noto-emoji@main/svg/emoji_u${underscored}.svg`;
}
/**
* Ensure an emoji is renderable inside `container` on Windows,
* and return a canonical glyph string for copy/insert.
* - Tries twemoji.parse first
* - If nothing replaced, forces an <img> using toCodePoint()
* - Returns { glyph, usedImg }
*/
function ensureRenderableAndGlyph(emojiStr, container) {
let glyph = emojiStr;
if (!(IS_WINDOWS && window.twemoji)) {
container.textContent = glyph;
return { glyph, usedImg: false };
}
// Try normal twemoji.parse on a temp node
const temp = document.createElement('span');
temp.textContent = emojiStr;
window.twemoji.parse(temp, {
folder: 'svg',
ext: '.svg',
base: 'https://twemoji.maxcdn.com/v/latest/',
attributes: () => ({ draggable: 'false', alt: '' })
});
const img = temp.querySelector('img');
if (img) {
// Parsed OK — adopt result
container.replaceChildren(img);
return { glyph, usedImg: true };
}
// Force build from codepoints (handles cases parse() didn't catch)
try {
const cp = window.twemoji.convert.toCodePoint(emojiStr);
const img2 = new Image();
img2.alt = emojiStr;
img2.draggable = false;
img2.src = notoSrcFromCodepoints(cp);
img2.onerror = () => {
img2.onerror = null; // prevent loops
img2.src = twemojiSrcFromCodepoints(cp);
};
container.replaceChildren(img2);
// Normalize the glyph for copying from the same codepoints
// (twemoji provides the inverse)
glyph = window.twemoji.convert.fromCodePoint(cp);
return { glyph, usedImg: true };
} catch {
// Last resort: show text (may be tofu) but keep original glyph
container.textContent = emojiStr;
return { glyph: emojiStr, usedImg: false };
}
}
function renderCard(e) {
const card = document.createElement('div');
card.className = 'card';
card.title = e.name || '';
const emo = document.createElement('div');
emo.className = 'emo';
let baseGlyph = e.emoji || e.symbol || e.text || '';
const prefIdxPromise = getPreferredToneIndex();
(async () => {
let renderGlyph = baseGlyph;
if (licenseValid && toneLock && e.supports_skin_tone) {
const idx = await prefIdxPromise;
if (idx >= 0) {
const base = e.emoji_base || stripSkinTone(baseGlyph);
renderGlyph = withSkinTone(base, SKIN_TONES[idx].ch);
}
}
const res = ensureRenderableAndGlyph(renderGlyph, emo);
baseGlyph = res.glyph;
})();
const name = document.createElement('div');
name.className = 'nm';
name.textContent = e.name || '';
card.addEventListener('click', async () => {
if (!licenseValid || !e.supports_skin_tone) {
performEmojiAction(baseGlyph).catch(console.error);
return;
}
const idx = await prefIdxPromise;
const base = e.emoji_base || stripSkinTone(baseGlyph);
if (toneLock && idx >= 0) {
const variant = withSkinTone(base, SKIN_TONES[idx].ch);
performEmojiAction(variant).catch(console.error);
return;
}
removePopover();
const pop = buildTonePopover(base, idx, async (newIdx, tone) => {
const variant = withSkinTone(base, tone.ch);
if (toneLock && idx < 0) {
await setPreferredToneIndex(newIdx);
showToast('Preferred tone set');
}
performEmojiAction(variant).catch(console.error);
removePopover();
});
showPopoverNear(card, pop);
});
card.append(emo, name);
list.appendChild(card);
}
// pick a tofu cell
const el = document.querySelector('.card .emo');
const raw = el?.textContent || '';
console.log('RAW points:', [...raw].map(c=>c.codePointAt(0).toString(16)));
if (window.twemoji) {
const cp = window.twemoji.convert.toCodePoint(raw);
console.log('Twemoji codepoints:', cp);
console.log('Canonical from codepoints:', window.twemoji.convert.fromCodePoint(cp));
}
loadSettings().catch(() => {});
// Example verify-on-boot (disabled in dev):
// (async function verifyLicenseOnBoot(){
// const { licenseKey, lastLicenseCheck } = await chrome.storage.local.get(['licenseKey','lastLicenseCheck']);
// if (!licenseKey) return;
// const day = 24*60*60*1000; if (Date.now() - (lastLicenseCheck||0) < day) return;
// try {
// const acct = await accountId();
// const res = await fetch('https://YOUR-API/license/verify', {
// method: 'POST', headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({ key: licenseKey, account_id: acct.id })
// });
// const data = await res.json();
// const ok = !!data.ok; await chrome.storage.local.set({ licenseValid: ok, lastLicenseCheck: Date.now() });
// licenseValid = ok; applyLicenseUI();
// } catch {}
// })();
// Re-render tone section when opening settings (in case lock/pref changed during session)
const __openSheet = openSheet;
openSheet = function(){
__openSheet();
const genBtn = document.querySelector('#settings-sheet .tab[data-tab="general"]');
const proBtn = document.querySelector('#settings-sheet .tab[data-tab="pro"]');
genBtn?.classList.add('active'); proBtn?.classList.remove('active');
document.getElementById('tab-general')?.classList.add('active');
document.getElementById('tab-pro')?.classList.remove('active');
renderToneSettingsSection();
};