first commit
This commit is contained in:
44
background.js
Normal file
44
background.js
Normal 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
212
content.js
Normal 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
41
manifest.json
Normal 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
113
panel.html
Normal 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
989
panel.js
Normal 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 → A–Z
|
||||
}
|
||||
|
||||
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
256
styles.css
Normal 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 doesn’t overlap sheet */
|
||||
.toast { bottom: 18px; }
|
||||
2
vendor/twemoji.min.js
vendored
Normal file
2
vendor/twemoji.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user