YT 直播聊天室贴图复制工具

让 YouTube™ 直播聊天室的贴图可以被复制

// ==UserScript==
// @name                YT Live Chat Emoji Copy Tool
// @name:zh             YT 直播聊天室貼圖複製工具
// @name:zh-TW          YT 直播聊天室貼圖複製工具
// @name:zh-CN          YT 直播聊天室贴图复制工具
// @namespace           https://github.com/kevin823lin
// @version             0.3
// @description         Make YouTube™ Live Chat's emoji can be copied.
// @description:zh      讓 YouTube™ 直播聊天室的貼圖可以被複製
// @description:zh-TW   讓 YouTube™ 直播聊天室的貼圖可以被複製
// @description:zh-CN   让 YouTube™ 直播聊天室的贴图可以被复制
// @author              kevin823lin
// @match               https://www.youtube.com/live_chat*
// @match               https://www.youtube.com/live_chat_replay*
// @match               https://studio.youtube.com/live_chat*
// @match               https://studio.youtube.com/live_chat_replay*
// @icon                https://www.google.com/s2/favicons?domain=youtube.com
// @require             https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js
// @grant               none
// @date                2025-01-11
// @license             MIT
// ==/UserScript==

/*!
 * YT Live Chat Emoji Copy Tool v0.3
 * https://github.com/kevin823lin/yt-live-chat-emoji-copy-tool
 * 
 * Includes Lodash v4.17.21
 * 
 * Copyright (c) 2025 kevin823lin
 * Released under the MIT license
 * https://opensource.org/licenses/MIT
 * 
 * Date: 2025-01-11
 */

(function() {
    'use strict';

    // Your code here...
    const TYPES = {
        CHAT: Symbol("Chat"),
        INPUT: Symbol("Input"),
        PICKER: Symbol("Picker"),
    };

    const YOUTUBE_CHANNEL_ID_REGEX = /^UCkszU2WH9gy1mb0dV-11UJg\//;
    const CHAT_EMOJI_SELECTOR = 'img.emoji.yt-live-chat-text-message-renderer[shared-tooltip-text][data-emoji-id]:not(.copyable)';
    const INPUT_FIELD_SELECTOR = 'yt-live-chat-text-input-field-renderer > #input';
    const EMOJI_PICKER_SELECTOR = 'div.yt-emoji-picker-renderer#categories';
    const VERSION = (typeof GM_info !== 'undefined') ? GM_info?.script?.version : (typeof chrome !== 'undefined') ? chrome?.runtime?.getManifest()?.version : '';

    /**
     * Check if the emoji is already copyable.
     */
    function isEmojiCopyable(emoji) {
        return emoji.classList.contains('copyable');
    }

    /**
     * Retrieve the emoji's ID.
     */
    function getEmojiId(emoji) {
        return emoji.dataset.emojiId || emoji.id;
    }

    /**
     * Determine if the emoji is a YouTube-specific emoji.
     */
    function isYoutubeEmoji(emoji) {
        const id = getEmojiId(emoji);
        return !id || YOUTUBE_CHANNEL_ID_REGEX.test(id);
    }

    /**
     * Update the emoji's alt attribute with colon format.
     */
    function updateEmojiAltWithColon(emoji, compareText = null) {
        if (compareText && !compareText.match(emoji.alt)) return;
        emoji.alt = `:${isYoutubeEmoji(emoji) ? '' : '_'}${emoji.alt}:`;
    }

    /**
     * Process the emoji based on its type.
     */
    function processEmoji(emoji, type) {
        if (isEmojiCopyable(emoji)) return;

        emoji.classList.add('copyable');
        let compareText = null;

        switch (type) {
            case TYPES.CHAT:
                if (!document.contains(emoji)) return;
                compareText = emoji.getAttribute('shared-tooltip-text');
                break;
            case TYPES.INPUT:
                if (!getEmojiId(emoji)) return;
                break;
            case TYPES.PICKER:
                compareText = emoji.getAttribute('aria-label');
                break;
            default:
                console.warn(`Unknown emoji type: ${type}`);
                return;
        }

        updateEmojiAltWithColon(emoji, compareText);
    }

    /**
     * Update all emojis in the selected range.
     */
    function updateSelectedRangeEmojis() {
        try {
            const selection = window.getSelection();
            if (!selection.rangeCount) return;
            const range = selection.getRangeAt(0);
            const fragment = range.cloneContents();
            const selectedEmojis = fragment.querySelectorAll(CHAT_EMOJI_SELECTOR);
            selectedEmojis.forEach(clonedEmoji => {
                const originalEmoji = range.commonAncestorContainer.querySelector(`img.emoji#${clonedEmoji.id}`);
                if (originalEmoji) {
                    processEmoji(originalEmoji, TYPES.CHAT);
                }
            });
        } catch (error) {
            console.error(`Error in updateSelectedRangeEmojis: ${error}`);
        }
    }

    /**
     * Update all emojis inside the input field.
     */
    function updateInputFieldEmojis(inputField) {
        const inputEmojis = inputField?.getElementsByClassName('yt-live-chat-text-input-field-renderer') || [];
        Array.from(inputEmojis).forEach((node) => processEmoji(node, TYPES.INPUT));
    }

    /**
     * Update emojis in the emoji picker based on mutations.
     */
    function updateEmojiPickerEmojis(mutations) {
        mutations.forEach((mutation) => {
            mutation.addedNodes.forEach((node) => {
                if (node.nodeType === Node.ELEMENT_NODE && node.tagName === 'IMG') {
                    processEmoji(node, TYPES.PICKER);
                }
            });
        });
    }

    /**
     * Initialize observers and event listeners on page load.
     */
    function initializeObservers() {
        const inputField = document.querySelector(INPUT_FIELD_SELECTOR);
        const emojiPicker = document.querySelector(EMOJI_PICKER_SELECTOR);

        const selectionchangeCallback = _.debounce(updateSelectedRangeEmojis, 200);
        const observeInputFieldCallback = _.debounce(() => {
            if (inputField) updateInputFieldEmojis(inputField);
        }, 200);
        const observeEmojiPickerCallback = updateEmojiPickerEmojis;

        const inputFieldObserver = new MutationObserver(observeInputFieldCallback);
        const emojiPickerObserver = new MutationObserver(observeEmojiPickerCallback);

        if (inputField) inputFieldObserver.observe(inputField, { childList: true, subtree: true });
        if (emojiPicker) emojiPickerObserver.observe(emojiPicker, { childList: true, subtree: true });

        document.addEventListener('selectionchange', selectionchangeCallback);
    }

    if (/^\/live_chat/.test(window.location.pathname)) {
        initializeObservers();
        console.log(`[YT Live Chat Emoji Copy Tool${VERSION ? ` v${VERSION}` : ''}]`);
    }
})();