first commit

This commit is contained in:
dwindown
2025-08-29 23:36:07 +07:00
commit a21f9927e4
7 changed files with 1657 additions and 0 deletions

44
background.js Normal file
View File

@@ -0,0 +1,44 @@
// Let Chrome open the panel when the toolbar icon is clicked
chrome.runtime.onInstalled.addListener(() => {
chrome.sidePanel?.setPanelBehavior?.({ openPanelOnActionClick: true });
});
// If you still want to ensure the correct path on click, setOptions ONLY:
chrome.action.onClicked.addListener(async () => {
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) return;
// inject content.js before the panel opens (best effort; ignore errors)
try {
const pong = await chrome.tabs.sendMessage(tab.id, { type: 'dewemoji_ping' });
if (!pong?.ok) {
await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['content.js'] });
}
} catch {
// No content script: inject it
try { await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['content.js'] }); } catch {}
}
await chrome.sidePanel.setOptions({ tabId: tab.id, path: "panel.html" }); // Chrome opens it
});
// Keyboard shortcut: we do open() here (this is a user gesture)
chrome.commands.onCommand.addListener(async (command) => {
if (command !== "toggle-panel") return;
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab?.id) return;
// inject content.js before we open the panel with keyboard
try {
const pong = await chrome.tabs.sendMessage(tab.id, { type: 'dewemoji_ping' });
if (!pong?.ok) {
await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['content.js'] });
}
} catch {
// No content script: inject it
try { await chrome.scripting.executeScript({ target: { tabId: tab.id }, files: ['content.js'] }); } catch {}
}
await chrome.sidePanel.setOptions({ tabId: tab.id, path: "panel.html" });
await chrome.sidePanel.open({ tabId: tab.id });
});

212
content.js Normal file
View File

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

41
manifest.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "Dewemoji - Emojis Made Effortless",
"description": "Search emojis and insert into any text field.",
"version": "1.0.0",
"manifest_version": 3,
"permissions": ["storage", "scripting", "activeTab", "sidePanel", "identity", "identity.email"],
"side_panel": {
"default_path": "panel.html"
},
"host_permissions": ["<all_urls>"],
"action": {
"default_title": "Emoji Widget"
},
"background": {
"service_worker": "background.js"
},
"commands": {
"toggle-panel": {
"suggested_key": {
"default": "Ctrl+Shift+E",
"mac": "Command+Shift+E"
},
"description": "Toggle Emoji Side Panel"
}
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle",
"all_frames": false
}
]
}

113
panel.html Normal file
View File

