ux: improve skintone interaction & active state behavior
- Remove swatch modal → replaced with inline tone row UX - Add active card highlight and tone selection row spanning 4 columns - Apply tone immediately on click (copy/insert according to settings) - Add tooltip on hover for names (replacing marquee on hover) - Enable marquee animation only for overflowed text when active - Integrate forbid & non-toneable filters matching site policy (LGBT & fantasy exclusions) - Sync tone whitelist (roles like technologist, scientist, firefighter, etc.) - Sync tone blacklist (e.g. merman, mermaid, deaf man/woman, woman beard, men with bunny ears) - Add overflow-y hidden toggle when settings modal shown - Move inline styles to style.css for cleaner structure - Refactor panel.js for maintainable tone row injection logic
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "Dewemoji - Emojis Made Effortless",
|
||||
"description": "Find and copy emojis instantly. Optional Pro license unlocks tone lock, insert mode, and more.",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1",
|
||||
"offline_enabled": false,
|
||||
"permissions": [
|
||||
"storage",
|
||||
|
||||
35
panel.html
35
panel.html
@@ -5,6 +5,16 @@
|
||||
<title>Emoji Widget</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="styles.css" rel="stylesheet">
|
||||
<style>
|
||||
.modal { position: fixed; inset: 0; display:flex; align-items:center; justify-content:center; z-index: 1000; }
|
||||
.modal[hidden] { display:none; }
|
||||
.modal-card { background: var(--c-bg, #0b1220); color: var(--c-fg, #e5e7eb); border: 1px solid var(--c-border, rgba(255,255,255,.1)); border-radius: 14px; width: min(420px, 92vw); padding: 16px; box-shadow: 0 20px 50px rgba(0,0,0,.45); }
|
||||
.theme-light .modal-card { background: #fff; color: #111827; border-color: rgba(0,0,0,.08); }
|
||||
.modal-head { display:flex; align-items:center; gap:12px; margin-bottom: 8px; }
|
||||
.modal-logo { width: 28px; height: 28px; border-radius: 6px; }
|
||||
.modal-body { font-size: 14px; opacity: .9; margin: 8px 0 14px; }
|
||||
.modal-actions { display:flex; justify-content:flex-end; gap:10px; }
|
||||
</style>
|
||||
<script src="vendor/twemoji.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -86,7 +96,12 @@
|
||||
<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>
|
||||
<span id="license-status" class="muted">
|
||||
Free mode — Pro features locked.
|
||||
<br><a href="https://dewemoji.com/pricing" target="_blank" class="cta-link" style="margin-left:6px; text-decoration: underline; font-weight: 500;">
|
||||
🔓 Upgrade to Pro
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -107,7 +122,23 @@
|
||||
|
||||
</aside>
|
||||
|
||||
<!-- Branded confirm modal -->
|
||||
<div id="confirm-backdrop" class="backdrop" hidden></div>
|
||||
<div id="confirm-modal" class="modal" hidden role="dialog" aria-modal="true" aria-labelledby="confirm-title" aria-describedby="confirm-message">
|
||||
<div class="modal-card">
|
||||
<div class="modal-head">
|
||||
<img src="assets/icon-128.png" alt="Dewemoji" class="modal-logo" />
|
||||
<h3 id="confirm-title">Confirm</h3>
|
||||
</div>
|
||||
<div id="confirm-message" class="modal-body">Are you sure?</div>
|
||||
<div class="modal-actions">
|
||||
<button id="confirm-cancel" class="btn ghost">Cancel</button>
|
||||
<button id="confirm-ok" class="btn">OK</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||
<script src="panel.js"></script>
|
||||
<script src="panel.js?ver=1.0.1"></script>
|
||||
</body>
|
||||
</html>
|
||||
555
panel.js
555
panel.js
@@ -23,25 +23,85 @@ const licenseDeactivateBtn = document.getElementById('license-deactivate');
|
||||
const licenseEditBtn = document.getElementById('license-edit');
|
||||
const licenseCancelEditBtn = document.getElementById('license-cancel-edit');
|
||||
|
||||
// --- Branded confirm modal helper ---
|
||||
function showConfirmModal(opts = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const modal = document.getElementById('confirm-modal');
|
||||
const back = document.getElementById('confirm-backdrop');
|
||||
const title = document.getElementById('confirm-title');
|
||||
const msg = document.getElementById('confirm-message');
|
||||
const okBtn = document.getElementById('confirm-ok');
|
||||
const noBtn = document.getElementById('confirm-cancel');
|
||||
if (!modal || !back) return resolve(window.confirm(opts.message || 'Are you sure?'));
|
||||
|
||||
title.textContent = opts.title || 'Confirm';
|
||||
msg.textContent = opts.message || 'Are you sure?';
|
||||
okBtn.textContent = opts.okText || 'OK';
|
||||
noBtn.textContent = opts.cancelText || 'Cancel';
|
||||
|
||||
function close(val){
|
||||
modal.setAttribute('hidden','');
|
||||
back.setAttribute('hidden','');
|
||||
document.removeEventListener('keydown', onKey);
|
||||
okBtn.removeEventListener('click', onOk);
|
||||
noBtn.removeEventListener('click', onNo);
|
||||
back.removeEventListener('click', onNo);
|
||||
resolve(val);
|
||||
}
|
||||
function onOk(){ close(true); }
|
||||
function onNo(){ close(false); }
|
||||
function onKey(e){ if (e.key === 'Escape') close(false); if (e.key === 'Enter') close(true); }
|
||||
|
||||
back.removeAttribute('hidden');
|
||||
modal.removeAttribute('hidden');
|
||||
document.addEventListener('keydown', onKey);
|
||||
okBtn.addEventListener('click', onOk);
|
||||
noBtn.addEventListener('click', onNo);
|
||||
back.addEventListener('click', onNo);
|
||||
// focus primary
|
||||
setTimeout(()=> okBtn.focus(), 0);
|
||||
});
|
||||
}
|
||||
|
||||
// --- License busy helpers (UI feedback for activate/deactivate) ---
|
||||
let __licensePrev = { text: '', disabled: false };
|
||||
let __licensePrev = {
|
||||
text: '',
|
||||
activateDisabled: false,
|
||||
keyDisabled: false,
|
||||
deactivateDisabled: false,
|
||||
editDisabled: false,
|
||||
cancelDisabled: false,
|
||||
};
|
||||
function setLicenseBusy(on, label){
|
||||
const btn = licenseActivateBtn;
|
||||
if (!btn) return;
|
||||
if (on) {
|
||||
// snapshot current states
|
||||
__licensePrev.text = btn.textContent;
|
||||
__licensePrev.disabled = btn.disabled;
|
||||
__licensePrev.activateDisabled = !!btn.disabled;
|
||||
__licensePrev.keyDisabled = !!(licenseKeyEl && licenseKeyEl.disabled);
|
||||
__licensePrev.deactivateDisabled = !!(licenseDeactivateBtn && licenseDeactivateBtn.disabled);
|
||||
__licensePrev.editDisabled = !!(licenseEditBtn && licenseEditBtn.disabled);
|
||||
__licensePrev.cancelDisabled = !!(licenseCancelEditBtn && licenseCancelEditBtn.disabled);
|
||||
|
||||
// set busy states
|
||||
btn.textContent = label || 'Verifying…';
|
||||
btn.disabled = true;
|
||||
licenseKeyEl && (licenseKeyEl.disabled = true);
|
||||
licenseDeactivateBtn && (licenseDeactivateBtn.disabled = true);
|
||||
licenseEditBtn && (licenseEditBtn.disabled = true);
|
||||
licenseCancelEditBtn && (licenseCancelEditBtn.disabled = true);
|
||||
licenseStatusEl && (licenseStatusEl.textContent = 'Checking license…');
|
||||
if (licenseKeyEl) licenseKeyEl.disabled = true;
|
||||
if (licenseDeactivateBtn) licenseDeactivateBtn.disabled = true;
|
||||
if (licenseEditBtn) licenseEditBtn.disabled = true;
|
||||
if (licenseCancelEditBtn) licenseCancelEditBtn.disabled = true;
|
||||
if (licenseStatusEl) licenseStatusEl.textContent = 'Checking license…';
|
||||
} else {
|
||||
// restore previous states
|
||||
btn.textContent = __licensePrev.text || 'Activate';
|
||||
btn.disabled = __licensePrev.disabled || false;
|
||||
// Restore enabled state based on current license view
|
||||
btn.disabled = !!__licensePrev.activateDisabled;
|
||||
if (licenseKeyEl) licenseKeyEl.disabled = !!__licensePrev.keyDisabled;
|
||||
if (licenseDeactivateBtn) licenseDeactivateBtn.disabled = !!__licensePrev.deactivateDisabled;
|
||||
if (licenseEditBtn) licenseEditBtn.disabled = !!__licensePrev.editDisabled;
|
||||
if (licenseCancelEditBtn) licenseCancelEditBtn.disabled = !!__licensePrev.cancelDisabled;
|
||||
|
||||
// Re-apply UI (visibility/display) for current license state
|
||||
applyLicenseUI();
|
||||
}
|
||||
}
|
||||
@@ -99,8 +159,28 @@ async function loadCategories() {
|
||||
try {
|
||||
const res = await fetch(`${API.base}${API.cats}`);
|
||||
if (res && res.ok) {
|
||||
CAT_MAP = await res.json();
|
||||
ok = true;
|
||||
const data = await res.json();
|
||||
// Normalize payloads:
|
||||
// A) New shape: { items: [ { name: 'People & Body', subcategories: ['person','person-gesture'] }, ... ] }
|
||||
// B) Legacy shape: { 'People & Body': ['person','person-gesture'], ... }
|
||||
if (data && Array.isArray(data.items)) {
|
||||
const map = {};
|
||||
for (const it of data.items) {
|
||||
if (!it || typeof it.name !== 'string') continue;
|
||||
const name = it.name;
|
||||
const subsRaw = Array.isArray(it.subcategories) ? it.subcategories : [];
|
||||
const subs = subsRaw
|
||||
.map(s => (typeof s === 'string') ? s : (s && (s.slug || s.name) || ''))
|
||||
.filter(Boolean);
|
||||
map[name] = subs;
|
||||
}
|
||||
CAT_MAP = map;
|
||||
} else if (data && typeof data === 'object') {
|
||||
CAT_MAP = data; // assume already in object-map form
|
||||
} else {
|
||||
throw new Error('bad_categories_payload');
|
||||
}
|
||||
ok = Object.keys(CAT_MAP || {}).length > 0;
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
@@ -134,7 +214,11 @@ 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 }));
|
||||
const raw = (CAT_MAP?.[category] || []).slice();
|
||||
const subs = raw
|
||||
.map(s => (typeof s === 'string') ? s : (s && (s.slug || s.name) || ''))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base', numeric: true }));
|
||||
subs.forEach(sc => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = sc;
|
||||
@@ -284,7 +368,145 @@ const SKIN_TONES = [
|
||||
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||''}`; }
|
||||
|
||||
// === Policy: forbid list & non‑toneable list (mirror of site script.js) ===
|
||||
const FORBID_NAMES = new Set([
|
||||
'woman: beard',
|
||||
'man with veil',
|
||||
'pregnant man',
|
||||
'man with bunny ears',
|
||||
'men with bunny ears',
|
||||
]);
|
||||
|
||||
const NON_TONEABLE_NAMES = new Set([
|
||||
// Core non-toneable bases
|
||||
'mechanical arm', 'mechanical leg','anatomical heart','brain','lungs','tooth','bone','eyes','eye','tongue','mouth','biting lips',
|
||||
// Fantasy subset (problematic gendered variants remain but not toneable)
|
||||
'genie','man genie','woman genie','zombie','man zombie','woman zombie','troll',
|
||||
// Activities & gestures we treat as non-toneable
|
||||
'skier','snowboarder','speaking head','bust in silhouette','busts in silhouette','people hugging','family','footprints','fingerprint',
|
||||
'people fencing',
|
||||
// Directional variants we keep but not toneable
|
||||
'person walking facing right','person running facing right','person kneeling facing right',
|
||||
// Accessibility
|
||||
'deaf man','deaf woman',
|
||||
// Merfolk
|
||||
'merman','mermaid'
|
||||
]);
|
||||
|
||||
function normName(e){
|
||||
return String(e?.name || '').trim().toLowerCase();
|
||||
}
|
||||
function isForbiddenEntry(e){
|
||||
const n = normName(e);
|
||||
return FORBID_NAMES.has(n);
|
||||
}
|
||||
function isNonToneableByPolicy(e){
|
||||
const n = normName(e);
|
||||
if (!n) return false;
|
||||
// Families never toneable
|
||||
if (isFamilyEntry(e)) return true;
|
||||
// Explicit list
|
||||
if (NON_TONEABLE_NAMES.has(n)) return true;
|
||||
// Buckets by subcategory we consider symbol-like (person-symbol) or family
|
||||
const sc = String(e?.subcategory || '').toLowerCase();
|
||||
if (sc === 'family' || sc === 'person-symbol') return true;
|
||||
return false;
|
||||
}
|
||||
function isToneableByPolicy(e){
|
||||
// Must advertise tone support, must not be family, and not on our non-toneable list.
|
||||
if (!e?.supports_skin_tone) return false;
|
||||
if (isNonToneableByPolicy(e)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Tone applicability heuristic ---
|
||||
function canApplyToneTo(emojiStr){
|
||||
if (!emojiStr) return false;
|
||||
const cps = Array.from(emojiStr);
|
||||
// If any explicit Emoji_Modifier_Base exists, it's safe
|
||||
try {
|
||||
for (let i = 0; i < cps.length; i++) {
|
||||
if (/\p{Emoji_Modifier_Base}/u.test(cps[i])) return true;
|
||||
}
|
||||
} catch { /* property escapes may not be supported; fall through */ }
|
||||
|
||||
// Fallback heuristic:
|
||||
// Count human bases (👨 U+1F468, 👩 U+1F469, 🧑 U+1F9D1)
|
||||
let humanCount = 0;
|
||||
for (let i = 0; i < cps.length; i++) {
|
||||
const cp = cps[i].codePointAt(0);
|
||||
if (cp === 0x1F468 || cp === 0x1F469 || cp === 0x1F9D1) humanCount++;
|
||||
}
|
||||
// Allow tones for couples (2 humans) and single-person emojis; block families (3+ humans)
|
||||
if (humanCount >= 3) return false; // families
|
||||
if (humanCount === 2) return true; // couples (kiss, couple-with-heart, etc.)
|
||||
if (humanCount === 1) return true; // single human (ok)
|
||||
|
||||
// Non-human (hands etc.) — many support tone, but those have modifier base property
|
||||
// Without the property present, avoid applying to prevent stray squares
|
||||
return false;
|
||||
}
|
||||
|
||||
// Helper: Detect multi-human ZWJ sequences
|
||||
function isMultiHuman(s){
|
||||
if (!s) return false;
|
||||
const hasZWJ = /\u200D/.test(s);
|
||||
if (!hasZWJ) return false;
|
||||
let count = 0;
|
||||
for (const ch of Array.from(s)) {
|
||||
const cp = ch.codePointAt(0);
|
||||
if (cp === 0x1F468 || cp === 0x1F469 || cp === 0x1F9D1) count++;
|
||||
}
|
||||
return count >= 2;
|
||||
}
|
||||
|
||||
// Helper: Detect family entries by metadata
|
||||
function isFamilyEntry(e){
|
||||
const n = String(e?.name||'').toLowerCase();
|
||||
const sc = String(e?.subcategory||'').toLowerCase();
|
||||
return n.startsWith('family:') || sc === 'family';
|
||||
}
|
||||
|
||||
function withSkinTone(base, ch){
|
||||
const emojiChar = base || '';
|
||||
const modifierChar = ch || '';
|
||||
if (!emojiChar || !modifierChar) return emojiChar;
|
||||
|
||||
// Guard: if we heuristically think tone shouldn't be applied, return base
|
||||
if (!canApplyToneTo(emojiChar)) return emojiChar;
|
||||
|
||||
// Split into codepoints (keeps surrogate pairs intact)
|
||||
const cps = Array.from(emojiChar);
|
||||
|
||||
// Try to find the last Emoji_Modifier_Base in the sequence
|
||||
let lastBaseIdx = -1;
|
||||
try {
|
||||
for (let i = 0; i < cps.length; i++) {
|
||||
if (/\p{Emoji_Modifier_Base}/u.test(cps[i])) lastBaseIdx = i;
|
||||
}
|
||||
} catch (_) {
|
||||
// Property escapes not supported → handled by fallback below
|
||||
}
|
||||
|
||||
// Fallback: if no base detected, treat first human as base (man, woman, person)
|
||||
if (lastBaseIdx === -1) {
|
||||
const cp0 = cps[0] ? cps[0].codePointAt(0) : 0;
|
||||
const HUMAN_BASES = new Set([0x1F468, 0x1F469, 0x1F9D1]); // 👨, 👩, 🧑
|
||||
if (HUMAN_BASES.has(cp0)) lastBaseIdx = 0;
|
||||
}
|
||||
|
||||
if (lastBaseIdx === -1) return emojiChar; // avoid appending stray square
|
||||
|
||||
// Insert after the base (and after VS16 if present)
|
||||
const afterBase = (idx) => {
|
||||
if (cps[idx + 1] && cps[idx + 1].codePointAt(0) === 0xFE0F) return idx + 2; // VS16
|
||||
return idx + 1;
|
||||
};
|
||||
const insertPos = afterBase(lastBaseIdx);
|
||||
const out = cps.slice(0, insertPos).concat([modifierChar], cps.slice(insertPos));
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
// Tone helpers: keep globals in sync and expose helpers for slug
|
||||
async function getPreferredToneIndex(){
|
||||
@@ -795,6 +1017,87 @@ function showToast(text='Done') {
|
||||
showToast._t = setTimeout(() => toastEl.classList.remove('show'), 1400);
|
||||
}
|
||||
|
||||
// === Tone row UX (row-spanning picker) =========================
|
||||
const GRID_COLS = 4; // grid is 4 columns in the panel
|
||||
let __toneRow = null; // the expanded row element (if any)
|
||||
let __activeCard = null; // the card that owns the expanded row
|
||||
let __prevOverflowY = '';
|
||||
|
||||
function closeToneRow(){
|
||||
if (__activeCard) {
|
||||
__activeCard.classList.remove('active');
|
||||
__activeCard.style.background = '';
|
||||
__activeCard.style.boxShadow = '';
|
||||
if (typeof __activeCard.__setNameStatic === 'function') {
|
||||
__activeCard.__setNameStatic();
|
||||
}
|
||||
__activeCard = null;
|
||||
}
|
||||
if (__toneRow && __toneRow.parentNode) __toneRow.parentNode.removeChild(__toneRow);
|
||||
__toneRow = null;
|
||||
}
|
||||
|
||||
function buildToneRow(baseGlyph, activeIdx, onPick){
|
||||
const row = document.createElement('div');
|
||||
row.className = 'tone-row';
|
||||
|
||||
SKIN_TONES.forEach((t,i)=>{
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'tone-option';
|
||||
|
||||
const variant = canApplyToneTo(baseGlyph) ? withSkinTone(baseGlyph, t.ch) : baseGlyph;
|
||||
const span = document.createElement('span');
|
||||
ensureRenderableAndGlyph(variant, span);
|
||||
btn.appendChild(span);
|
||||
|
||||
if (i === activeIdx) btn.classList.add('selected');
|
||||
btn.addEventListener('click', ()=> onPick(i, t, variant));
|
||||
row.appendChild(btn);
|
||||
});
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
function openToneRowFor(card, item, baseGlyph){
|
||||
// Toggle behavior
|
||||
if (__activeCard === card) { closeToneRow(); return; }
|
||||
// Close any previous
|
||||
closeToneRow();
|
||||
|
||||
__activeCard = card;
|
||||
// Visual highlight for the active owner card
|
||||
card.classList.add('active');
|
||||
card.style.background = 'var(--c-chip,#eef2ff)';
|
||||
card.style.boxShadow = '0 0 0 2px rgba(59,130,246,.65) inset';
|
||||
if (isToneableByPolicy(item) && typeof card.__setNameMarquee === 'function') {
|
||||
card.__setNameMarquee();
|
||||
}
|
||||
|
||||
// Decide where to insert: after the end of this 4-col row
|
||||
const cardsOnly = Array.from(list.querySelectorAll('.card'));
|
||||
const idx = cardsOnly.indexOf(card);
|
||||
const rowStart = Math.floor(idx / GRID_COLS) * GRID_COLS;
|
||||
const rowEnd = Math.min(rowStart + GRID_COLS - 1, cardsOnly.length - 1);
|
||||
const anchor = cardsOnly[rowEnd] || card;
|
||||
|
||||
const prefIdxPromise = getPreferredToneIndex();
|
||||
|
||||
(async ()=>{
|
||||
const currentIdx = await prefIdxPromise;
|
||||
const base = stripSkinTone(item.emoji_base || baseGlyph);
|
||||
__toneRow = buildToneRow(base, currentIdx, async (newIdx, tone, variant)=>{
|
||||
if (toneLock && currentIdx < 0) {
|
||||
await setPreferredToneIndex(newIdx);
|
||||
showToast('Preferred tone set');
|
||||
}
|
||||
await performEmojiAction(variant);
|
||||
closeToneRow();
|
||||
});
|
||||
anchor.insertAdjacentElement('afterend', __toneRow);
|
||||
try { __toneRow.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } catch(_){}
|
||||
})();
|
||||
}
|
||||
|
||||
// (obsolete) placeholder; real renderCard defined later with Windows support + Pro tone UI
|
||||
function renderCard(e) {}
|
||||
// --- Popover helpers for skin tone picker ---
|
||||
@@ -823,14 +1126,23 @@ function buildTonePopover(base, activeIdx, onPick){
|
||||
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);
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'tone-btn';
|
||||
btn.title = t.label;
|
||||
btn.style.cssText = 'min-width:32px;height:32px;border-radius:6px;border:1px solid transparent;display:flex;align-items:center;justify-content:center;background:var(--c-chip,#f3f4f6)';
|
||||
|
||||
// Build the toned variant only when applicable; else preview base to avoid squares
|
||||
const variant = canApplyToneTo(base) ? withSkinTone(base, t.ch) : base;
|
||||
const span = document.createElement('span');
|
||||
span.style.display = 'inline-flex';
|
||||
span.style.alignItems = 'center';
|
||||
span.style.justifyContent = 'center';
|
||||
ensureRenderableAndGlyph(variant, span);
|
||||
btn.appendChild(span);
|
||||
|
||||
if (i === activeIdx) btn.classList.add('selected');
|
||||
btn.addEventListener('click', () => onPick(i, t));
|
||||
el.appendChild(btn);
|
||||
});
|
||||
setTimeout(() => document.addEventListener('click', handleOutsideClose, true), 0);
|
||||
return el;
|
||||
@@ -869,6 +1181,15 @@ async function saveSettings() {
|
||||
await chrome.storage.local.set({ licenseValid, actionMode, licenseKey: licenseKeyEl?.value || '' });
|
||||
}
|
||||
|
||||
// --- Helper to render Free status with CTA ---
|
||||
function freeStatusHTML(){
|
||||
return 'Free mode — Pro features locked.' +
|
||||
'<br><a href="https://dewemoji.com/pricing" target="_blank" class="cta-link"' +
|
||||
' style="margin-left:6px; text-decoration: underline; font-weight:500;">' +
|
||||
'🔓 Upgrade to Pro' +
|
||||
'</a>';
|
||||
}
|
||||
|
||||
function setLicenseEditMode(on) {
|
||||
if (!licenseKeyEl) return;
|
||||
const isOn = !!on;
|
||||
@@ -909,7 +1230,7 @@ function applyLicenseUI() {
|
||||
// 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.');
|
||||
licenseStatusEl && (licenseStatusEl.innerHTML = freeStatusHTML());
|
||||
} else {
|
||||
// Restore selected mode if Pro; if current actionMode is pro, check it
|
||||
const target = modeGroup.querySelector(`input[type="radio"][value="${actionMode}"]`) ||
|
||||
@@ -932,6 +1253,8 @@ function applyLicenseUI() {
|
||||
if (licenseCancelEditBtn) licenseCancelEditBtn.style.display = 'none';
|
||||
}
|
||||
setStatusTag();
|
||||
// --- PATCH: Refresh tone section after license state changes ---
|
||||
try { renderToneSettingsSection(); } catch(_) {}
|
||||
}
|
||||
|
||||
function applyModeUI() {
|
||||
@@ -1023,6 +1346,13 @@ function renderToneSettingsSection(){
|
||||
pal.innerHTML = '';
|
||||
const lockEl = sec.querySelector('#tone-lock');
|
||||
lockEl.checked = !!toneLock;
|
||||
// Disable lock checkbox and visually dim if license is invalid
|
||||
lockEl.disabled = !licenseValid;
|
||||
if (!licenseValid) {
|
||||
lockEl.parentElement.style.opacity = '0.5';
|
||||
} else {
|
||||
lockEl.parentElement.style.opacity = '';
|
||||
}
|
||||
lockEl.onchange = async (e)=>{ await setToneLock(!!e.target.checked); showToast(toneLock ? 'Tone lock enabled' : 'Tone lock disabled'); };
|
||||
const idx = TONE_SLUGS.indexOf(preferredToneSlug);
|
||||
const basePreview = '✋';
|
||||
@@ -1033,6 +1363,12 @@ function renderToneSettingsSection(){
|
||||
b.textContent = basePreview + t.ch; // small preview
|
||||
b.style.cssText = 'height:32px;border-radius:8px;padding:0 8px;border:1px solid var(--c-border, rgba(0,0,0,.12));background:var(--c-chip,#f3f4f6)';
|
||||
if (i === idx) b.classList.add('selected');
|
||||
// Disable tone chips and visually dim if license is invalid
|
||||
if (!licenseValid) {
|
||||
b.disabled = true;
|
||||
b.style.opacity = '0.5';
|
||||
b.style.pointerEvents = 'none';
|
||||
}
|
||||
b.addEventListener('click', async ()=>{
|
||||
await setPreferredToneIndex(i);
|
||||
showToast('Preferred tone set');
|
||||
@@ -1051,10 +1387,24 @@ function renderToneSettingsSection(){
|
||||
function openSheet() {
|
||||
sheet.removeAttribute('hidden'); backdrop.removeAttribute('hidden');
|
||||
requestAnimationFrame(() => { sheet.classList.add('show'); backdrop.classList.add('show'); });
|
||||
// lock page scroll while sheet is open
|
||||
try {
|
||||
__prevOverflowY = document.documentElement.style.overflowY || '';
|
||||
document.documentElement.style.overflowY = 'hidden';
|
||||
// also lock body in case the site CSS scrolls body instead of root
|
||||
document.body && (document.body.style.overflowY = 'hidden');
|
||||
} catch(_) {}
|
||||
}
|
||||
function closeSheet() {
|
||||
sheet.classList.remove('show'); backdrop.classList.remove('show');
|
||||
setTimeout(() => { sheet.setAttribute('hidden',''); backdrop.setAttribute('hidden',''); }, 180);
|
||||
setTimeout(() => {
|
||||
sheet.setAttribute('hidden',''); backdrop.setAttribute('hidden','');
|
||||
// restore page scroll after the closing animation completes
|
||||
try {
|
||||
document.documentElement.style.overflowY = __prevOverflowY || '';
|
||||
document.body && (document.body.style.overflowY = '');
|
||||
} catch(_) {}
|
||||
}, 180);
|
||||
}
|
||||
|
||||
settingsBtn?.addEventListener('click', openSheet);
|
||||
@@ -1113,6 +1463,8 @@ licenseActivateBtn?.addEventListener('click', async () => {
|
||||
applyLicenseUI();
|
||||
setLicenseEditMode(false);
|
||||
setStatusTag();
|
||||
// PATCH: Immediately re-render tone section to enable controls after activation
|
||||
renderToneSettingsSection();
|
||||
showToast('License activated ✓');
|
||||
|
||||
// Refresh results so Pro headers/tier take effect immediately
|
||||
@@ -1140,7 +1492,12 @@ licenseCancelEditBtn?.addEventListener('click', async () => {
|
||||
|
||||
licenseDeactivateBtn?.addEventListener('click', async () => {
|
||||
if (!licenseValid) return;
|
||||
const ok = confirm('Deactivate Pro on this device?');
|
||||
const ok = await showConfirmModal({
|
||||
title: 'Deactivate Dewemoji Pro?',
|
||||
message: 'Deactivate Pro on this device?',
|
||||
okText: 'Deactivate',
|
||||
cancelText: 'Cancel'
|
||||
});
|
||||
if (!ok) return;
|
||||
setLicenseBusy(true, 'Deactivating…');
|
||||
try {
|
||||
@@ -1155,6 +1512,8 @@ licenseDeactivateBtn?.addEventListener('click', async () => {
|
||||
await chrome.storage.local.set({ licenseValid: false, licenseKey: '' });
|
||||
if (licenseKeyEl) licenseKeyEl.value = '';
|
||||
applyLicenseUI();
|
||||
// PATCH: Immediately re-render tone section to disable controls after deactivation
|
||||
renderToneSettingsSection();
|
||||
licenseKeyCurrent = '';
|
||||
setLicenseEditMode(false);
|
||||
showToast('License deactivated');
|
||||
@@ -1312,60 +1671,125 @@ function renderCard(e) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
card.title = e.name || '';
|
||||
// Policy: skip forbidden entries entirely
|
||||
if (isForbiddenEntry(e)) return;
|
||||
const FAMILY = isFamilyEntry(e);
|
||||
|
||||
const emo = document.createElement('div');
|
||||
emo.className = 'emo';
|
||||
|
||||
let baseGlyph = e.emoji || e.symbol || e.text || '';
|
||||
// Strictly sanitize: families & any non‑toneable policy items must not carry tone modifiers
|
||||
if (FAMILY || !isToneableByPolicy(e) || isMultiHuman(baseGlyph)) {
|
||||
baseGlyph = stripSkinTone(baseGlyph);
|
||||
}
|
||||
|
||||
const prefIdxPromise = getPreferredToneIndex();
|
||||
|
||||
(async () => {
|
||||
let renderGlyph = baseGlyph;
|
||||
if (licenseValid && toneLock && e.supports_skin_tone) {
|
||||
if (licenseValid && toneLock && isToneableByPolicy(e) && !FAMILY) {
|
||||
const idx = await prefIdxPromise;
|
||||
if (idx >= 0) {
|
||||
const base = e.emoji_base || stripSkinTone(baseGlyph);
|
||||
const base = stripSkinTone(e.emoji_base || baseGlyph);
|
||||
if (isToneableByPolicy(e) && canApplyToneTo(base)) {
|
||||
renderGlyph = withSkinTone(base, SKIN_TONES[idx].ch);
|
||||
}
|
||||
}
|
||||
}
|
||||
const res = ensureRenderableAndGlyph(renderGlyph, emo);
|
||||
baseGlyph = res.glyph;
|
||||
})();
|
||||
|
||||
const name = document.createElement('div');
|
||||
name.className = 'nm';
|
||||
name.textContent = e.name || '';
|
||||
// --- Name (truncated by default; marquee on hover/active) ---
|
||||
function buildNameStatic(text){
|
||||
const el = document.createElement('div');
|
||||
el.className = 'nm';
|
||||
el.textContent = text;
|
||||
return el;
|
||||
}
|
||||
function buildNameMarquee(text){
|
||||
const mq = document.createElement('marquee');
|
||||
mq.className = 'nm';
|
||||
mq.setAttribute('behavior','scroll');
|
||||
mq.setAttribute('direction','left');
|
||||
mq.setAttribute('scrollamount','3');
|
||||
mq.textContent = text;
|
||||
return mq;
|
||||
}
|
||||
// --- Name: truncated (tooltip on hover); marquee only when .card.active ---
|
||||
function buildNameStatic(text){
|
||||
const el = document.createElement('div');
|
||||
el.className = 'nm';
|
||||
el.textContent = text;
|
||||
// tooltip for hover
|
||||
el.title = text;
|
||||
return el;
|
||||
}
|
||||
function buildNameMarquee(text){
|
||||
const mq = document.createElement('marquee');
|
||||
mq.className = 'nm';
|
||||
mq.setAttribute('behavior','scroll');
|
||||
mq.setAttribute('direction','left');
|
||||
mq.setAttribute('scrollamount','3');
|
||||
mq.setAttribute('truespeed','');
|
||||
// keep tooltip text
|
||||
mq.title = text;
|
||||
mq.textContent = text;
|
||||
return mq;
|
||||
}
|
||||
let nameEl = buildNameStatic(e.name || '');
|
||||
|
||||
// Toggle helpers invoked by tone-row open/close
|
||||
card.__setNameStatic = () => {
|
||||
if (!nameEl || nameEl.tagName !== 'MARQUEE') return;
|
||||
const s = buildNameStatic(e.name || '');
|
||||
nameEl.replaceWith(s);
|
||||
nameEl = s;
|
||||
};
|
||||
card.__setNameMarquee = () => {
|
||||
// If already marquee, do nothing
|
||||
if (nameEl && nameEl.tagName === 'MARQUEE') return;
|
||||
// Ensure we only marquee when the static text overflows its container
|
||||
const needsMarquee = (() => {
|
||||
try {
|
||||
// Force a layout read; the element must be in the DOM
|
||||
const el = nameEl;
|
||||
if (!el) return false;
|
||||
// Small epsilon to avoid float rounding issues
|
||||
return (el.scrollWidth - el.clientWidth) > 1;
|
||||
} catch(_) { return false; }
|
||||
})();
|
||||
if (!needsMarquee) return; // keep static if it fits
|
||||
|
||||
const m = buildNameMarquee(e.name || '');
|
||||
nameEl.replaceWith(m);
|
||||
nameEl = m;
|
||||
// best-effort nudge so it begins smoothly
|
||||
requestAnimationFrame(() => { try { m.stop && m.stop(); m.start && m.start(); } catch(_){} });
|
||||
};
|
||||
|
||||
// Optional UI cue: dots for toneable entries
|
||||
if (isToneableByPolicy(e)) {
|
||||
const dots = document.createElement('div');
|
||||
dots.className = 'tone-ind';
|
||||
dots.style.cssText = 'position:absolute;top:6px;right:6px;display:flex;gap:3px;';
|
||||
const sw = (c)=>{ const d=document.createElement('span'); d.style.cssText=`width:6px;height:6px;border-radius:50%;background:${c};opacity:.9;`; return d; };
|
||||
dots.append(sw('#f7d7c4')); dots.append(sw('#c68642')); dots.append(sw('#8d5524'));
|
||||
card.appendChild(dots);
|
||||
}
|
||||
|
||||
card.addEventListener('click', async () => {
|
||||
if (!licenseValid || !e.supports_skin_tone) {
|
||||
// For non‑Pro, or non‑toneable, or families → perform action immediately
|
||||
if (!licenseValid || !isToneableByPolicy(e) || isFamilyEntry(e)) {
|
||||
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);
|
||||
// Open the row‑spanning tone chooser right below this card's row
|
||||
openToneRowFor(card, e, baseGlyph);
|
||||
});
|
||||
|
||||
card.append(emo, name);
|
||||
card.append(emo, nameEl);
|
||||
list.appendChild(card);
|
||||
}
|
||||
|
||||
@@ -1382,10 +1806,20 @@ if (window.twemoji) {
|
||||
|
||||
(async function verifyLicenseOnBoot(){
|
||||
try {
|
||||
const { licenseKey, lastLicenseCheck } = await chrome.storage.local.get(['licenseKey','lastLicenseCheck']);
|
||||
const { licenseKey, lastLicenseCheck, licenseValid: storedValid } = await chrome.storage.local.get(['licenseKey','lastLicenseCheck','licenseValid']);
|
||||
if (!licenseKey) return;
|
||||
const day = 24*60*60*1000;
|
||||
// If licenseValid is true and last check is within a day, skip checking and set UI immediately
|
||||
if (storedValid && (Date.now() - (lastLicenseCheck||0) < day)) {
|
||||
licenseValid = true;
|
||||
refreshPageLimit();
|
||||
applyLicenseUI();
|
||||
setStatusTag();
|
||||
licenseStatusEl && (licenseStatusEl.textContent = 'Pro active');
|
||||
return;
|
||||
}
|
||||
// Otherwise, show "Checking license…" while verifying
|
||||
licenseStatusEl && (licenseStatusEl.textContent = 'Checking license…');
|
||||
const day = 24*60*60*1000; if (Date.now() - (lastLicenseCheck||0) < day) return;
|
||||
const acct = await accountId();
|
||||
const res = await fetch('https://api.dewemoji.com/v1/license/verify', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1394,8 +1828,17 @@ if (window.twemoji) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
const ok = !!data.ok;
|
||||
await chrome.storage.local.set({ licenseValid: ok, lastLicenseCheck: Date.now() });
|
||||
licenseValid = ok; refreshPageLimit(); applyLicenseUI(); setStatusTag();
|
||||
licenseStatusEl && (licenseStatusEl.textContent = licenseValid ? 'Pro active' : 'Free mode — Pro features locked.');
|
||||
licenseValid = ok;
|
||||
refreshPageLimit();
|
||||
applyLicenseUI();
|
||||
setStatusTag();
|
||||
if (licenseStatusEl) {
|
||||
if (licenseValid) {
|
||||
licenseStatusEl.textContent = 'Pro active';
|
||||
} else {
|
||||
licenseStatusEl.innerHTML = freeStatusHTML();
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
})();
|
||||
|
||||
|
||||
29
styles.css
29
styles.css
@@ -1,6 +1,6 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg:#fff; --fg:#111; --mut:#6b7280; --dim:#f3f4f6; --br:#e5e7eb;
|
||||
--bg:#fff; --fg:#111; --mut:#6b7280; --dim:#f3f4f6; --br:#e5e7eb; --active: #60a5fa2b;
|
||||
}
|
||||
@media (prefers-color-scheme: dark){
|
||||
:root { --bg:#0b1220; --fg:#e5e7eb; --mut:#9ca3af; --dim:#111827; --br:#374151; }
|
||||
@@ -268,3 +268,30 @@ select#sub {
|
||||
position: relative;
|
||||
z-index: 6;
|
||||
}
|
||||
|
||||
div.card.active {
|
||||
background-color: var(--active)!important;
|
||||
}
|
||||
|
||||
.tone-row {
|
||||
grid-column: 1 / -1;
|
||||
background-color: var(--active);
|
||||
border: 1px solid var(--c-border, rgba(0, 0, 0, .12));
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0px, 1fr));
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tone-option {
|
||||
height: 56px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--c-border, rgba(0, 0, 0, .12));
|
||||
background: var(--c-bg, #f3f4f6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: x-large;
|
||||
}
|
||||
Reference in New Issue
Block a user