您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Animate emoji on the web using the noto animated emoji from Google.
当前为
// ==UserScript== // @name Animate Emoji on the web --Q // @namespace Violentmonkey Scripts // @version 2025-08-22_03-08 // @description Animate emoji on the web using the noto animated emoji from Google. // @author Quarrel // @homepage https://github.com/quarrel/animate-web-emoji // @match *://*/* // @run-at document-start // @icon https://www.google.com/s2/favicons?sz=64&domain=emojicopy.com // @noframes // @require https://cdn.jsdelivr.net/gh/quarrel/dotlottie-web-standalone@2133618935be739f13dd3b5b8d9a35d9ea47f407/build/dotlottie-web-iife.js // @grant GM.xmlhttpRequest // @grant GM.setValue // @grant GM.getValue // @grant GM.addStyle // @license MIT // ==/UserScript== 'use strict'; const config = { DEBUG_MODE: false, WASM_PLAYER_URL: 'https://cdn.jsdelivr.net/npm/@lottiefiles/[email protected]/dist/dotlottie-player.wasm', EMOJI_DATA_URL: 'https://googlefonts.github.io/noto-emoji-animation/data/api.json', LOTTIE_URL_PATTERN: 'https://fonts.gstatic.com/s/e/notoemoji/latest/{codepoint}/lottie.json', UNIQUE_EMOJI_CLASS: 'animated-emoji-q', EMOJI_DATA_CACHE_KEY: 'animated-emoji-q-noto-emoji-data-cache', LOTTIE_CACHE_KEY: 'animated-emoji-q-lottie', CACHE_EXPIRATION_MS: 14 * 24 * 60 * 60 * 1000, // 14 days DEBOUNCE_DELAY_MS: 10, DEBOUNCE_THRESHOLD: 25, MAX_CONCURRENT_REQUESTS: 8, SCALE_FACTOR: 1.1, WASM_CACHE_TTL: 24 * 60 * 60 * 1000, // 1 day }; (async () => { const scriptStartTime = Date.now(); const emojiRegex = /\p{RGI_Emoji}/gv; let requestQueue = []; let activeRequests = 0; let emojiDataPromise = null; let pendingLottieRequests = {}; const emojiToCodepoint = new Map(); GM.addStyle(` span.${config.UNIQUE_EMOJI_CLASS} { display: inline-flex; align-items: center; justify-content: center; vertical-align: middle; line-height: 1; overflow: hidden; } span.${config.UNIQUE_EMOJI_CLASS} > canvas { /* Sizing is now set by JS */ object-fit: contain; image-rendering: crisp-edges; } `); function base64ToBytes(base64) { const binString = atob(base64); return Uint8Array.from(binString, (char) => char.charCodeAt(0)); } function arrayBufferToBase64(buffer) { const bytes = new Uint8Array(buffer); const CHUNK_SIZE = 1024; let binary = ''; for (let i = 0; i < bytes.length; i += CHUNK_SIZE) { const chunk = bytes.subarray(i, i + CHUNK_SIZE); binary += String.fromCharCode.apply(null, chunk); } return btoa(binary); } const loadWasm = (url) => { return new Promise(async (resolve, reject) => { const CACHE_KEY = `wasm_cache_${url}`; const NOW = Date.now(); const cached = await GM.getValue(CACHE_KEY, null); if ( cached && cached.code && cached.timestamp > NOW - config.WASM_CACHE_TTL ) { if (config.DEBUG_MODE) { console.log('Loading WASM from cache:', url); } try { const bytes = base64ToBytes(cached.code); return resolve(bytes.buffer); // resolve as ArrayBuffer } catch (e) { console.warn('Cache decode failed, re-fetching:', e); } } // Cache miss — fetch fresh if (config.DEBUG_MODE) { console.log('Fetching WASM:', url); } GM.xmlhttpRequest({ method: 'GET', url, responseType: 'arraybuffer', onload: async (res) => { if (res.status !== 200 || !res.response) { return reject(new Error(`HTTP ${res.status}`)); } const arrayBuffer = res.response; try { const base64 = arrayBufferToBase64(arrayBuffer); await GM.setValue(CACHE_KEY, { code: base64, timestamp: NOW, }); } catch (e) { console.warn('Failed to cache WASM:', e); // Continue anyway } resolve(arrayBuffer); }, onerror: (err) => { console.error('GM.xmlhttpRequest failed:', err); reject(err); }, }); }); }; function patchFetchPlayer(bin) { const origFetch = window.fetch; window.fetch = new Proxy(origFetch, { apply(target, thisArg, args) { const resource = args[0]; const url = typeof resource === 'string' ? resource : resource.url; if (url.endsWith('dotlottie-player.wasm')) { return Promise.resolve( new Response(bin, { status: 200, headers: { 'Content-Type': 'application/wasm' }, }) ); } return Reflect.apply(target, thisArg, args); }, }); } const getEmojiData = () => { return new Promise(async (resolve, reject) => { const cachedData = await GM.getValue( config.EMOJI_DATA_CACHE_KEY, null ); if ( cachedData && cachedData.timestamp > Date.now() - config.CACHE_EXPIRATION_MS ) { resolve(cachedData.data); return; } GM.xmlhttpRequest({ method: 'GET', url: config.EMOJI_DATA_URL, responseType: 'json', onload: (response) => { if (response.status === 200) { const dataToCache = { data: response.response, timestamp: Date.now(), }; GM.setValue(config.EMOJI_DATA_CACHE_KEY, dataToCache); resolve(response.response); } else { reject('Failed to load emoji data'); } }, onerror: reject, }); }); }; function processRequestQueue() { if ( requestQueue.length === 0 || activeRequests >= config.MAX_CONCURRENT_REQUESTS ) { return; } activeRequests++; const { codepoint, resolve, reject } = requestQueue.shift(); GM.xmlhttpRequest({ method: 'GET', url: config.LOTTIE_URL_PATTERN.replace('{codepoint}', codepoint), responseType: 'json', onload: async (response) => { if (response.status === 200) { const data = response.response; const uniqueCacheKey = `${config.LOTTIE_CACHE_KEY}_${codepoint}`; const dataToCache = { data, timestamp: Date.now(), }; await GM.setValue(uniqueCacheKey, dataToCache); resolve(data); } else { reject('Failed to load Lottie animation: ' + codepoint); } }, onerror: reject, onloadend: () => { activeRequests--; processRequestQueue(); }, }); } const getLottieAnimationData = async (codepoint) => { // if we've got the promise, it is either resolved or we need to wait on it - serves a runtime cache to avoid hitting GM.getValue too if (pendingLottieRequests[codepoint]) { return pendingLottieRequests[codepoint]; } const uniqueCacheKey = `${config.LOTTIE_CACHE_KEY}_${codepoint}`; const cached = await GM.getValue(uniqueCacheKey, null); if ( cached && cached.timestamp > Date.now() - config.CACHE_EXPIRATION_MS ) { if (config.DEBUG_MODE) { //console.log(`Lottie cache hit for ${codepoint}`); } return cached.data; } if (config.DEBUG_MODE) { //console.log(`Lottie cache miss for ${codepoint}, fetching...`); } const promise = new Promise((resolve, reject) => { requestQueue.push({ codepoint, resolve, reject }); processRequestQueue(); }); pendingLottieRequests[codepoint] = promise; return promise; }; const allDotLotties = new Set(); const renderCfg = { devicePixelRatio: 1.5, // dottie can't be trusted, at least if you have changes in DPI during the page freezeOnOffscreen: true, autoResize: false, }; const layoutCfg = { //fit: 'fill', align: [0.5, 0.5], }; const sharedIO = new IntersectionObserver( async (entries) => { for (const entry of entries) { const span = entry.target; if (entry.isIntersecting) { let player = span.dotLottiePlayer; if (!player) { getLottieAnimationData(span.dataset.codepoint) .then((animationData) => { const canvas = document.createElement('canvas'); // Set bitmap size canvas.width = Math.round(span.finalSize * 0.9); // widths are mostly 90% of height, but feels weird to use it .. ??? canvas.height = Math.round(span.finalSize); // Set CSS size canvas.style.width = `${Math.round( span.finalSize * 0.9 )}px`; canvas.style.height = `${Math.round( span.finalSize )}px`; /* console.log( span.dataset.emoji + ': ' + 'width = ' + canvas.width + ', height = ' + canvas.height + ', finalSize = ' + span.finalSize ); */ // Clear the text placeholder before adding the canvas span.textContent = ''; span.appendChild(canvas); player = new DotLottie({ canvas, data: animationData, loop: true, autoplay: true, renderConfig: renderCfg, layout: layoutCfg, }); span.dotLottiePlayer = player; allDotLotties.add(player); }) .catch((err) => { if (config.DEBUG_MODE) { console.error( 'Failed to load emoji animation, leaving as text.', err ); } sharedIO.unobserve(span); }); } if (player) player.play(); } else { if (span.dotLottiePlayer) { span.dotLottiePlayer.pause(); } } } }, { rootMargin: '100px' } ); // Pause/play all animations when tab visibility changes document.addEventListener('visibilitychange', () => { if (document.hidden) { allDotLotties.forEach((p) => p.pause()); } else { allDotLotties.forEach((p) => p.play()); } }); // Shouldn't be needed? /* document.addEventListener('pagehide', () => { allDotLotties.forEach((p) => p.pause()); }); */ function createLazyEmojiSpan(emoji, referenceNode) { const span = document.createElement('span'); span.className = config.UNIQUE_EMOJI_CLASS; span.dataset.emoji = emoji; span.dataset.codepoint = emojiToCodepoint.get(emoji); span.title = `${emoji} (emoji u${emoji.codePointAt(0).toString(16)})`; let finalSize; if (referenceNode && referenceNode.parentNode) { const parentStyle = getComputedStyle(referenceNode.parentNode); const fontSizePx = parseFloat(parentStyle.fontSize); let blockSizePx = parseFloat(parentStyle.blockSize); if (isNaN(blockSizePx)) { blockSizePx = fontSizePx; } // If blockSize is significantly larger than fontSize, it's likely due to // line-height or padding. In such cases, fontSize is a more reliable measure. if (blockSizePx > fontSizePx * 1.2) { finalSize = Math.round(fontSizePx * config.SCALE_FACTOR); } else { finalSize = Math.round(blockSizePx); } } else { finalSize = 16; // Fallback size } span.finalSize = finalSize; span.textContent = emoji; sharedIO.observe(span); return span; } async function replaceEmojiInTextNode(node) { const SKIP = new Set([ 'SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'CODE', 'PRE', 'SVG', 'CANVAS', ]); const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, { acceptNode(textNode) { const parent = textNode.parentNode; if (!parent) return NodeFilter.FILTER_REJECT; if (SKIP.has(parent.nodeName)) { return NodeFilter.FILTER_REJECT; } if ( parent.closest( '[contenteditable=""]', '[contenteditable="true"]' ) ) { return NodeFilter.FILTER_REJECT; } if (parent.closest('.' + config.UNIQUE_EMOJI_CLASS)) { return NodeFilter.FILTER_REJECT; } return NodeFilter.FILTER_ACCEPT; }, }); const replacements = []; //console.log('Current node: ' + walker.currentNode.nodeValue); while (walker.nextNode()) { const textNode = walker.currentNode; const text = textNode.nodeValue; if (!text) continue; const matches = [...text.matchAll(emojiRegex)]; // we know we have emojis, now we need to make sure we have enough data loaded to create the span if (emojiDataPromise) { await emojiDataPromise; emojiDataPromise = null; } const emojisToProcess = matches .map((match) => { const emojiStr = match[0]; const codepoint = emojiToCodepoint.get(emojiStr); if (codepoint) { if (config.DEBUG_MODE) { console.log(emojiStr, codepoint); } } return codepoint ? { match, codepoint } : null; }) .filter(Boolean); if (emojisToProcess.length === 0) continue; // do we want to try and set to pre-fetching all the emoji? can cut this to do it only on demand as they appear in the visibility observer const promises = emojisToProcess.map((emoji) => getLottieAnimationData(emoji.codepoint).catch(() => { // This is for pre-fetching, errors will be handled by the IntersectionObserver }) ); const frag = document.createDocumentFragment(); let lastIndex = 0; emojisToProcess.forEach((emoji, i) => { const { match } = emoji; if (match.index > lastIndex) { frag.appendChild( document.createTextNode( text.slice(lastIndex, match.index) ) ); } frag.appendChild(createLazyEmojiSpan(match[0], textNode)); lastIndex = match.index + match[0].length; }); if (lastIndex < text.length) { frag.appendChild( document.createTextNode(text.slice(lastIndex)) ); } replacements.push({ textNode, frag }); } for (const { textNode, frag } of replacements) { const parent = textNode.parentNode; if (!parent) { if (config.DEBUG_MODE) { console.error( 'No parent node for text node, I do not think this should happen. Node: ' + textNode.nodeValue ); } continue; } // move a single new span, in a span, up a level, with the correct styling. if ( parent.tagName === 'SPAN' && parent.childNodes.length === 1 && frag.childElementCount === 1 ) { const newEmojiEl = frag.firstChild; // Preserve original attributes (like title, aria-label) for (const attr of Array.from(parent.attributes)) { if (!newEmojiEl.hasAttribute(attr.name)) { newEmojiEl.setAttribute(attr.name, attr.value); } } // Swap parent span with our emoji span parent.replaceWith(newEmojiEl); } else { textNode.parentNode.replaceChild(frag, textNode); } } } const processAddedNode = async (node) => { if (!document.body || !document.body.contains(node)) return; replaceEmojiInTextNode(node); }; let observerCount = 0; let debouncedNodes = new Set(); let debouncedTimeout = null; function processDebouncedNodes() { if (debouncedNodes.size === 0) { debouncedTimeout = null; return; } const node = debouncedNodes.values().next().value; debouncedNodes.delete(node); processAddedNode(node); // Re-schedule the processing for the next node in the queue - timeslice it debouncedTimeout = setTimeout(processDebouncedNodes, 0); } const observer = new MutationObserver((mutationsList) => { observerCount++; const newNodes = new Set(); for (const mutation of mutationsList) { if ( mutation.type === 'childList' && mutation.addedNodes.length > 0 ) { mutation.addedNodes.forEach((node) => newNodes.add(node)); } else if ( ['characterData', 'attributes'].includes(mutation.type) ) { newNodes.add(mutation.target); } // Handle removed nodes if ( mutation.type === 'childList' && mutation.removedNodes.length > 0 ) { mutation.removedNodes.forEach((node) => { if ( node.nodeType === Node.ELEMENT_NODE && node.classList.contains(config.UNIQUE_EMOJI_CLASS) ) { if (node.dotLottiePlayer) { node.dotLottiePlayer.destroy(); allDotLotties.delete(node.dotLottiePlayer); delete node.dotLottiePlayer; } } }); } } if (observerCount <= config.DEBOUNCE_THRESHOLD) { newNodes.forEach(processAddedNode); return; } newNodes.forEach((node) => { if (node.nodeType !== Node.ELEMENT_NODE) { debouncedNodes.add(node); return; } for (const existing of debouncedNodes) { if ( existing.nodeType === Node.ELEMENT_NODE && existing.contains(node) ) return; } for (const existing of [...debouncedNodes]) { if ( existing.nodeType === Node.ELEMENT_NODE && node.contains(existing) ) { debouncedNodes.delete(existing); } } debouncedNodes.add(node); }); if (debouncedTimeout) return; debouncedTimeout = setTimeout( processDebouncedNodes, config.DEBOUNCE_DELAY_MS ); }); const initializeEmojiData = async () => { const emojiData = await getEmojiData(); for (const icon of emojiData.icons) { const chars = icon.codepoint .split('_') .map((hex) => String.fromCodePoint(parseInt(hex, 16))) .join(''); emojiToCodepoint.set(chars, icon.codepoint); } if (config.DEBUG_MODE) { console.log( 'Emoji cache loaded ' + (Date.now() - scriptStartTime) + 'ms' ); } }; const startObserver = () => { observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true, attributes: false, }); if (document.body) { processAddedNode(document.body); } }; const main = async () => { try { if (config.DEBUG_MODE) { console.log( 'Script startup time: ' + (Date.now() - scriptStartTime) + 'ms' ); } // Defer heavy initialization to avoid blocking page load. setTimeout(() => { loadWasm(config.WASM_PLAYER_URL).then((bin) => { patchFetchPlayer(bin); if (config.DEBUG_MODE) console.log( 'Player wasm patched after ' + (Date.now() - scriptStartTime) + 'ms' ); }); // Lottie cache initialization is removed. emojiDataPromise = initializeEmojiData(); startObserver(); }, 0); } catch (error) { if (config.DEBUG_MODE) { console.error( 'Failed to initialize emoji animation script:', error ); } } }; main(); })();