- Remove swatch modal → replaced with inline tone row UX - Add active card highlight and tone selection row spanning 4 columns - Apply tone immediately on click (copy/insert according to settings) - Add tooltip on hover for names (replacing marquee on hover) - Enable marquee animation only for overflowed text when active - Integrate forbid & non-toneable filters matching site policy (LGBT & fantasy exclusions) - Sync tone whitelist (roles like technologist, scientist, firefighter, etc.) - Sync tone blacklist (e.g. merman, mermaid, deaf man/woman, woman beard, men with bunny ears) - Add overflow-y hidden toggle when settings modal shown - Move inline styles to style.css for cleaner structure - Refactor panel.js for maintainable tone row injection logic
1855 lines
67 KiB
JavaScript
1855 lines
67 KiB
JavaScript
// === 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'
|
||
let licenseKeyCurrent = '';
|
||
|
||
// 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');
|
||
|
||
// --- Branded confirm modal helper ---
|
||
function showConfirmModal(opts = {}) {
|
||
return new Promise((resolve) => {
|
||
const modal = document.getElementById('confirm-modal');
|
||
const back = document.getElementById('confirm-backdrop');
|
||
const title = document.getElementById('confirm-title');
|
||
const msg = document.getElementById('confirm-message');
|
||
const okBtn = document.getElementById('confirm-ok');
|
||
const noBtn = document.getElementById('confirm-cancel');
|
||
if (!modal || !back) return resolve(window.confirm(opts.message || 'Are you sure?'));
|
||
|
||
title.textContent = opts.title || 'Confirm';
|
||
msg.textContent = opts.message || 'Are you sure?';
|
||
okBtn.textContent = opts.okText || 'OK';
|
||
noBtn.textContent = opts.cancelText || 'Cancel';
|
||
|
||
function close(val){
|
||
modal.setAttribute('hidden','');
|
||
back.setAttribute('hidden','');
|
||
document.removeEventListener('keydown', onKey);
|
||
okBtn.removeEventListener('click', onOk);
|
||
noBtn.removeEventListener('click', onNo);
|
||
back.removeEventListener('click', onNo);
|
||
resolve(val);
|
||
}
|
||
function onOk(){ close(true); }
|
||
function onNo(){ close(false); }
|
||
function onKey(e){ if (e.key === 'Escape') close(false); if (e.key === 'Enter') close(true); }
|
||
|
||
back.removeAttribute('hidden');
|
||
modal.removeAttribute('hidden');
|
||
document.addEventListener('keydown', onKey);
|
||
okBtn.addEventListener('click', onOk);
|
||
noBtn.addEventListener('click', onNo);
|
||
back.addEventListener('click', onNo);
|
||
// focus primary
|
||
setTimeout(()=> okBtn.focus(), 0);
|
||
});
|
||
}
|
||
|
||
// --- License busy helpers (UI feedback for activate/deactivate) ---
|
||
let __licensePrev = {
|
||
text: '',
|
||
activateDisabled: false,
|
||
keyDisabled: false,
|
||
deactivateDisabled: false,
|
||
editDisabled: false,
|
||
cancelDisabled: false,
|
||
};
|
||
function setLicenseBusy(on, label){
|
||
const btn = licenseActivateBtn;
|
||
if (!btn) return;
|
||
if (on) {
|
||
// snapshot current states
|
||
__licensePrev.text = btn.textContent;
|
||
__licensePrev.activateDisabled = !!btn.disabled;
|
||
__licensePrev.keyDisabled = !!(licenseKeyEl && licenseKeyEl.disabled);
|
||
__licensePrev.deactivateDisabled = !!(licenseDeactivateBtn && licenseDeactivateBtn.disabled);
|
||
__licensePrev.editDisabled = !!(licenseEditBtn && licenseEditBtn.disabled);
|
||
__licensePrev.cancelDisabled = !!(licenseCancelEditBtn && licenseCancelEditBtn.disabled);
|
||
|
||
// set busy states
|
||
btn.textContent = label || 'Verifying…';
|
||
btn.disabled = true;
|
||
if (licenseKeyEl) licenseKeyEl.disabled = true;
|
||
if (licenseDeactivateBtn) licenseDeactivateBtn.disabled = true;
|
||
if (licenseEditBtn) licenseEditBtn.disabled = true;
|
||
if (licenseCancelEditBtn) licenseCancelEditBtn.disabled = true;
|
||
if (licenseStatusEl) licenseStatusEl.textContent = 'Checking license…';
|
||
} else {
|
||
// restore previous states
|
||
btn.textContent = __licensePrev.text || 'Activate';
|
||
btn.disabled = !!__licensePrev.activateDisabled;
|
||
if (licenseKeyEl) licenseKeyEl.disabled = !!__licensePrev.keyDisabled;
|
||
if (licenseDeactivateBtn) licenseDeactivateBtn.disabled = !!__licensePrev.deactivateDisabled;
|
||
if (licenseEditBtn) licenseEditBtn.disabled = !!__licensePrev.editDisabled;
|
||
if (licenseCancelEditBtn) licenseCancelEditBtn.disabled = !!__licensePrev.cancelDisabled;
|
||
|
||
// Re-apply UI (visibility/display) for current license state
|
||
applyLicenseUI();
|
||
}
|
||
}
|
||
|
||
const diagRunBtn = document.getElementById('diag-run');
|
||
const diagSpin = document.getElementById('diag-spin');
|
||
const diagOut = document.getElementById('diag-out');
|
||
|
||
const API = {
|
||
base: "https://api.dewemoji.com/v1",
|
||
list: "/emojis"
|
||
};
|
||
API.cats = "/categories";
|
||
let PAGE_LIMIT = 20; // Free default
|
||
function refreshPageLimit(){
|
||
PAGE_LIMIT = licenseValid ? 50 : 20; // Pro gets bigger pages
|
||
}
|
||
refreshPageLimit();
|
||
const FRONTEND_ID = 'ext-v1';
|
||
|
||
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]));
|
||
// Minimal local subcategories to enable the sub-select even without API
|
||
const LOCAL_SUBS = {
|
||
'Travel & Places': ['place-religious','place-geographic','transport-water'],
|
||
'People & Body': ['hand-fingers-open','hand-fingers-partial','person-gesture'],
|
||
'Animals & Nature': ['animal-mammal','animal-bird','animal-bug'],
|
||
'Activities': ['sport','game','event'],
|
||
};
|
||
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 → A–Z
|
||
}
|
||
|
||
async function loadCategories() {
|
||
// Try live endpoint; fall back silently to local list (no subs)
|
||
let ok = false;
|
||
try {
|
||
const res = await fetch(`${API.base}${API.cats}`);
|
||
if (res && res.ok) {
|
||
const data = await res.json();
|
||
// Normalize payloads:
|
||
// A) New shape: { items: [ { name: 'People & Body', subcategories: ['person','person-gesture'] }, ... ] }
|
||
// B) Legacy shape: { 'People & Body': ['person','person-gesture'], ... }
|
||
if (data && Array.isArray(data.items)) {
|
||
const map = {};
|
||
for (const it of data.items) {
|
||
if (!it || typeof it.name !== 'string') continue;
|
||
const name = it.name;
|
||
const subsRaw = Array.isArray(it.subcategories) ? it.subcategories : [];
|
||
const subs = subsRaw
|
||
.map(s => (typeof s === 'string') ? s : (s && (s.slug || s.name) || ''))
|
||
.filter(Boolean);
|
||
map[name] = subs;
|
||
}
|
||
CAT_MAP = map;
|
||
} else if (data && typeof data === 'object') {
|
||
CAT_MAP = data; // assume already in object-map form
|
||
} else {
|
||
throw new Error('bad_categories_payload');
|
||
}
|
||
ok = Object.keys(CAT_MAP || {}).length > 0;
|
||
}
|
||
} catch (_) {}
|
||
|
||
if (!ok) {
|
||
// Silent fallback with a minimal subcategory seed
|
||
CAT_MAP = Object.fromEntries(PREFERRED_CATEGORY_ORDER.map(c => [c, LOCAL_SUBS[c] || []]));
|
||
}
|
||
populateCategorySelect();
|
||
}
|
||
|
||
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 raw = (CAT_MAP?.[category] || []).slice();
|
||
const subs = raw
|
||
.map(s => (typeof s === 'string') ? s : (s && (s.slug || s.name) || ''))
|
||
.filter(Boolean)
|
||
.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;
|
||
subSel.classList.toggle('opacity-50', subs.length === 0);
|
||
subSel.classList.toggle('pointer-events-none', subs.length === 0);
|
||
}
|
||
|
||
let page = 1, total = 0, items = [];
|
||
let lastServerTier = null; // 'pro' | null (free) | undefined (no response yet)
|
||
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();
|
||
|
||
// --- Alert helper for messages (warn/error, light/dark theme) ---
|
||
function buildAlert(kind, title, desc){
|
||
const isDark = document.documentElement.classList.contains('theme-dark');
|
||
const tone = (kind === 'error')
|
||
? (isDark
|
||
? { bg:'#3b0f0f', border:'#7f1d1d', text:'#fecaca', icon:'‼' }
|
||
: { bg:'#fee2e2', border:'#fecaca', text:'#7f1d1d', icon:'‼' })
|
||
: (isDark
|
||
? { bg:'#3b2f0f', border:'#a16207', text:'#fde68a', icon:'⚠️' }
|
||
: { bg:'#fef3c7', border:'#fde68a', text:'#92400e', icon:'⚠️' });
|
||
|
||
const wrap = document.createElement('div');
|
||
wrap.style.gridColumn = '1 / -1';
|
||
wrap.style.background = tone.bg;
|
||
wrap.style.border = `1px solid ${tone.border}`;
|
||
wrap.style.color = tone.text;
|
||
wrap.style.borderRadius = '10px';
|
||
wrap.style.padding = '10px 12px';
|
||
wrap.style.display = 'flex';
|
||
wrap.style.alignItems = 'flex-start';
|
||
wrap.style.gap = '10px';
|
||
wrap.className = 'dewemoji-alert';
|
||
|
||
const ico = document.createElement('span');
|
||
ico.textContent = tone.icon;
|
||
ico.style.fontSize = '18px';
|
||
ico.style.lineHeight = '20px';
|
||
|
||
const body = document.createElement('div');
|
||
const h = document.createElement('div');
|
||
h.textContent = title || '';
|
||
h.style.fontWeight = '600';
|
||
h.style.marginBottom = desc ? '2px' : '0';
|
||
const p = document.createElement('div');
|
||
p.textContent = desc || '';
|
||
p.style.opacity = '0.9';
|
||
p.style.fontSize = '12.5px';
|
||
|
||
body.appendChild(h);
|
||
if (desc) body.appendChild(p);
|
||
wrap.append(ico, body);
|
||
return wrap;
|
||
}
|
||
|
||
// --- 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,''); }
|
||
|
||
// === Policy: forbid list & non‑toneable list (mirror of site script.js) ===
|
||
const FORBID_NAMES = new Set([
|
||
'woman: beard',
|
||
'man with veil',
|
||
'pregnant man',
|
||
'man with bunny ears',
|
||
'men with bunny ears',
|
||
]);
|
||
|
||
const NON_TONEABLE_NAMES = new Set([
|
||
// Core non-toneable bases
|
||
'mechanical arm', 'mechanical leg','anatomical heart','brain','lungs','tooth','bone','eyes','eye','tongue','mouth','biting lips',
|
||
// Fantasy subset (problematic gendered variants remain but not toneable)
|
||
'genie','man genie','woman genie','zombie','man zombie','woman zombie','troll',
|
||
// Activities & gestures we treat as non-toneable
|
||
'skier','snowboarder','speaking head','bust in silhouette','busts in silhouette','people hugging','family','footprints','fingerprint',
|
||
'people fencing',
|
||
// Directional variants we keep but not toneable
|
||
'person walking facing right','person running facing right','person kneeling facing right',
|
||
// Accessibility
|
||
'deaf man','deaf woman',
|
||
// Merfolk
|
||
'merman','mermaid'
|
||
]);
|
||
|
||
function normName(e){
|
||
return String(e?.name || '').trim().toLowerCase();
|
||
}
|
||
function isForbiddenEntry(e){
|
||
const n = normName(e);
|
||
return FORBID_NAMES.has(n);
|
||
}
|
||
function isNonToneableByPolicy(e){
|
||
const n = normName(e);
|
||
if (!n) return false;
|
||
// Families never toneable
|
||
if (isFamilyEntry(e)) return true;
|
||
// Explicit list
|
||
if (NON_TONEABLE_NAMES.has(n)) return true;
|
||
// Buckets by subcategory we consider symbol-like (person-symbol) or family
|
||
const sc = String(e?.subcategory || '').toLowerCase();
|
||
if (sc === 'family' || sc === 'person-symbol') return true;
|
||
return false;
|
||
}
|
||
function isToneableByPolicy(e){
|
||
// Must advertise tone support, must not be family, and not on our non-toneable list.
|
||
if (!e?.supports_skin_tone) return false;
|
||
if (isNonToneableByPolicy(e)) return false;
|
||
return true;
|
||
}
|
||
|
||
// --- Tone applicability heuristic ---
|
||
function canApplyToneTo(emojiStr){
|
||
if (!emojiStr) return false;
|
||
const cps = Array.from(emojiStr);
|
||
// If any explicit Emoji_Modifier_Base exists, it's safe
|
||
try {
|
||
for (let i = 0; i < cps.length; i++) {
|
||
if (/\p{Emoji_Modifier_Base}/u.test(cps[i])) return true;
|
||
}
|
||
} catch { /* property escapes may not be supported; fall through */ }
|
||
|
||
// Fallback heuristic:
|
||
// Count human bases (👨 U+1F468, 👩 U+1F469, 🧑 U+1F9D1)
|
||
let humanCount = 0;
|
||
for (let i = 0; i < cps.length; i++) {
|
||
const cp = cps[i].codePointAt(0);
|
||
if (cp === 0x1F468 || cp === 0x1F469 || cp === 0x1F9D1) humanCount++;
|
||
}
|
||
// Allow tones for couples (2 humans) and single-person emojis; block families (3+ humans)
|
||
if (humanCount >= 3) return false; // families
|
||
if (humanCount === 2) return true; // couples (kiss, couple-with-heart, etc.)
|
||
if (humanCount === 1) return true; // single human (ok)
|
||
|
||
// Non-human (hands etc.) — many support tone, but those have modifier base property
|
||
// Without the property present, avoid applying to prevent stray squares
|
||
return false;
|
||
}
|
||
|
||
// Helper: Detect multi-human ZWJ sequences
|
||
function isMultiHuman(s){
|
||
if (!s) return false;
|
||
const hasZWJ = /\u200D/.test(s);
|
||
if (!hasZWJ) return false;
|
||
let count = 0;
|
||
for (const ch of Array.from(s)) {
|
||
const cp = ch.codePointAt(0);
|
||
if (cp === 0x1F468 || cp === 0x1F469 || cp === 0x1F9D1) count++;
|
||
}
|
||
return count >= 2;
|
||
}
|
||
|
||
// Helper: Detect family entries by metadata
|
||
function isFamilyEntry(e){
|
||
const n = String(e?.name||'').toLowerCase();
|
||
const sc = String(e?.subcategory||'').toLowerCase();
|
||
return n.startsWith('family:') || sc === 'family';
|
||
}
|
||
|
||
function withSkinTone(base, ch){
|
||
const emojiChar = base || '';
|
||
const modifierChar = ch || '';
|
||
if (!emojiChar || !modifierChar) return emojiChar;
|
||
|
||
// Guard: if we heuristically think tone shouldn't be applied, return base
|
||
if (!canApplyToneTo(emojiChar)) return emojiChar;
|
||
|
||
// Split into codepoints (keeps surrogate pairs intact)
|
||
const cps = Array.from(emojiChar);
|
||
|
||
// Try to find the last Emoji_Modifier_Base in the sequence
|
||
let lastBaseIdx = -1;
|
||
try {
|
||
for (let i = 0; i < cps.length; i++) {
|
||
if (/\p{Emoji_Modifier_Base}/u.test(cps[i])) lastBaseIdx = i;
|
||
}
|
||
} catch (_) {
|
||
// Property escapes not supported → handled by fallback below
|
||
}
|
||
|
||
// Fallback: if no base detected, treat first human as base (man, woman, person)
|
||
if (lastBaseIdx === -1) {
|
||
const cp0 = cps[0] ? cps[0].codePointAt(0) : 0;
|
||
const HUMAN_BASES = new Set([0x1F468, 0x1F469, 0x1F9D1]); // 👨, 👩, 🧑
|
||
if (HUMAN_BASES.has(cp0)) lastBaseIdx = 0;
|
||
}
|
||
|
||
if (lastBaseIdx === -1) return emojiChar; // avoid appending stray square
|
||
|
||
// Insert after the base (and after VS16 if present)
|
||
const afterBase = (idx) => {
|
||
if (cps[idx + 1] && cps[idx + 1].codePointAt(0) === 0xFE0F) return idx + 2; // VS16
|
||
return idx + 1;
|
||
};
|
||
const insertPos = afterBase(lastBaseIdx);
|
||
const out = cps.slice(0, insertPos).concat([modifierChar], cps.slice(insertPos));
|
||
return out.join('');
|
||
}
|
||
|
||
// 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); }
|
||
|
||
// === P0: Usage & Cache ===
|
||
const CLIENT_FREE_DAILY_LIMIT = 30; // can be tuned; Pro => unlimited
|
||
const CACHE_TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
|
||
const QUERY_CACHE = new Map(); // in-memory cache: key => {ts, data}
|
||
const PREFETCHING = new Set(); // tracks in-flight prefetch keys (sig|page)
|
||
|
||
function todayKeyUTC(){
|
||
const d = new Date();
|
||
const y = d.getUTCFullYear();
|
||
const m = String(d.getUTCMonth()+1).padStart(2,'0');
|
||
const day = String(d.getUTCDate()).padStart(2,'0');
|
||
return `usage_${y}${m}${day}`;
|
||
}
|
||
|
||
async function getDailyUsage(){
|
||
const key = todayKeyUTC();
|
||
const got = await chrome.storage.local.get([key]);
|
||
return got[key] || { used: 0, limit: CLIENT_FREE_DAILY_LIMIT };
|
||
}
|
||
async function setDailyUsage(obj){
|
||
const key = todayKeyUTC();
|
||
await chrome.storage.local.set({ [key]: obj });
|
||
}
|
||
|
||
function normalize(str){ return String(str||'').trim().toLowerCase(); }
|
||
function slugifyLabel(s){
|
||
const x = normalize(s).replace(/&/g,'and').replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'');
|
||
return x || 'all';
|
||
}
|
||
function signatureFor(qVal, catVal, subVal){
|
||
const qn = normalize(qVal);
|
||
const cats = slugifyLabel(catVal || 'all');
|
||
const subs = subVal ? slugifyLabel(subVal) : '';
|
||
return `${qn}|${cats}|${subs}`;
|
||
}
|
||
|
||
async function getPersistedCache(){
|
||
const got = await chrome.storage.local.get(['searchCache']);
|
||
return got.searchCache || {}; // { key: { ts, data } }
|
||
}
|
||
async function savePersistedCache(cacheObj){
|
||
await chrome.storage.local.set({ searchCache: cacheObj });
|
||
}
|
||
function isFresh(ts){ return (Date.now() - ts) < CACHE_TTL_MS; }
|
||
|
||
function buildHeaders(){
|
||
const base = { 'X-Dewemoji-Frontend': FRONTEND_ID };
|
||
if (licenseValid && licenseKeyCurrent) {
|
||
try {
|
||
const acctPromise = accountId();
|
||
return acctPromise.then(acct => ({
|
||
...base,
|
||
// New preferred auth
|
||
'Authorization': `Bearer ${licenseKeyCurrent}`,
|
||
// Legacy headers kept for backward compatibility
|
||
'X-License-Key': licenseKeyCurrent,
|
||
'X-Account-Id': acct.id
|
||
})).catch(()=>base);
|
||
} catch {
|
||
return Promise.resolve({ ...base, 'Authorization': `Bearer ${licenseKeyCurrent}`, 'X-License-Key': licenseKeyCurrent });
|
||
}
|
||
}
|
||
return Promise.resolve(base);
|
||
}
|
||
|
||
async function prefetchNextIfNeeded(currentSig){
|
||
try {
|
||
// only prefetch when there are more results and we know the next page index
|
||
const nextPage = page + 1;
|
||
if (!(items.length < total && total > 0)) return;
|
||
const key = `${currentSig}|${nextPage}`;
|
||
if (QUERY_CACHE.has(key) || PREFETCHING.has(key)) return;
|
||
|
||
// check persisted cache
|
||
const persisted = await getPersistedCache();
|
||
const ent = persisted[key];
|
||
if (ent && isFresh(ent.ts)) {
|
||
QUERY_CACHE.set(key, ent); // lift to memory
|
||
return;
|
||
}
|
||
|
||
PREFETCHING.add(key);
|
||
|
||
// Build URL for next page
|
||
const params = new URLSearchParams({ page: String(nextPage), limit: String(PAGE_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()}`;
|
||
|
||
// headers (with optional license/account)
|
||
const headers = await buildHeaders();
|
||
|
||
// fetch quietly; avoid throwing UI errors — prefetch is best-effort
|
||
const res = await fetch(url, { cache: 'no-store', headers });
|
||
if (!res.ok) { PREFETCHING.delete(key); return; }
|
||
const data = await res.json().catch(()=>null);
|
||
if (!data || !Array.isArray(data.items)) { PREFETCHING.delete(key); return; }
|
||
|
||
const record = { ts: Date.now(), data };
|
||
QUERY_CACHE.set(key, record);
|
||
const persisted2 = await getPersistedCache();
|
||
persisted2[key] = record;
|
||
// prune stale
|
||
for (const k of Object.keys(persisted2)) { if (!persisted2[k] || !isFresh(persisted2[k].ts)) delete persisted2[k]; }
|
||
await savePersistedCache(persisted2);
|
||
} catch { /* silent */ }
|
||
finally { PREFETCHING.delete(`${currentSig}|${page+1}`); }
|
||
}
|
||
|
||
async function fetchPage(reset=false) {
|
||
more.disabled = true;
|
||
refreshPageLimit();
|
||
if (reset) { page = 1; items = []; list.innerHTML = ""; }
|
||
if (reset) { PREFETCHING.clear(); }
|
||
if (reset) { autoLoadBusy = false; }
|
||
|
||
// Build signature and check limits only for page 1
|
||
const sig = signatureFor(q.value, cat.value, sub.value);
|
||
|
||
// Gate on soft cap for Free (Pro unlimited)
|
||
if (!licenseValid && page === 1) {
|
||
const usage = await getDailyUsage();
|
||
const limit = usage.limit ?? CLIENT_FREE_DAILY_LIMIT;
|
||
// Check persisted cache first; a cached result does not consume usage
|
||
const persisted = await getPersistedCache();
|
||
const entry = persisted[sig];
|
||
const hasFreshPersist = entry && isFresh(entry.ts);
|
||
const hasFreshMem = QUERY_CACHE.has(`${sig}|1`) && isFresh(QUERY_CACHE.get(`${sig}|1`).ts);
|
||
|
||
if (usage.used >= limit && !hasFreshPersist && !hasFreshMem) {
|
||
// At cap and no cache — block network to be fair to server & UX
|
||
showToast('Daily limit reached — Upgrade to Pro');
|
||
updateFooter();
|
||
return;
|
||
}
|
||
}
|
||
|
||
try {
|
||
showSpinner();
|
||
|
||
// Cache key per page
|
||
const key = `${sig}|${page}`;
|
||
|
||
// 1) Try memory cache
|
||
const mem = QUERY_CACHE.get(key);
|
||
if (mem && isFresh(mem.ts)) {
|
||
const data = mem.data;
|
||
total = data.total || 0;
|
||
for (const e of (data.items || [])) { items.push(e); renderCard(e); }
|
||
// Kick off background prefetch for the next page (cached only)
|
||
const currentSig = signatureFor(q.value, cat.value, sub.value);
|
||
prefetchNextIfNeeded(currentSig).catch(()=>{});
|
||
try { ensureSentinel(); setupAutoLoadObserver(); } catch(_) {}
|
||
updateFooter();
|
||
return;
|
||
}
|
||
|
||
// 2) Try persisted cache
|
||
const persisted = await getPersistedCache();
|
||
const ent = persisted[key];
|
||
if (ent && isFresh(ent.ts)) {
|
||
total = ent.data.total || 0;
|
||
for (const e of (ent.data.items || [])) { items.push(e); renderCard(e); }
|
||
QUERY_CACHE.set(key, { ts: ent.ts, data: ent.data }); // promote to mem
|
||
// Kick off background prefetch for the next page (cached only)
|
||
const currentSig = signatureFor(q.value, cat.value, sub.value);
|
||
prefetchNextIfNeeded(currentSig).catch(()=>{});
|
||
try { ensureSentinel(); setupAutoLoadObserver(); } catch(_) {}
|
||
updateFooter();
|
||
return;
|
||
}
|
||
|
||
// 3) Network fetch
|
||
const params = new URLSearchParams({ page: String(page), limit: String(PAGE_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()}`;
|
||
|
||
// (usage increment moved to after successful fetch)
|
||
|
||
const headers = await buildHeaders();
|
||
|
||
// Optional nicety: offline guard (early exit if no network AND no fresh cache)
|
||
if (!navigator.onLine) {
|
||
const hasFreshMem = mem && isFresh(mem.ts);
|
||
const hasFreshPersist = ent && isFresh(ent.ts);
|
||
if (!hasFreshMem && !hasFreshPersist) {
|
||
throw new Error('offline');
|
||
}
|
||
}
|
||
|
||
const res = await fetch(url, { cache: 'no-store', headers });
|
||
|
||
if (!res.ok) throw new Error(`API ${res.status}`);
|
||
lastServerTier = res.headers.get('X-Dewemoji-Tier'); // 'pro' for Pro; null for Free/whitelist
|
||
const data = await res.json();
|
||
total = data.total || 0;
|
||
|
||
if (page === 1 && (!Array.isArray(data.items) || data.items.length === 0)) {
|
||
list.innerHTML = '';
|
||
items = [];
|
||
list.appendChild(buildAlert('warn', 'No results', 'Try another keyword, category, or tone.'));
|
||
setLoadMore('Load more', { hidden: true, disabled: true });
|
||
updateFooter();
|
||
return;
|
||
}
|
||
|
||
// Count usage only on successful network responses for Free, page 1, and when not cached
|
||
if (!licenseValid && page === 1) {
|
||
const cacheHas = !!(mem && isFresh(mem.ts)) || !!(ent && isFresh(ent.ts));
|
||
if (!cacheHas) {
|
||
const usage = await getDailyUsage();
|
||
const limit = usage.limit ?? CLIENT_FREE_DAILY_LIMIT;
|
||
usage.used = Math.min(limit, (usage.used || 0) + 1);
|
||
await setDailyUsage(usage);
|
||
}
|
||
}
|
||
|
||
// Save to caches
|
||
const record = { ts: Date.now(), data };
|
||
QUERY_CACHE.set(key, record);
|
||
const persisted2 = await getPersistedCache();
|
||
persisted2[key] = record;
|
||
// prune old entries occasionally (simple heuristic)
|
||
const now = Date.now();
|
||
for (const k of Object.keys(persisted2)) {
|
||
if (!persisted2[k] || !isFresh(persisted2[k].ts)) delete persisted2[k];
|
||
}
|
||
await savePersistedCache(persisted2);
|
||
|
||
// Render
|
||
for (const e of (data.items || [])) { items.push(e); renderCard(e); }
|
||
// Kick off background prefetch for the next page (cached only)
|
||
const currentSig = signatureFor(q.value, cat.value, sub.value);
|
||
prefetchNextIfNeeded(currentSig).catch(()=>{});
|
||
try { ensureSentinel(); setupAutoLoadObserver(); } catch(_) {}
|
||
updateFooter();
|
||
} catch (err) {
|
||
console.error('Fetch failed', err);
|
||
if (reset || page === 1) {
|
||
list.innerHTML = '';
|
||
items = []; total = 0;
|
||
}
|
||
list?.appendChild(buildAlert('error', 'Failed to load', 'Check your internet connection and try again.'));
|
||
if (page > 1) {
|
||
expectRetry = true;
|
||
setLoadMore('Retry', { hidden: false, disabled: false });
|
||
} else {
|
||
setLoadMore('Load more', { hidden: true, disabled: true });
|
||
}
|
||
} finally {
|
||
hideSpinner();
|
||
}
|
||
}
|
||
|
||
let autoObserver = null;
|
||
let autoLoadBusy = false;
|
||
let autoSentinel = null; // thin element placed right after the grid list
|
||
|
||
function ensureSentinel(){
|
||
if (autoSentinel && autoSentinel.isConnected) return autoSentinel;
|
||
autoSentinel = document.getElementById('autoload-sentinel');
|
||
if (!autoSentinel) {
|
||
autoSentinel = document.createElement('div');
|
||
autoSentinel.id = 'autoload-sentinel';
|
||
autoSentinel.style.cssText = 'width:100%;height:1px;';
|
||
}
|
||
try {
|
||
// Place immediately AFTER the grid list so intersecting means we reached the end
|
||
if (list && autoSentinel.parentElement !== list.parentElement) {
|
||
list.insertAdjacentElement('afterend', autoSentinel);
|
||
}
|
||
} catch(_) {}
|
||
return autoSentinel;
|
||
}
|
||
|
||
function setupAutoLoadObserver(){
|
||
const sentinel = ensureSentinel();
|
||
if (!sentinel) return;
|
||
|
||
if (autoObserver) {
|
||
try { autoObserver.unobserve(sentinel); } catch(_) {}
|
||
} else {
|
||
autoObserver = new IntersectionObserver((entries)=>{
|
||
const hit = entries.some(e => e.isIntersecting);
|
||
if (!hit) return;
|
||
if (autoLoadBusy) return;
|
||
|
||
const loading = !!spinnerEl;
|
||
const canLoad = items.length < total && total > 0;
|
||
if (loading || !canLoad) return;
|
||
|
||
autoLoadBusy = true;
|
||
setLoadMore('Loading…', { disabled: true });
|
||
page += 1;
|
||
fetchPage(false).finally(()=>{ autoLoadBusy = false; });
|
||
}, { root: null, rootMargin: '300px', threshold: 0 });
|
||
}
|
||
|
||
try { autoObserver.observe(sentinel); } catch(_) {}
|
||
}
|
||
|
||
// --- Load More helpers ---
|
||
let expectRetry = false;
|
||
function setLoadMore(label, opts = {}) {
|
||
if (!more) return;
|
||
if (label != null) more.textContent = label;
|
||
if (opts.hidden === true) more.classList.add('hidden');
|
||
if (opts.hidden === false) more.classList.remove('hidden');
|
||
if (typeof opts.disabled === 'boolean') more.disabled = opts.disabled;
|
||
}
|
||
|
||
async function updateFooter() {
|
||
// results text stays
|
||
count.textContent = `${items.length} / ${total}`;
|
||
|
||
// usage text append (right-aligned feel handled by CSS; here we append visually)
|
||
try {
|
||
const { used, limit } = await getDailyUsage();
|
||
const isServerPro = lastServerTier === 'pro';
|
||
const suffix = (licenseValid || isServerPro) ? '' : (limit != null ? ` · ${used} / ${limit} today` : '');
|
||
count.textContent = `${items.length} / ${total}${suffix}`;
|
||
} catch {}
|
||
|
||
const loading = !!spinnerEl;
|
||
const canLoadMore = items.length < total && total > 0;
|
||
if (canLoadMore) {
|
||
expectRetry = false;
|
||
setLoadMore('Load more', { hidden: loading, disabled: loading });
|
||
} else {
|
||
setLoadMore('Load more', { hidden: true, disabled: true });
|
||
}
|
||
// ensure auto-load observer is active when more is visible
|
||
try { setupAutoLoadObserver(); } catch(_) {}
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
|
||
async function migrateDailyLimit(){
|
||
try{
|
||
const usage = await getDailyUsage();
|
||
const target = CLIENT_FREE_DAILY_LIMIT; // 30
|
||
if (!licenseValid) {
|
||
if (typeof usage.limit !== 'number' || usage.limit > target) {
|
||
usage.limit = target;
|
||
await setDailyUsage(usage);
|
||
}
|
||
}
|
||
}catch{}
|
||
}
|
||
|
||
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', () => {
|
||
if (expectRetry) {
|
||
// retry same page without incrementing
|
||
expectRetry = false;
|
||
setLoadMore('Loading…', { disabled: true });
|
||
fetchPage(false);
|
||
return;
|
||
}
|
||
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);
|
||
}
|
||
|
||
// === Tone row UX (row-spanning picker) =========================
|
||
const GRID_COLS = 4; // grid is 4 columns in the panel
|
||
let __toneRow = null; // the expanded row element (if any)
|
||
let __activeCard = null; // the card that owns the expanded row
|
||
let __prevOverflowY = '';
|
||
|
||
function closeToneRow(){
|
||
if (__activeCard) {
|
||
__activeCard.classList.remove('active');
|
||
__activeCard.style.background = '';
|
||
__activeCard.style.boxShadow = '';
|
||
if (typeof __activeCard.__setNameStatic === 'function') {
|
||
__activeCard.__setNameStatic();
|
||
}
|
||
__activeCard = null;
|
||
}
|
||
if (__toneRow && __toneRow.parentNode) __toneRow.parentNode.removeChild(__toneRow);
|
||
__toneRow = null;
|
||
}
|
||
|
||
function buildToneRow(baseGlyph, activeIdx, onPick){
|
||
const row = document.createElement('div');
|
||
row.className = 'tone-row';
|
||
|
||
SKIN_TONES.forEach((t,i)=>{
|
||
const btn = document.createElement('button');
|
||
btn.className = 'tone-option';
|
||
|
||
const variant = canApplyToneTo(baseGlyph) ? withSkinTone(baseGlyph, t.ch) : baseGlyph;
|
||
const span = document.createElement('span');
|
||
ensureRenderableAndGlyph(variant, span);
|
||
btn.appendChild(span);
|
||
|
||
if (i === activeIdx) btn.classList.add('selected');
|
||
btn.addEventListener('click', ()=> onPick(i, t, variant));
|
||
row.appendChild(btn);
|
||
});
|
||
|
||
return row;
|
||
}
|
||
|
||
function openToneRowFor(card, item, baseGlyph){
|
||
// Toggle behavior
|
||
if (__activeCard === card) { closeToneRow(); return; }
|
||
// Close any previous
|
||
closeToneRow();
|
||
|
||
__activeCard = card;
|
||
// Visual highlight for the active owner card
|
||
card.classList.add('active');
|
||
card.style.background = 'var(--c-chip,#eef2ff)';
|
||
card.style.boxShadow = '0 0 0 2px rgba(59,130,246,.65) inset';
|
||
if (isToneableByPolicy(item) && typeof card.__setNameMarquee === 'function') {
|
||
card.__setNameMarquee();
|
||
}
|
||
|
||
// Decide where to insert: after the end of this 4-col row
|
||
const cardsOnly = Array.from(list.querySelectorAll('.card'));
|
||
const idx = cardsOnly.indexOf(card);
|
||
const rowStart = Math.floor(idx / GRID_COLS) * GRID_COLS;
|
||
const rowEnd = Math.min(rowStart + GRID_COLS - 1, cardsOnly.length - 1);
|
||
const anchor = cardsOnly[rowEnd] || card;
|
||
|
||
const prefIdxPromise = getPreferredToneIndex();
|
||
|
||
(async ()=>{
|
||
const currentIdx = await prefIdxPromise;
|
||
const base = stripSkinTone(item.emoji_base || baseGlyph);
|
||
__toneRow = buildToneRow(base, currentIdx, async (newIdx, tone, variant)=>{
|
||
if (toneLock && currentIdx < 0) {
|
||
await setPreferredToneIndex(newIdx);
|
||
showToast('Preferred tone set');
|
||
}
|
||
await performEmojiAction(variant);
|
||
closeToneRow();
|
||
});
|
||
anchor.insertAdjacentElement('afterend', __toneRow);
|
||
try { __toneRow.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } catch(_){}
|
||
})();
|
||
}
|
||
|
||
// (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 btn = document.createElement('button');
|
||
btn.className = 'tone-btn';
|
||
btn.title = t.label;
|
||
btn.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)';
|
||
|
||
// Build the toned variant only when applicable; else preview base to avoid squares
|
||
const variant = canApplyToneTo(base) ? withSkinTone(base, t.ch) : base;
|
||
const span = document.createElement('span');
|
||
span.style.display = 'inline-flex';
|
||
span.style.alignItems = 'center';
|
||
span.style.justifyContent = 'center';
|
||
ensureRenderableAndGlyph(variant, span);
|
||
btn.appendChild(span);
|
||
|
||
if (i === activeIdx) btn.classList.add('selected');
|
||
btn.addEventListener('click', () => onPick(i, t));
|
||
el.appendChild(btn);
|
||
});
|
||
setTimeout(() => document.addEventListener('click', handleOutsideClose, true), 0);
|
||
return el;
|
||
}
|
||
|
||
// initial
|
||
loadCategories().catch(console.error);
|
||
(async () => {
|
||
try {
|
||
await loadSettings();
|
||
await migrateDailyLimit();
|
||
} catch(_) {}
|
||
await fetchPage(true).catch(console.error);
|
||
try { setupAutoLoadObserver(); } catch(_) {}
|
||
})();
|
||
|
||
async function loadSettings() {
|
||
const data = await chrome.storage.local.get(['licenseValid', 'licenseKey', 'actionMode']);
|
||
licenseValid = !!data.licenseValid;
|
||
refreshPageLimit();
|
||
actionMode = data.actionMode || 'copy';
|
||
if (data.licenseKey) licenseKeyEl && (licenseKeyEl.value = data.licenseKey);
|
||
licenseKeyCurrent = 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 || '' });
|
||
}
|
||
|
||
// --- Helper to render Free status with CTA ---
|
||
function freeStatusHTML(){
|
||
return 'Free mode — Pro features locked.' +
|
||
'<br><a href="https://dewemoji.com/pricing" target="_blank" class="cta-link"' +
|
||
' style="margin-left:6px; text-decoration: underline; font-weight:500;">' +
|
||
'🔓 Upgrade to Pro' +
|
||
'</a>';
|
||
}
|
||
|
||
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.innerHTML = freeStatusHTML());
|
||
} 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();
|
||
// --- PATCH: Refresh tone section after license state changes ---
|
||
try { renderToneSettingsSection(); } catch(_) {}
|
||
}
|
||
|
||
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;
|
||
// Disable lock checkbox and visually dim if license is invalid
|
||
lockEl.disabled = !licenseValid;
|
||
if (!licenseValid) {
|
||
lockEl.parentElement.style.opacity = '0.5';
|
||
} else {
|
||
lockEl.parentElement.style.opacity = '';
|
||
}
|
||
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');
|
||
// Disable tone chips and visually dim if license is invalid
|
||
if (!licenseValid) {
|
||
b.disabled = true;
|
||
b.style.opacity = '0.5';
|
||
b.style.pointerEvents = 'none';
|
||
}
|
||
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'); });
|
||
// lock page scroll while sheet is open
|
||
try {
|
||
__prevOverflowY = document.documentElement.style.overflowY || '';
|
||
document.documentElement.style.overflowY = 'hidden';
|
||
// also lock body in case the site CSS scrolls body instead of root
|
||
document.body && (document.body.style.overflowY = 'hidden');
|
||
} catch(_) {}
|
||
}
|
||
function closeSheet() {
|
||
sheet.classList.remove('show'); backdrop.classList.remove('show');
|
||
setTimeout(() => {
|
||
sheet.setAttribute('hidden',''); backdrop.setAttribute('hidden','');
|
||
// restore page scroll after the closing animation completes
|
||
try {
|
||
document.documentElement.style.overflowY = __prevOverflowY || '';
|
||
document.body && (document.body.style.overflowY = '');
|
||
} catch(_) {}
|
||
}, 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; }
|
||
try {
|
||
setLicenseBusy(true, 'Verifying…');
|
||
// Call your API /license/verify (works for Gumroad and Mayar)
|
||
const acct = await accountId();
|
||
const res = await fetch('https://api.dewemoji.com/v1/license/verify', {
|
||
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().catch(() => ({}));
|
||
if (!res.ok || !data.ok) throw new Error(data?.error || `Verify ${res.status}`);
|
||
|
||
licenseValid = true;
|
||
refreshPageLimit();
|
||
licenseKeyCurrent = key;
|
||
await chrome.storage.local.set({ licenseValid: true, licenseKey: key, lastLicenseCheck: Date.now() });
|
||
|
||
applyLicenseUI();
|
||
setLicenseEditMode(false);
|
||
setStatusTag();
|
||
// PATCH: Immediately re-render tone section to enable controls after activation
|
||
renderToneSettingsSection();
|
||
showToast('License activated ✓');
|
||
|
||
// Refresh results so Pro headers/tier take effect immediately
|
||
fetchPage(true).catch(console.error);
|
||
setLicenseBusy(false);
|
||
} catch (e) {
|
||
setLicenseBusy(false);
|
||
licenseValid = false;
|
||
await chrome.storage.local.set({ licenseValid: false });
|
||
applyLicenseUI();
|
||
setStatusTag();
|
||
showToast(`Activation failed${e?.message ? ': ' + e.message : ''}`);
|
||
}
|
||
});
|
||
|
||
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 = await showConfirmModal({
|
||
title: 'Deactivate Dewemoji Pro?',
|
||
message: 'Deactivate Pro on this device?',
|
||
okText: 'Deactivate',
|
||
cancelText: 'Cancel'
|
||
});
|
||
if (!ok) return;
|
||
setLicenseBusy(true, 'Deactivating…');
|
||
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;
|
||
refreshPageLimit();
|
||
await chrome.storage.local.set({ licenseValid: false, licenseKey: '' });
|
||
if (licenseKeyEl) licenseKeyEl.value = '';
|
||
applyLicenseUI();
|
||
// PATCH: Immediately re-render tone section to disable controls after deactivation
|
||
renderToneSettingsSection();
|
||
licenseKeyCurrent = '';
|
||
setLicenseEditMode(false);
|
||
showToast('License deactivated');
|
||
fetchPage(true).catch(console.error);
|
||
setLicenseBusy(false);
|
||
} catch (e) {
|
||
setLicenseBusy(false);
|
||
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 || '';
|
||
// Policy: skip forbidden entries entirely
|
||
if (isForbiddenEntry(e)) return;
|
||
const FAMILY = isFamilyEntry(e);
|
||
|
||
const emo = document.createElement('div');
|
||
emo.className = 'emo';
|
||
|
||
let baseGlyph = e.emoji || e.symbol || e.text || '';
|
||
// Strictly sanitize: families & any non‑toneable policy items must not carry tone modifiers
|
||
if (FAMILY || !isToneableByPolicy(e) || isMultiHuman(baseGlyph)) {
|
||
baseGlyph = stripSkinTone(baseGlyph);
|
||
}
|
||
|
||
const prefIdxPromise = getPreferredToneIndex();
|
||
|
||
(async () => {
|
||
let renderGlyph = baseGlyph;
|
||
if (licenseValid && toneLock && isToneableByPolicy(e) && !FAMILY) {
|
||
const idx = await prefIdxPromise;
|
||
if (idx >= 0) {
|
||
const base = stripSkinTone(e.emoji_base || baseGlyph);
|
||
if (isToneableByPolicy(e) && canApplyToneTo(base)) {
|
||
renderGlyph = withSkinTone(base, SKIN_TONES[idx].ch);
|
||
}
|
||
}
|
||
}
|
||
const res = ensureRenderableAndGlyph(renderGlyph, emo);
|
||
baseGlyph = res.glyph;
|
||
})();
|
||
|
||
// --- Name (truncated by default; marquee on hover/active) ---
|
||
function buildNameStatic(text){
|
||
const el = document.createElement('div');
|
||
el.className = 'nm';
|
||
el.textContent = text;
|
||
return el;
|
||
}
|
||
function buildNameMarquee(text){
|
||
const mq = document.createElement('marquee');
|
||
mq.className = 'nm';
|
||
mq.setAttribute('behavior','scroll');
|
||
mq.setAttribute('direction','left');
|
||
mq.setAttribute('scrollamount','3');
|
||
mq.textContent = text;
|
||
return mq;
|
||
}
|
||
// --- Name: truncated (tooltip on hover); marquee only when .card.active ---
|
||
function buildNameStatic(text){
|
||
const el = document.createElement('div');
|
||
el.className = 'nm';
|
||
el.textContent = text;
|
||
// tooltip for hover
|
||
el.title = text;
|
||
return el;
|
||
}
|
||
function buildNameMarquee(text){
|
||
const mq = document.createElement('marquee');
|
||
mq.className = 'nm';
|
||
mq.setAttribute('behavior','scroll');
|
||
mq.setAttribute('direction','left');
|
||
mq.setAttribute('scrollamount','3');
|
||
mq.setAttribute('truespeed','');
|
||
// keep tooltip text
|
||
mq.title = text;
|
||
mq.textContent = text;
|
||
return mq;
|
||
}
|
||
let nameEl = buildNameStatic(e.name || '');
|
||
|
||
// Toggle helpers invoked by tone-row open/close
|
||
card.__setNameStatic = () => {
|
||
if (!nameEl || nameEl.tagName !== 'MARQUEE') return;
|
||
const s = buildNameStatic(e.name || '');
|
||
nameEl.replaceWith(s);
|
||
nameEl = s;
|
||
};
|
||
card.__setNameMarquee = () => {
|
||
// If already marquee, do nothing
|
||
if (nameEl && nameEl.tagName === 'MARQUEE') return;
|
||
// Ensure we only marquee when the static text overflows its container
|
||
const needsMarquee = (() => {
|
||
try {
|
||
// Force a layout read; the element must be in the DOM
|
||
const el = nameEl;
|
||
if (!el) return false;
|
||
// Small epsilon to avoid float rounding issues
|
||
return (el.scrollWidth - el.clientWidth) > 1;
|
||
} catch(_) { return false; }
|
||
})();
|
||
if (!needsMarquee) return; // keep static if it fits
|
||
|
||
const m = buildNameMarquee(e.name || '');
|
||
nameEl.replaceWith(m);
|
||
nameEl = m;
|
||
// best-effort nudge so it begins smoothly
|
||
requestAnimationFrame(() => { try { m.stop && m.stop(); m.start && m.start(); } catch(_){} });
|
||
};
|
||
|
||
// Optional UI cue: dots for toneable entries
|
||
if (isToneableByPolicy(e)) {
|
||
const dots = document.createElement('div');
|
||
dots.className = 'tone-ind';
|
||
dots.style.cssText = 'position:absolute;top:6px;right:6px;display:flex;gap:3px;';
|
||
const sw = (c)=>{ const d=document.createElement('span'); d.style.cssText=`width:6px;height:6px;border-radius:50%;background:${c};opacity:.9;`; return d; };
|
||
dots.append(sw('#f7d7c4')); dots.append(sw('#c68642')); dots.append(sw('#8d5524'));
|
||
card.appendChild(dots);
|
||
}
|
||
|
||
card.addEventListener('click', async () => {
|
||
// For non‑Pro, or non‑toneable, or families → perform action immediately
|
||
if (!licenseValid || !isToneableByPolicy(e) || isFamilyEntry(e)) {
|
||
performEmojiAction(baseGlyph).catch(console.error);
|
||
return;
|
||
}
|
||
// Open the row‑spanning tone chooser right below this card's row
|
||
openToneRowFor(card, e, baseGlyph);
|
||
});
|
||
|
||
card.append(emo, nameEl);
|
||
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));
|
||
}
|
||
|
||
(async function verifyLicenseOnBoot(){
|
||
try {
|
||
const { licenseKey, lastLicenseCheck, licenseValid: storedValid } = await chrome.storage.local.get(['licenseKey','lastLicenseCheck','licenseValid']);
|
||
if (!licenseKey) return;
|
||
const day = 24*60*60*1000;
|
||
// If licenseValid is true and last check is within a day, skip checking and set UI immediately
|
||
if (storedValid && (Date.now() - (lastLicenseCheck||0) < day)) {
|
||
licenseValid = true;
|
||
refreshPageLimit();
|
||
applyLicenseUI();
|
||
setStatusTag();
|
||
licenseStatusEl && (licenseStatusEl.textContent = 'Pro active');
|
||
return;
|
||
}
|
||
// Otherwise, show "Checking license…" while verifying
|
||
licenseStatusEl && (licenseStatusEl.textContent = 'Checking license…');
|
||
const acct = await accountId();
|
||
const res = await fetch('https://api.dewemoji.com/v1/license/verify', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ key: licenseKey, account_id: acct.id, version: chrome.runtime.getManifest().version })
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
const ok = !!data.ok;
|
||
await chrome.storage.local.set({ licenseValid: ok, lastLicenseCheck: Date.now() });
|
||
licenseValid = ok;
|
||
refreshPageLimit();
|
||
applyLicenseUI();
|
||
setStatusTag();
|
||
if (licenseStatusEl) {
|
||
if (licenseValid) {
|
||
licenseStatusEl.textContent = 'Pro active';
|
||
} else {
|
||
licenseStatusEl.innerHTML = freeStatusHTML();
|
||
}
|
||
}
|
||
} 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();
|
||
}; |