// ==UserScript== // @name Email Copy Button for All Sites // @name:zh-CN 全站邮箱一键复制 // @namespace http://tampermonkey.net/ // @version 2.8.1 // @description Changed store icon to an envelope (✉️) via inline SVG data URI. No functional changes. // @description:zh-CN 将脚本商店图标改为信封 ✉️(内嵌 SVG data URI),无功能改动。 // @author Nosy Swab (optimized, fixed) // @match *://*/* // @exclude https://apps.sfc.hk/* // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48dGV4dCB4PSI1MCIgeT0iNzgiIGZvbnQtc2l6ZT0iODAiIHRleHQtYW5jaG9yPSJtaWRkbGUiPuKcie+4jzwvdGV4dD48L3N2Zz4= // @icon64 data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMDAgMTAwIj48dGV4dCB4PSI1MCIgeT0iNzgiIGZvbnQtc2l6ZT0iODAiIHRleHQtYW5jaG9yPSJtaWRkbGUiPuKcie+4jzwvdGV4dD48L3N2Zz4= // @noframes // @run-at document-end // @grant none // @license MIT // ==/UserScript== (function () { 'use strict'; // Subdomain-safe guard (covers apps.sfc.hk and *.apps.sfc.hk) if (location.hostname === 'apps.sfc.hk' || location.hostname.endsWith('.apps.sfc.hk')) return; const EMAIL_RE = /[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/; const EMAIL_RE_G = /[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/g; const BTN_ATTR = 'data-ecb-btn'; const DONE_ATTR = 'data-ecb-done'; // 'BUTTON' is a second guard: prevents the TreeWalker from descending into // our own injected buttons (their aria-label contains the email and would // otherwise re-match in injectIntoAria). const SKIP_TAGS = new Set([ 'SCRIPT','STYLE','NOSCRIPT','CODE','PRE','SVG','CANVAS','BUTTON', 'HEAD','TEMPLATE','TEXTAREA' ]); // v2.7: strip leading <( and trailing .,;:'">[] so copies are clean function cleanEmail(raw) { return raw.replace(/^[<\s(]+/, '').replace(/[)\]}>;:'",.\s]+$/, '').trim(); } function isInEditableRegion(node) { let el = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement; while (el) { if (el.isContentEditable) return true; el = el.parentElement; } return false; } // v2.7: modern async API first, legacy execCommand fallback; never false-success function legacyCopy(text) { try { const ta = document.createElement('textarea'); ta.value = text; ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0;width:1px;height:1px'; document.body.appendChild(ta); ta.focus(); ta.select(); const ok = document.execCommand('copy'); document.body.removeChild(ta); return ok; } catch (e) { return false; } } function copyToClipboard(text) { if (navigator.clipboard && window.isSecureContext) { return navigator.clipboard.writeText(text) .then(function () { return true; }) .catch(function () { return legacyCopy(text); }); } return Promise.resolve(legacyCopy(text)); } // v2.7: single shared stylesheet (injected once) instead of per-button inline styles (function injectStyle() { const s = document.createElement('style'); s.textContent = '.ecb-btn{display:inline-flex;align-items:center;justify-content:center;margin-left:4px;' + 'padding:1px 5px;font-size:12px;line-height:1;border:1px solid #d9d9d9;border-radius:4px;' + 'background:#fff;color:inherit;cursor:pointer;vertical-align:middle;transition:background .15s;flex-shrink:0}' + '.ecb-btn:hover{background:#f0f0f0}' + '.ecb-btn.ok{background:#52c41a;border-color:#52c41a;color:#fff}'; (document.head || document.documentElement).appendChild(s); })(); function makeBtn(email) { const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'ecb-btn'; // v2.7 FIX: load-bearing marker. Without it the MutationObserver re-walks // injected buttons, and since aria-label holds the email, injectIntoAria // re-fires and appends buttons forever. Restored after the single- // stylesheet refactor dropped it. btn.setAttribute(BTN_ATTR, '1'); btn.textContent = '\uD83D\uDCCB'; btn.title = 'Copy ' + email; btn.setAttribute('aria-label', 'Copy ' + email); btn.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); copyToClipboard(email).then(function (ok) { btn.classList.add('ok'); btn.textContent = ok ? '\u2713' : '\u2716'; setTimeout(function () { btn.classList.remove('ok'); btn.textContent = '\uD83D\uDCCB'; }, 1500); }); }); return btn; } // v2.7: process ALL direct text children of the parent at once, so a parent // with emails split across multiple text nodes is fully handled (no skips) function injectIntoTextNode(node) { const text = node.nodeValue; if (!text || !EMAIL_RE.test(text)) return; const parent = node.parentNode; if (!parent || !parent.tagName) return; if (SKIP_TAGS.has(parent.tagName)) return; if (parent.getAttribute(BTN_ATTR)) return; if (parent.getAttribute(DONE_ATTR)) return; if (isInEditableRegion(parent)) return; const targets = []; for (let c = parent.firstChild; c; c = c.nextSibling) { if (c.nodeType === Node.TEXT_NODE && EMAIL_RE.test(c.nodeValue)) targets.push(c); } if (!targets.length) return; parent.setAttribute(DONE_ATTR, '1'); targets.forEach(function (tn) { const parts = tn.nodeValue.split(EMAIL_RE_G); const matches = tn.nodeValue.match(EMAIL_RE_G); const frag = document.createDocumentFragment(); parts.forEach(function (part, i) { frag.appendChild(document.createTextNode(part)); if (i < matches.length) { const email = cleanEmail(matches[i]); if (email) frag.appendChild(makeBtn(email)); } }); parent.replaceChild(frag, tn); }); } function injectIntoMailto(a) { if (a.getAttribute(DONE_ATTR)) return; if (isInEditableRegion(a)) return; const href = a.getAttribute('href') || ''; const match = href.match(/^mailto:([^?]+)/); if (!match) return; const email = cleanEmail(match[1]); if (!email) return; a.setAttribute(DONE_ATTR, '1'); const existingBtn = a.querySelector('button, [role="button"]'); if (existingBtn && !existingBtn.getAttribute(BTN_ATTR)) { existingBtn.addEventListener('click', function (e) { e.stopPropagation(); copyToClipboard(email); }); return; } a.insertAdjacentElement('afterend', makeBtn(email)); } function injectIntoInput(el) { if (el.getAttribute(DONE_ATTR)) return; if (el.type === 'hidden' || el.type === 'password') return; if (isInEditableRegion(el)) return; const val = el.value || ''; const m = val.match(EMAIL_RE); if (!m) return; const email = cleanEmail(m[0]); if (!email) return; el.setAttribute(DONE_ATTR, '1'); el.insertAdjacentElement('afterend', makeBtn(email)); } function injectIntoAria(el) { // Defense-in-depth: injectIntoAria is only called from walkNode() after // the TreeWalker has already filtered SKIP_TAGS, so this is redundant // today. Kept in case the call path changes and re-introduces the // BTN_ATTR-loss failure mode through this route. if (SKIP_TAGS.has(el.tagName)) return; if (el.getAttribute(DONE_ATTR)) return; if (isInEditableRegion(el)) return; if (el.closest && el.closest('a[' + DONE_ATTR + ']')) return; const label = el.getAttribute('aria-label') || el.getAttribute('aria-description') || ''; const m = label.match(EMAIL_RE); if (!m) return; const email = cleanEmail(m[0]); if (!email) return; el.setAttribute(DONE_ATTR, '1'); el.insertAdjacentElement('afterend', makeBtn(email)); } function walkNode(root) { if (isInEditableRegion(root)) return; let fields = []; if (root.nodeType === Node.ELEMENT_NODE) { if (root.tagName === 'INPUT' || root.tagName === 'TEXTAREA') fields.push(root); if (root.querySelectorAll) { fields = fields.concat(Array.prototype.slice.call(root.querySelectorAll('input, textarea'))); } } fields.forEach(injectIntoInput); const walker = document.createTreeWalker( root, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, { acceptNode: function (node) { if (node.nodeType === Node.ELEMENT_NODE) { if (SKIP_TAGS.has(node.tagName)) return NodeFilter.FILTER_REJECT; if (node.getAttribute(BTN_ATTR)) return NodeFilter.FILTER_REJECT; if (node.isContentEditable) return NodeFilter.FILTER_REJECT; if (node.tagName === 'A' && node.getAttribute('href') && node.getAttribute('href').indexOf('mailto:') === 0 && node.getAttribute(DONE_ATTR)) return NodeFilter.FILTER_REJECT; } return NodeFilter.FILTER_ACCEPT; } }, false ); const textNodes = []; let node; while ((node = walker.nextNode())) { if (node.nodeType === Node.TEXT_NODE) { textNodes.push(node); } else if (node.nodeType === Node.ELEMENT_NODE) { if (node.tagName === 'A') injectIntoMailto(node); injectIntoAria(node); } } textNodes.forEach(injectIntoTextNode); } walkNode(document.body); // v2.7 FIX: trailing debounce + accumulated roots. The earlier build did // `if (_timer) return`, which dropped every mutation batch arriving inside // the 200ms window. Here we push every batch's roots into a persistent Set // and reset the timer on each batch, so we drain ALL roots once, 200ms // after the last activity. // v2.8: removed the `_scanning` re-entrancy flag — drain() is synchronous // and MutationObserver callbacks are async, so re-entrancy is impossible. let pendingRoots = new Set(); let _timer = null; function drain() { const roots = pendingRoots; pendingRoots = new Set(); roots.forEach(function (root) { if (document.body && document.body.contains(root)) walkNode(root); }); } const observer = new MutationObserver(function (mutations) { mutations.forEach(function (m) { m.addedNodes.forEach(function (n) { if (n.nodeType === Node.ELEMENT_NODE || n.nodeType === Node.TEXT_NODE) { const root = n.nodeType === Node.ELEMENT_NODE ? n : n.parentElement; if (root && !root.getAttribute(BTN_ATTR) && !root.getAttribute(DONE_ATTR)) { pendingRoots.add(root); } } }); }); // reset timer on every batch -> trailing debounce clearTimeout(_timer); _timer = setTimeout(drain, 200); }); observer.observe(document.body, { childList: true, subtree: true }); })();