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-26_02-40
// @description Animate emoji on the web using the noto animated emoji from Google.
// @author Quarrel
// @homepage https://github.com/quarrel/animate-web-emoji
// @match *://*/*
// @exclude https://news.ycombinator.com/*
// @run-at document-start
// @icon https://www.google.com/s2/favicons?sz=64&domain=emojicopy.com
// @noframes
// @grant GM.xmlhttpRequest
// @grant GM.setValue
// @grant GM.getValue
// @grant GM.addStyle
// @grant GM.addElement
// @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',
DOTLOTTIE_PLAYER_URL:
'https://cdn.jsdelivr.net/gh/quarrel/dotlottie-web-standalone@2133618935be739f13dd3b5b8d9a35d9ea47f407/build/dotlottie-web-iife.js',
LOTTIE_BACKUP_PUREJS_PLAYER_URL:
'https://cdnjs.cloudflare.com/ajax/libs/lottie-web/5.13.0/lottie_canvas.min.js',
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 WA_ALLOWED = true;
let requestQueue = [];
let activeRequests = 0;
let emojiDataPromise = null;
let pendingLottieRequests = {};
const emojiToCodepoint = new Map();
try {
// A no-op WASM module - we need to understand if we're allowed to load WAsm modules early.
const module = new WebAssembly.Module(
Uint8Array.of(0x0, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00)
);
new WebAssembly.Instance(module);
} catch (e) {
if (e.message.includes('Content Security Policy')) {
if (config.DEBUG_MODE) {
console.warn(
'🇦🇺: ',
'Script using old pure JS animations on this page due to Content Security Policy.'
);
}
GM.addElement('script', {
src: config.LOTTIE_BACKUP_PUREJS_PLAYER_URL,
type: 'text/javascript',
});
// To stop myself from accidentally having both paths active
delete window.DotLottie;
delete window.DotLottieWorker;
WA_ALLOWED = false;
}
}
if (WA_ALLOWED) {
GM.addElement('script', {
src: config.DOTLOTTIE_PLAYER_URL,
type: 'text/javascript',
});
}
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 {
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);
} catch (e) {
console.warn(
'🇦🇺: ',
'Cache decode failed, re-fetching:',
e
);
}
}
if (config.DEBUG_MODE) {
console.log('🇦🇺: ', 'Fetching remote 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);
}
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')) {
if (config.DEBUG_MODE)
console.log('🇦🇺: ', 'patched fetch being resolved');
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 = JSON.parse(
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,
JSON.stringify(dataToCache)
);
resolve(response.response);
} else {
reject('Failed to load emoji data');
}
},
onerror: reject,
});
});
};
function processAnimationRequestQueue() {
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,
JSON.stringify(dataToCache)
);
resolve(data);
} else {
reject('Failed to load Lottie animation: ' + codepoint);
}
},
onerror: reject,
onloadend: () => {
activeRequests--;
processAnimationRequestQueue();
},
});
}
function tryParseJSONObject(jsonString) {
try {
var o = JSON.parse(jsonString);
if (o && typeof o === 'object') {
return o;
}
} catch (e) {}
return false;
}
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 = JSON.parse(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}`);
}
if (config.DEBUG_MODE) {
if (!tryParseJSONObject(JSON.stringify(cached.data))) {
console.log(
'bad json for ' +
codepoint +
' type = ' +
typeof cached.data +
' length of json text: ' +
JSON.stringify(cached.data).length
);
}
}
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 });
processAnimationRequestQueue();
});
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);
if (WA_ALLOWED) {
player = new DotLottie({
canvas,
data: animationData,
loop: true,
autoplay: true,
renderConfig: renderCfg,
layout: layoutCfg,
});
} else {
player = lottie.loadAnimation({
renderer: 'canvas',
loop: true,
autoplay: true,
progressiveLoad: false,
animationData: animationData,
rendererSettings: {
context: canvas.getContext('2d'), // Use the 2D context
//dpr: 1.5, // Match devicePixelRatio - lottie doesn't seem to handle it
preserveAspectRatio:
'xMidYMid meet', // Match layout alignment
clearCanvas: true,
hideOnTransparent: true,
},
});
}
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 = [];
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) => {
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.childNodes.length === 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 (WA_ALLOWED) {
loadWasm(config.WASM_PLAYER_URL)
.then((bin) => {
patchFetchPlayer(bin);
if (config.DEBUG_MODE)
console.log(
'🇦🇺: ',
'Player wasm patched after ' +
(Date.now() - scriptStartTime) +
'ms'
);
})
.catch((err) => {
if (config.DEBUG_MODE) {
console.error('🇦🇺: ', 'Failed to load wasm', err);
}
});
}
emojiDataPromise = initializeEmojiData();
if (config.DEBUG_MODE) {
console.log(
'🇦🇺: ',
'Script startup time: ' +
(Date.now() - scriptStartTime) +
'ms'
);
}
startObserver();
} catch (error) {
if (config.DEBUG_MODE) {
console.error(
'🇦🇺: ',
'Failed to initialize emoji animation script:',
error
);
}
}
};
main();
})();