@@ -0,0 +1,113 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Emoji Widget</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link href="styles.css" rel="stylesheet">
<script src="vendor/twemoji.min.js"></script>
</head>
<body>
<header class="hdr">
<h1 class="ttl">Find Your Emoji <span id="dewemoji-status" class="tag free">Free</span></h1>
<div class="bar">
<input id="q" class="inp" type="text" placeholder="Search (e.g. love)" />
<button id="clear" class="btn icon" title="Clear" aria-label="Clear"></button>
<button id="theme" class="btn icon" title="Toggle theme" aria-label="Toggle theme">🌙</button>
<button id="settings" class="btn icon" title="Settings" aria-label="Settings">⚙️</button>
</div>
</header>
<section class="filters">
<select id="cat" class="sel">
<option value="">All categories</option>
</select>
<select id="sub" class="sel" disabled>
<option value="">All subcategories</option>
</select>
</section>
<main id="list" class="grid"></main>
<footer class="ft">
<button id="more" class="btn w">Load more</button>
<span id="count" class="muted nowrap"></span>
<span id="ver" class="badge nowrap"></span>
</footer>
<!-- Settings sheet -->
<div id="sheet-backdrop" class="backdrop" hidden></div>
<aside id="settings-sheet" class="sheet" hidden role="dialog" aria-modal="true" aria-labelledby="settings-title">
<div class="sheet-head">
<h3 id="settings-title">Settings</h3>
<button id="sheet-close" class="btn icon" title="Close" aria-label="Close"></button>
</div>
<div class="sheet-body">
<div class="tabs">
<button class="tab active" data-tab="general">General</button>
<button class="tab" data-tab="pro">Pro</button>
</div>
<div id="tab-general" class="tabpane active">
<!-- Action mode -->
<div class="field">
<label class="lbl">Action on emoji click</label>
<div class="radios" id="mode-group">
<label class="radio">
<input type="radio" name="actionMode" value="copy" checked>
<span>Copy to clipboard</span>
<span class="tag free">Free</span>
</label>
<label class="radio">
<input type="radio" name="actionMode" value="insert">
<span>Insert to input</span>
<span class="tag pro">Pro</span>
</label>
<label class="radio">
<input type="radio" name="actionMode" value="auto">
<span>Automatic</span>
<span class="tag pro">Pro</span>
</label>
</div>
<p class="hint muted">Automatic tries to insert at the last caret; if not possible, it copies instead.</p>
</div>
</div>
<div id="tab-pro" class="tabpane">
<!-- License (first) -->
<div class="field">
<label class="lbl">License</label>
<div class="row">
<input id="license-key" class="inp" placeholder="Enter license key" type="password">
<button id="license-activate" class="btn">Activate</button>
<button id="license-deactivate" class="btn ghost">Deactivate</button>
</div>
<div class="row" style="margin-top:6px;">
<button id="license-edit" class="link" style="width: fit-content">Change key</button>
<button id="license-cancel-edit" class="link" style="display:none;width: fit-content">Cancel</button>
<span id="license-status" class="muted">Free mode — Pro features locked.</span>
</div>
</div>
<!-- Tone settings are injected here by panel.js -->
<!-- Diagnostics (hidden for debugging) -->
<div class="field" style="display:none;">
<label class="lbl">Diagnostics</label>
<div class="row">
<button id="diag-run" class="btn">Run diagnostics</button>
<span id="diag-spin" class="muted" style="display:none;">Running…</span>
</div>
<div id="diag-out" class="diagbox muted"></div>
<p class="hint muted">Tip: Click in a text box on the page first, then run diagnostics.</p>
</div>
</div>
</div>
</aside>
<div id="toast" class="toast" role="status" aria-live="polite"></div>
<script src="panel.js"></script>
</body>
</html>

989
panel.js Normal file
View File

@@ -0,0 +1,989 @@
// === 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();
};

256
styles.css Normal file
View File

