commit a21f9927e4cdbf12f73ae31e89e56023b0370eda Author: dwindown Date: Fri Aug 29 23:36:07 2025 +0700 first commit diff --git a/background.js b/background.js new file mode 100644 index 0000000..6c860f8 --- /dev/null +++ b/background.js @@ -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 }); +}); \ No newline at end of file diff --git a/content.js b/content.js new file mode 100644 index 0000000..c42890b --- /dev/null +++ b/content.js @@ -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 + } + }); +} \ No newline at end of file diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..6390921 --- /dev/null +++ b/manifest.json @@ -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": [""], + + "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": [""], + "js": ["content.js"], + "run_at": "document_idle", + "all_frames": false + } + ] + } \ No newline at end of file diff --git a/panel.html b/panel.html new file mode 100644 index 0000000..b53fb81 --- /dev/null +++ b/panel.html @@ -0,0 +1,113 @@ + + + + + Emoji Widget + + + + + +
+

Find Your Emoji Free

+
+ + + + +
+
+ +
+ + +
+ +
+ +
+ + + +
+ + + + + +
+ + + \ No newline at end of file diff --git a/panel.js b/panel.js new file mode 100644 index 0000000..8163daa --- /dev/null +++ b/panel.js @@ -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 = ``; + 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 = ``; + subSel.disabled = true; +} + +function populateSubcategorySelect(category) { + const subSel = document.getElementById('sub'); + if (!subSel) return; + subSel.innerHTML = ``; + 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 = '
'; + // 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 = ``; + clearBtn.title = 'Clear'; + clearBtn.setAttribute('aria-label', 'Clear'); + clearBtn.classList.add('nm'); + clearBtn.dataset.mode = 'clear'; + } else { + clearBtn.innerHTML = ``; // 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: , label: } + + // 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;iPreferred skin tone + +
+
+ `; + 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 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(); +}; \ No newline at end of file diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..ab958d1 --- /dev/null +++ b/styles.css @@ -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; } \ No newline at end of file diff --git a/vendor/twemoji.min.js b/vendor/twemoji.min.js new file mode 100644 index 0000000..a37dee5 --- /dev/null +++ b/vendor/twemoji.min.js @@ -0,0 +1,2 @@ +/*! Copyright Twitter Inc. and other contributors. Licensed under MIT */ +var twemoji=function(){"use strict";var twemoji={base:"https://twemoji.maxcdn.com/v/14.0.2/",ext:".png",size:"72x72",className:"emoji",convert:{fromCodePoint:fromCodePoint,toCodePoint:toCodePoint},onerror:function onerror(){if(this.parentNode){this.parentNode.replaceChild(createText(this.alt,false),this)}},parse:parse,replace:replace,test:test},escaper={"&":"&","<":"<",">":">","'":"'",'"':"""},re=/(?:\ud83d\udc68\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffc-\udfff]|\ud83e\uddd1\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb\udffd-\udfff]|\ud83e\uddd1\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb\udffc\udffe\udfff]|\ud83e\uddd1\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb-\udffd\udfff]|\ud83e\uddd1\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83e\uddd1\ud83c[\udffb-\udffe]|\ud83d\udc68\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffc-\udfff]|\ud83d\udc68\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffd-\udfff]|\ud83d\udc68\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffc\udffe\udfff]|\ud83d\udc68\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffd\udfff]|\ud83d\udc68\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc68\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffe]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffc-\udfff]|\ud83d\udc69\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffc-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffd-\udfff]|\ud83d\udc69\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb\udffd-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb\udffc\udffe\udfff]|\ud83d\udc69\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb\udffc\udffe\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffd\udfff]|\ud83d\udc69\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb-\udffd\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc68\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83d\udc69\ud83c[\udffb-\udfff]|\ud83d\udc69\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc68\ud83c[\udffb-\udffe]|\ud83d\udc69\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83d\udc69\ud83c[\udffb-\udffe]|\ud83e\uddd1\ud83c\udffb\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffc-\udfff]|\ud83e\uddd1\ud83c\udffb\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffc\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb\udffd-\udfff]|\ud83e\uddd1\ud83c\udffc\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffd\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb\udffc\udffe\udfff]|\ud83e\uddd1\ud83c\udffd\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udffe\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb-\udffd\udfff]|\ud83e\uddd1\ud83c\udffe\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83e\uddd1\ud83c\udfff\u200d\u2764\ufe0f\u200d\ud83e\uddd1\ud83c[\udffb-\udffe]|\ud83e\uddd1\ud83c\udfff\u200d\ud83e\udd1d\u200d\ud83e\uddd1\ud83c[\udffb-\udfff]|\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc68|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d[\udc68\udc69]|\ud83e\udef1\ud83c\udffb\u200d\ud83e\udef2\ud83c[\udffc-\udfff]|\ud83e\udef1\ud83c\udffc\u200d\ud83e\udef2\ud83c[\udffb\udffd-\udfff]|\ud83e\udef1\ud83c\udffd\u200d\ud83e\udef2\ud83c[\udffb\udffc\udffe\udfff]|\ud83e\udef1\ud83c\udffe\u200d\ud83e\udef2\ud83c[\udffb-\udffd\udfff]|\ud83e\udef1\ud83c\udfff\u200d\ud83e\udef2\ud83c[\udffb-\udffe]|\ud83d\udc68\u200d\u2764\ufe0f\u200d\ud83d\udc68|\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d[\udc68\udc69]|\ud83e\uddd1\u200d\ud83e\udd1d\u200d\ud83e\uddd1|\ud83d\udc6b\ud83c[\udffb-\udfff]|\ud83d\udc6c\ud83c[\udffb-\udfff]|\ud83d\udc6d\ud83c[\udffb-\udfff]|\ud83d\udc8f\ud83c[\udffb-\udfff]|\ud83d\udc91\ud83c[\udffb-\udfff]|\ud83e\udd1d\ud83c[\udffb-\udfff]|\ud83d[\udc6b-\udc6d\udc8f\udc91]|\ud83e\udd1d)|(?:\ud83d[\udc68\udc69]|\ud83e\uddd1)(?:\ud83c[\udffb-\udfff])?\u200d(?:\u2695\ufe0f|\u2696\ufe0f|\u2708\ufe0f|\ud83c[\udf3e\udf73\udf7c\udf84\udf93\udfa4\udfa8\udfeb\udfed]|\ud83d[\udcbb\udcbc\udd27\udd2c\ude80\ude92]|\ud83e[\uddaf-\uddb3\uddbc\uddbd])|(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75]|\u26f9)((?:\ud83c[\udffb-\udfff]|\ufe0f)\u200d[\u2640\u2642]\ufe0f)|(?:\ud83c[\udfc3\udfc4\udfca]|\ud83d[\udc6e\udc70\udc71\udc73\udc77\udc81\udc82\udc86\udc87\ude45-\ude47\ude4b\ude4d\ude4e\udea3\udeb4-\udeb6]|\ud83e[\udd26\udd35\udd37-\udd39\udd3d\udd3e\uddb8\uddb9\uddcd-\uddcf\uddd4\uddd6-\udddd])(?:\ud83c[\udffb-\udfff])?\u200d[\u2640\u2642]\ufe0f|(?:\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc68\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc68\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d\udc66\u200d\ud83d\udc66|\ud83d\udc69\u200d\ud83d\udc67\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83c\udff3\ufe0f\u200d\u26a7\ufe0f|\ud83c\udff3\ufe0f\u200d\ud83c\udf08|\ud83d\ude36\u200d\ud83c\udf2b\ufe0f|\u2764\ufe0f\u200d\ud83d\udd25|\u2764\ufe0f\u200d\ud83e\ude79|\ud83c\udff4\u200d\u2620\ufe0f|\ud83d\udc15\u200d\ud83e\uddba|\ud83d\udc3b\u200d\u2744\ufe0f|\ud83d\udc41\u200d\ud83d\udde8|\ud83d\udc68\u200d\ud83d[\udc66\udc67]|\ud83d\udc69\u200d\ud83d[\udc66\udc67]|\ud83d\udc6f\u200d\u2640\ufe0f|\ud83d\udc6f\u200d\u2642\ufe0f|\ud83d\ude2e\u200d\ud83d\udca8|\ud83d\ude35\u200d\ud83d\udcab|\ud83e\udd3c\u200d\u2640\ufe0f|\ud83e\udd3c\u200d\u2642\ufe0f|\ud83e\uddde\u200d\u2640\ufe0f|\ud83e\uddde\u200d\u2642\ufe0f|\ud83e\udddf\u200d\u2640\ufe0f|\ud83e\udddf\u200d\u2642\ufe0f|\ud83d\udc08\u200d\u2b1b)|[#*0-9]\ufe0f?\u20e3|(?:[ยฉยฎ\u2122\u265f]\ufe0f)|(?:\ud83c[\udc04\udd70\udd71\udd7e\udd7f\ude02\ude1a\ude2f\ude37\udf21\udf24-\udf2c\udf36\udf7d\udf96\udf97\udf99-\udf9b\udf9e\udf9f\udfcd\udfce\udfd4-\udfdf\udff3\udff5\udff7]|\ud83d[\udc3f\udc41\udcfd\udd49\udd4a\udd6f\udd70\udd73\udd76-\udd79\udd87\udd8a-\udd8d\udda5\udda8\uddb1\uddb2\uddbc\uddc2-\uddc4\uddd1-\uddd3\udddc-\uddde\udde1\udde3\udde8\uddef\uddf3\uddfa\udecb\udecd-\udecf\udee0-\udee5\udee9\udef0\udef3]|[\u203c\u2049\u2139\u2194-\u2199\u21a9\u21aa\u231a\u231b\u2328\u23cf\u23ed-\u23ef\u23f1\u23f2\u23f8-\u23fa\u24c2\u25aa\u25ab\u25b6\u25c0\u25fb-\u25fe\u2600-\u2604\u260e\u2611\u2614\u2615\u2618\u2620\u2622\u2623\u2626\u262a\u262e\u262f\u2638-\u263a\u2640\u2642\u2648-\u2653\u2660\u2663\u2665\u2666\u2668\u267b\u267f\u2692-\u2697\u2699\u269b\u269c\u26a0\u26a1\u26a7\u26aa\u26ab\u26b0\u26b1\u26bd\u26be\u26c4\u26c5\u26c8\u26cf\u26d1\u26d3\u26d4\u26e9\u26ea\u26f0-\u26f5\u26f8\u26fa\u26fd\u2702\u2708\u2709\u270f\u2712\u2714\u2716\u271d\u2721\u2733\u2734\u2744\u2747\u2757\u2763\u2764\u27a1\u2934\u2935\u2b05-\u2b07\u2b1b\u2b1c\u2b50\u2b55\u3030\u303d\u3297\u3299])(?:\ufe0f|(?!\ufe0e))|(?:(?:\ud83c[\udfcb\udfcc]|\ud83d[\udd74\udd75\udd90]|[\u261d\u26f7\u26f9\u270c\u270d])(?:\ufe0f|(?!\ufe0e))|(?:\ud83c[\udf85\udfc2-\udfc4\udfc7\udfca]|\ud83d[\udc42\udc43\udc46-\udc50\udc66-\udc69\udc6e\udc70-\udc78\udc7c\udc81-\udc83\udc85-\udc87\udcaa\udd7a\udd95\udd96\ude45-\ude47\ude4b-\ude4f\udea3\udeb4-\udeb6\udec0\udecc]|\ud83e[\udd0c\udd0f\udd18-\udd1c\udd1e\udd1f\udd26\udd30-\udd39\udd3d\udd3e\udd77\uddb5\uddb6\uddb8\uddb9\uddbb\uddcd-\uddcf\uddd1-\udddd\udec3-\udec5\udef0-\udef6]|[\u270a\u270b]))(?:\ud83c[\udffb-\udfff])?|(?:\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc65\udb40\udc6e\udb40\udc67\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc73\udb40\udc63\udb40\udc74\udb40\udc7f|\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc77\udb40\udc6c\udb40\udc73\udb40\udc7f|\ud83c\udde6\ud83c[\udde8-\uddec\uddee\uddf1\uddf2\uddf4\uddf6-\uddfa\uddfc\uddfd\uddff]|\ud83c\udde7\ud83c[\udde6\udde7\udde9-\uddef\uddf1-\uddf4\uddf6-\uddf9\uddfb\uddfc\uddfe\uddff]|\ud83c\udde8\ud83c[\udde6\udde8\udde9\uddeb-\uddee\uddf0-\uddf5\uddf7\uddfa-\uddff]|\ud83c\udde9\ud83c[\uddea\uddec\uddef\uddf0\uddf2\uddf4\uddff]|\ud83c\uddea\ud83c[\udde6\udde8\uddea\uddec\udded\uddf7-\uddfa]|\ud83c\uddeb\ud83c[\uddee-\uddf0\uddf2\uddf4\uddf7]|\ud83c\uddec\ud83c[\udde6\udde7\udde9-\uddee\uddf1-\uddf3\uddf5-\uddfa\uddfc\uddfe]|\ud83c\udded\ud83c[\uddf0\uddf2\uddf3\uddf7\uddf9\uddfa]|\ud83c\uddee\ud83c[\udde8-\uddea\uddf1-\uddf4\uddf6-\uddf9]|\ud83c\uddef\ud83c[\uddea\uddf2\uddf4\uddf5]|\ud83c\uddf0\ud83c[\uddea\uddec-\uddee\uddf2\uddf3\uddf5\uddf7\uddfc\uddfe\uddff]|\ud83c\uddf1\ud83c[\udde6-\udde8\uddee\uddf0\uddf7-\uddfb\uddfe]|\ud83c\uddf2\ud83c[\udde6\udde8-\udded\uddf0-\uddff]|\ud83c\uddf3\ud83c[\udde6\udde8\uddea-\uddec\uddee\uddf1\uddf4\uddf5\uddf7\uddfa\uddff]|\ud83c\uddf4\ud83c\uddf2|\ud83c\uddf5\ud83c[\udde6\uddea-\udded\uddf0-\uddf3\uddf7-\uddf9\uddfc\uddfe]|\ud83c\uddf6\ud83c\udde6|\ud83c\uddf7\ud83c[\uddea\uddf4\uddf8\uddfa\uddfc]|\ud83c\uddf8\ud83c[\udde6-\uddea\uddec-\uddf4\uddf7-\uddf9\uddfb\uddfd-\uddff]|\ud83c\uddf9\ud83c[\udde6\udde8\udde9\uddeb-\udded\uddef-\uddf4\uddf7\uddf9\uddfb\uddfc\uddff]|\ud83c\uddfa\ud83c[\udde6\uddec\uddf2\uddf3\uddf8\uddfe\uddff]|\ud83c\uddfb\ud83c[\udde6\udde8\uddea\uddec\uddee\uddf3\uddfa]|\ud83c\uddfc\ud83c[\uddeb\uddf8]|\ud83c\uddfd\ud83c\uddf0|\ud83c\uddfe\ud83c[\uddea\uddf9]|\ud83c\uddff\ud83c[\udde6\uddf2\uddfc]|\ud83c[\udccf\udd8e\udd91-\udd9a\udde6-\uddff\ude01\ude32-\ude36\ude38-\ude3a\ude50\ude51\udf00-\udf20\udf2d-\udf35\udf37-\udf7c\udf7e-\udf84\udf86-\udf93\udfa0-\udfc1\udfc5\udfc6\udfc8\udfc9\udfcf-\udfd3\udfe0-\udff0\udff4\udff8-\udfff]|\ud83d[\udc00-\udc3e\udc40\udc44\udc45\udc51-\udc65\udc6a\udc6f\udc79-\udc7b\udc7d-\udc80\udc84\udc88-\udc8e\udc90\udc92-\udca9\udcab-\udcfc\udcff-\udd3d\udd4b-\udd4e\udd50-\udd67\udda4\uddfb-\ude44\ude48-\ude4a\ude80-\udea2\udea4-\udeb3\udeb7-\udebf\udec1-\udec5\uded0-\uded2\uded5-\uded7\udedd-\udedf\udeeb\udeec\udef4-\udefc\udfe0-\udfeb\udff0]|\ud83e[\udd0d\udd0e\udd10-\udd17\udd20-\udd25\udd27-\udd2f\udd3a\udd3c\udd3f-\udd45\udd47-\udd76\udd78-\uddb4\uddb7\uddba\uddbc-\uddcc\uddd0\uddde-\uddff\ude70-\ude74\ude78-\ude7c\ude80-\ude86\ude90-\udeac\udeb0-\udeba\udec0-\udec2\uded0-\uded9\udee0-\udee7]|[\u23e9-\u23ec\u23f0\u23f3\u267e\u26ce\u2705\u2728\u274c\u274e\u2753-\u2755\u2795-\u2797\u27b0\u27bf\ue50a])|\ufe0f/g,UFE0Fg=/\uFE0F/g,U200D=String.fromCharCode(8205),rescaper=/[&<>'"]/g,shouldntBeParsed=/^(?:iframe|noframes|noscript|script|select|style|textarea)$/,fromCharCode=String.fromCharCode;return twemoji;function createText(text,clean){return document.createTextNode(clean?text.replace(UFE0Fg,""):text)}function escapeHTML(s){return s.replace(rescaper,replacer)}function defaultImageSrcGenerator(icon,options){return"".concat(options.base,options.size,"/",icon,options.ext)}function grabAllTextNodes(node,allText){var childNodes=node.childNodes,length=childNodes.length,subnode,nodeType;while(length--){subnode=childNodes[length];nodeType=subnode.nodeType;if(nodeType===3){allText.push(subnode)}else if(nodeType===1&&!("ownerSVGElement"in subnode)&&!shouldntBeParsed.test(subnode.nodeName.toLowerCase())){grabAllTextNodes(subnode,allText)}}return allText}function grabTheRightIcon(rawText){return toCodePoint(rawText.indexOf(U200D)<0?rawText.replace(UFE0Fg,""):rawText)}function parseNode(node,options){var allText=grabAllTextNodes(node,[]),length=allText.length,attrib,attrname,modified,fragment,subnode,text,match,i,index,img,rawText,iconId,src;while(length--){modified=false;fragment=document.createDocumentFragment();subnode=allText[length];text=subnode.nodeValue;i=0;while(match=re.exec(text)){index=match.index;if(index!==i){fragment.appendChild(createText(text.slice(i,index),true))}rawText=match[0];iconId=grabTheRightIcon(rawText);i=index+rawText.length;src=options.callback(iconId,options);if(iconId&&src){img=new Image;img.onerror=options.onerror;img.setAttribute("draggable","false");attrib=options.attributes(rawText,iconId);for(attrname in attrib){if(attrib.hasOwnProperty(attrname)&&attrname.indexOf("on")!==0&&!img.hasAttribute(attrname)){img.setAttribute(attrname,attrib[attrname])}}img.className=options.className;img.alt=rawText;img.src=src;modified=true;fragment.appendChild(img)}if(!img)fragment.appendChild(createText(rawText,false));img=null}if(modified){if(i")}return ret})}function replacer(m){return escaper[m]}function returnNull(){return null}function toSizeSquaredAsset(value){return typeof value==="number"?value+"x"+value:value}function fromCodePoint(codepoint){var code=typeof codepoint==="string"?parseInt(codepoint,16):codepoint;if(code<65536){return fromCharCode(code)}code-=65536;return fromCharCode(55296+(code>>10),56320+(code&1023))}function parse(what,how){if(!how||typeof how==="function"){how={callback:how}}return(typeof what==="string"?parseString:parseNode)(what,{callback:how.callback||defaultImageSrcGenerator,attributes:typeof how.attributes==="function"?how.attributes:returnNull,base:typeof how.base==="string"?how.base:twemoji.base,ext:how.ext||twemoji.ext,size:how.folder||toSizeSquaredAsset(how.size||twemoji.size),className:how.className||twemoji.className,onerror:how.onerror||twemoji.onerror})}function replace(text,callback){return String(text).replace(re,callback)}function test(text){re.lastIndex=0;var result=re.test(text);re.lastIndex=0;return result}function toCodePoint(unicodeSurrogates,sep){var r=[],c=0,p=0,i=0;while(i