@@ -0,0 +1,256 @@
:root {
color-scheme: light dark;
--bg:#fff; --fg:#111; --mut:#6b7280; --dim:#f3f4f6; --br:#e5e7eb;
}
@media (prefers-color-scheme: dark){
:root { --bg:#0b1220; --fg:#e5e7eb; --mut:#9ca3af; --dim:#111827; --br:#374151; }
}
/* explicit override by class */
.theme-light {
color-scheme: light;
--bg:#fff; --fg:#111; --mut:#6b7280; --dim:#f3f4f6; --br:#e5e7eb;
}
.theme-dark {
color-scheme: dark;
--bg:#0b1220; --fg:#e5e7eb; --mut:#9ca3af; --dim:#111827; --br:#374151;
}
html, body { margin:0; padding:0; background:var(--bg); color:var(--fg);
font:14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, Inter, sans-serif; }
.hdr { position:sticky; top:0; background:var(--bg); padding:10px 10px 6px; border-bottom:1px solid var(--br); }
.ttl { margin:0 0 8px; font-size:16px; font-weight:700; }
.bar { display:flex; gap:6px; }
.inp { flex:1; padding:8px 8px; border:1px solid var(--br); border-radius:8px; background:var(--bg); color:var(--fg); }
/* buttons */
.btn { padding:8px 10px; border:1px solid var(--br); border-radius:8px; background:var(--dim); cursor:pointer; }
.btn:hover { filter: brightness(0.98); }
.btn.icon { width:36px; height:36px; display:grid; place-items:center; padding:0; }
/* disabled buttons (e.g., Load more) */
.btn[disabled] { opacity:.55; cursor:not-allowed; }
.filters { display:grid; grid-template-columns:1fr 1fr; gap:6px; padding:8px 10px; border-bottom:1px solid var(--br); }
.grid { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); gap:8px; padding:10px; }
.card {
display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:6px; padding:10px; border:1px solid var(--br); border-radius:12px; background:var(--bg);
}
.card .emo { font-size:28px; line-height:1; }
.card .nm { font-size:12px; color:var(--mut); text-align:center; max-width:100%;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.ft { display:flex; align-items:center; justify-content: space-between; gap:8px; padding:10px; border-top:1px solid var(--br);
position:sticky; bottom:0; background:var(--bg); }
.muted { color:var(--mut); }
.nowrap { white-space: nowrap; }
/* Toast */
.toast {
position: fixed; left: 50%; bottom: 16px; transform: translateX(-50%);
background: var(--dim); color: var(--fg); padding: 8px 12px; border-radius: 8px;
border:1px solid var(--br); opacity: 0; pointer-events:none; transition: opacity .18s ease;
}
.toast.show { opacity: 1; }
.sel {
padding: 8px 10px;
border: 1px solid var(--br);
border-radius: 8px;
background: var(--bg);
color: var(--fg);
appearance: none; /* cleaner look */
background-image:
linear-gradient(45deg, transparent 50%, var(--mut) 50%),
linear-gradient(135deg, var(--mut) 50%, transparent 50%);
background-position:
calc(100% - 18px) calc(50% - 3px),
calc(100% - 12px) calc(50% - 3px);
background-size: 6px 6px, 6px 6px;
background-repeat: no-repeat;
}
.sel:disabled { opacity: .6; }
.badge {
padding:2px 8px; border-radius:9999px; font-size:11px; line-height:1.6;
background: var(--dim); color: var(--fg); border:1px solid var(--br);
}
/* Backdrop + Sheet (modal) */
.backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,.35);
z-index: 80; opacity: 0; transition: opacity .18s ease;
}
.backdrop.show { opacity: 1; }
.sheet {
position: fixed; right: 12px; bottom: 12px; left: 12px; max-width: 520px;
margin: 0 auto; z-index: 81; background: var(--bg); color: var(--fg);
border: 1px solid var(--br); border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,.25);
transform: translateY(8px); opacity: 0; transition: transform .18s ease, opacity .18s ease;
}
.sheet.show { transform: translateY(0); opacity: 1; }
.sheet-head { display:flex; align-items:center; justify-content:space-between; padding:12px; border-bottom:1px solid var(--br); border-radius: inherit}
.sheet-body { padding:12px; display:grid; gap:14px; }
.field .lbl { display:block; font-weight:600; margin-bottom:6px; }
.field .hint { margin-top:6px; font-size:12px; }
.row { display:flex; gap:8px; }
/* Radios */
.radios { display:grid; gap:8px; }
.radio { display:flex; align-items:center; gap:8px; padding:8px; border:1px solid var(--br); border-radius:8px; background: var(--bg); }
.radio input[type="radio"] { accent-color: #3b82f6; }
.radio[aria-disabled="true"] { opacity:.55; }
.tag {
margin-left:auto; font-size:11px; padding:2px 8px; border-radius:9999px;
border:1px solid var(--br); background: var(--dim); color: var(--fg);
}
/* Badge (version) already exists — keep it; this is just a reminder */
.badge { padding:2px 8px; border-radius:9999px; font-size:11px; line-height:1.6;
background: var(--dim); color: var(--fg); border:1px solid var(--br); }
/* Buttons & inputs already set; ensure icon buttons look good */
.btn.icon { width:36px; height:36px; display:grid; place-items:center; padding:0; }
.btn[disabled] { opacity:.55; cursor:not-allowed; }
.diagbox {
margin-top: 8px;
padding: 10px;
border: 1px dashed var(--br);
border-radius: 8px;
background: var(--dim);
color: var(--fg);
font-size: 12px;
white-space: pre-wrap;
word-break: break-word;
}
/* apply to the glyph container you use */
.emo, .emo * {
font-family:
"Segoe UI Emoji", "Noto Color Emoji", "Apple Color Emoji",
"Twemoji Mozilla", "EmojiOne Color", "Segoe UI Symbol",
system-ui, sans-serif !important;
font-variant-emoji: emoji;
line-height: 1;
}
.emo img {
width: 28px;
height: 28px;
display: block;
}
#dewemoji-status {
float: right;
}
/* Settings tabs */
.tabs {
display: flex;
border-bottom: 1px solid var(--br);
margin-bottom: 12px;
}
.tab {
flex: 1;
padding: 8px;
text-align: center;
cursor: pointer;
background: var(--dim);
border: 1px solid var(--br);
border-bottom: none;
border-radius: 8px 8px 0 0;
font-weight: 600;
}
.tab:not(.active) {
opacity: 0.6;
}
.tab.active {
background: var(--bg);
color: var(--fg);
opacity: 1;
}
.tabpane { display: none; }
.tabpane.active { display: block; }
/* Tone palette theming */
.theme-light, :root.theme-light {
--c-bg: var(--bg, #ffffff);
--c-chip: var(--dim, #f3f4f6);
--c-border: var(--br, #e5e7eb);
}
.theme-dark, :root.theme-dark {
--c-bg: var(--bg, #0f172a);
--c-chip: var(--dim, #111827);
--c-border: var(--br, #374151);
}
/* Accent color for focus/selection rings */
.theme-light, :root.theme-light { --accent: #3b82f6; }
.theme-dark, :root.theme-dark { --accent: #60a5fa; }
/* Tone palette buttons (popover + settings) */
.tone-btn, .tone-chip {
border: 1px solid var(--c-border);
background: var(--c-chip);
}
.tone-btn.selected, .tone-chip.selected {
border-color: var(--accent) !important;
box-shadow: 0 0 0 2px var(--accent) !important;
}
.tone-btn:focus-visible, .tone-chip:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* ===== Settings sheet polish ===== */
.sheet { max-width: 540px; }
.sheet-head {
position: sticky; top: 0; background: var(--bg);
z-index: 2; border-bottom: 1px solid var(--br);
}
.sheet-head h3 { margin: 0; }
.sheet-body { padding-top: 10px; }
.field { margin: 12px 0 16px; }
.field .lbl { display: block; font-weight: 600; margin-bottom: 6px; }
/* Compact rows */
.row {
display: grid;
grid-template-columns: 1fr auto auto; /* input | Activate | Deactivate */
gap: 8px;
align-items: center;
}
.row + .row { margin-top: 6px; }
/* Inputs and small buttons */
.inp { padding: 6px 10px; }
.btn.sm { padding: 6px 10px; font-size: 12px; line-height: 1; }
.btn.ghost { background: transparent; border: 1px solid var(--br); }
.link { background: none; border: 0; color: var(--accent); cursor: pointer; padding: 0; }
.link:hover { text-decoration: underline; }
/* Tabs = segmented control */
.tabs {
background: var(--dim); padding: 4px; border-radius: 10px;
display: inline-flex; gap: 4px; border: 1px solid var(--br);
margin-bottom: 12px;
}
.tab { border: 0; background: transparent; padding: 6px 12px; border-radius: 8px; font-weight: 600; }
.tab.active { background: var(--bg); box-shadow: inset 0 0 0 1px var(--br); }
/* License subtext wraps neatly */
#license-status { display: inline-block; margin-left: 8px; }
/* Tone palette spacing + buttons */
.tone-palette { display: flex; gap: 8px; flex-wrap: wrap; }
.tone-chip {
min-width: 40px; height: 36px;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 8px; border: 1px solid var(--c-border); background: var(--c-chip);
}
.tone-chip.selected { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); }
/* Toast a tad higher so it doesnt overlap sheet */
.toast { bottom: 18px; }

2
vendor/twemoji.min.js vendored Normal file

File diff suppressed because one or more lines are too long