LMArena | Floating Copy Buttons

Adds floating copy buttons for code blocks (centered) and chat messages (beside/overlay)

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         LMArena | Floating Copy Buttons
// @namespace    https://greasyfork.org/en/users/1462137-piknockyou
// @version      1.3
// @author       Piknockyou (vibe-coded)
// @license      AGPL-3.0
// @description  Adds floating copy buttons for code blocks (centered) and chat messages (beside/overlay)
// @match        *://*lmarena.ai/*
// @icon         https://lmarena.ai/favicon.ico
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    // ═══════════════════════════════════════════════════════════════════════
    // CONFIGURATION
    // ═══════════════════════════════════════════════════════════════════════

    const CONFIG = {
        // Code block button (centered on code)
        codeBlock: {
            size: 60,
            padding: 6,
            borderRadius: 14,
            iconSize: 28,
            idle: {
                background: 'rgba(255,255,255,0.08)',
                border: 'rgba(255,255,255,0.15)',
                color: 'rgba(255,255,255,0.35)'
            },
            hover: {
                background: 'rgba(30,30,30,0.95)',
                border: 'rgba(255,255,255,0.4)',
                color: '#ffffff'
            }
        },

        // Message buttons (beside message, fallback to overlay)
        message: {
            size: 40,
            padding: 8,
            borderRadius: 10,
            iconSize: 18,
            offset: 10, // Gap between button and message edge when beside
            overlayPadding: 8, // Padding when overlaying on message
            ai: {
                side: 'right',
                idle: {
                    background: 'rgba(139,92,246,0.08)',
                    border: 'rgba(139,92,246,0.25)',
                    color: 'rgba(139,92,246,0.5)'
                },
                hover: {
                    background: 'rgba(109,72,206,0.95)',
                    border: 'rgba(167,139,250,0.6)',
                    color: '#ffffff'
                }
            },
            user: {
                side: 'left',
                idle: {
                    background: 'rgba(59,130,246,0.08)',
                    border: 'rgba(59,130,246,0.25)',
                    color: 'rgba(59,130,246,0.5)'
                },
                hover: {
                    background: 'rgba(37,99,235,0.95)',
                    border: 'rgba(96,165,250,0.6)',
                    color: '#ffffff'
                }
            }
        },

        // Shared copied state
        copied: {
            background: 'rgba(34,197,94,0.95)',
            border: '#22c55e',
            color: '#ffffff'
        },

        // Safe zone padding from header/footer
        safeZonePadding: 8,

        // Hold duration threshold for drag mode (milliseconds)
        holdThreshold: 300
    };

    // ═══════════════════════════════════════════════════════════════════════
    // STATE
    // ═══════════════════════════════════════════════════════════════════════

    const buttonMap = new Map();

    // ═══════════════════════════════════════════════════════════════════════
    // UTILITIES
    // ═══════════════════════════════════════════════════════════════════════

    function copyText(text) {
        if (navigator.clipboard?.writeText) {
            return navigator.clipboard.writeText(text);
        }
        const ta = document.createElement('textarea');
        ta.value = text;
        ta.style.cssText = 'position:fixed;opacity:0;pointer-events:none';
        document.body.appendChild(ta);
        ta.select();
        document.execCommand('copy');
        ta.remove();
        return Promise.resolve();
    }

    // Find the native copy button within a message element (not code block buttons)
    function findNativeCopyButton(el) {
        // Find all copy icons in the element
        const copyIcons = el.querySelectorAll('svg.lucide-copy');

        for (const icon of copyIcons) {
            const button = icon.closest('button');
            if (!button) continue;

            // Skip if this button is inside a code block
            if (button.closest('[data-code-block="true"]')) continue;

            return button;
        }

        return null;
    }

    // Get the safe zone (area excluding header and input area)
    function getSafeZone() {
        const padding = CONFIG.safeZonePadding;

        // Find header - the border-b element in chat area
        const header = document.querySelector('#chat-area > .flex-shrink-0.border-b') ||
                       document.querySelector('#chat-area > div:first-child');

        // Find input area container - the form wrapper at the bottom
        const inputContainer = document.querySelector('.relative.flex.flex-col.items-center.pb-6') ||
                              document.querySelector('form')?.closest('div[class*="pb-"]');

        let safeTop = padding;
        let safeBottom = window.innerHeight - padding;

        if (header) {
            const headerRect = header.getBoundingClientRect();
            safeTop = Math.max(safeTop, headerRect.bottom + padding);
        }

        if (inputContainer) {
            const inputRect = inputContainer.getBoundingClientRect();
            safeBottom = Math.min(safeBottom, inputRect.top - padding);
        }

        return { safeTop, safeBottom };
    }

    function getVisibleBounds(el, safeZone) {
        const rect = el.getBoundingClientRect();
        const { safeTop, safeBottom } = safeZone;
        const vw = window.innerWidth;

        // Intersect element rect with safe zone
        const top = Math.max(rect.top, safeTop);
        const bottom = Math.min(rect.bottom, safeBottom);
        const left = Math.max(rect.left, 0);
        const right = Math.min(rect.right, vw);

        // No visible area
        if (top >= bottom || left >= right) return null;

        return {
            top, bottom, left, right,
            width: right - left,
            height: bottom - top,
            fullRect: rect
        };
    }

    function createButton(size, iconSize, className) {
        // Using div instead of button - buttons have browser-specific drag issues
        const btn = document.createElement('div');
        btn.className = `fcb-btn ${className}`;
        btn.setAttribute('role', 'button');
        btn.setAttribute('tabindex', '0');
        btn.setAttribute('draggable', 'true');
        btn.innerHTML = `
            <svg class="icon icon-copy" xmlns="http://www.w3.org/2000/svg" width="${iconSize}" height="${iconSize}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                <rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect>
                <path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path>
            </svg>
            <svg class="icon icon-check" xmlns="http://www.w3.org/2000/svg" width="${iconSize}" height="${iconSize}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                <path d="M20 6 9 17l-5-5"></path>
            </svg>
        `;
        document.body.appendChild(btn);
        return btn;
    }

    function showCopiedFeedback(btn) {
        btn.classList.add('copied');
        setTimeout(() => btn.classList.remove('copied'), 2000);
    }

    function hideButton(btn) {
        btn.style.opacity = '0';
        btn.style.visibility = 'hidden';
    }

    function showButton(btn, x, y) {
        btn.style.left = `${x}px`;
        btn.style.top = `${y}px`;
        btn.style.opacity = '1';
        btn.style.visibility = 'visible';
    }

    function clampY(y, bounds, size, padding) {
        const minY = bounds.top + padding;
        const maxY = bounds.bottom - size - padding;

        // Not enough vertical space
        if (minY > maxY) return null;

        return Math.max(minY, Math.min(maxY, y));
    }

    // ═══════════════════════════════════════════════════════════════════════
    // POSITIONING STRATEGIES
    // ═══════════════════════════════════════════════════════════════════════

    function positionCentered(btn, targetEl, size, padding) {
        const safeZone = getSafeZone();
        const minVisible = size + padding * 2;
        const bounds = getVisibleBounds(targetEl, safeZone);

        if (!bounds || bounds.height < minVisible) {
            return hideButton(btn);
        }

        let x = bounds.left + (bounds.width / 2) - (size / 2);
        let y = bounds.top + (bounds.height / 2) - (size / 2);

        const clampedY = clampY(y, bounds, size, padding);
        if (clampedY === null) {
            return hideButton(btn);
        }

        // Clamp X within visible bounds
        x = Math.max(bounds.left + padding, Math.min(bounds.right - size - padding, x));

        showButton(btn, x, clampedY);
    }

    function positionBeside(btn, targetEl, size, padding, side, offset) {
        const safeZone = getSafeZone();
        const minVisible = size + padding * 2;
        const bounds = getVisibleBounds(targetEl, safeZone);

        if (!bounds || bounds.height < minVisible) {
            return hideButton(btn);
        }

        // Calculate "beside" position
        const besideX = side === 'left'
            ? bounds.fullRect.left - size - offset
            : bounds.fullRect.right + offset;

        // Check if "beside" position fits within viewport
        const fitsBeside = besideX >= padding && besideX + size <= window.innerWidth - padding;

        let x;

        if (fitsBeside) {
            // Position beside the message
            x = besideX;
        } else {
            // Fallback: overlay on the message
            const overlayPadding = CONFIG.message.overlayPadding;

            if (side === 'left') {
                // For user messages (left side), overlay on left edge
                x = bounds.left + overlayPadding;
            } else {
                // For AI messages (right side), overlay on right edge
                x = bounds.right - size - overlayPadding;
            }

            // Ensure x stays within viewport
            x = Math.max(padding, Math.min(window.innerWidth - size - padding, x));
        }

        // Calculate Y position (vertically centered within visible bounds)
        let y = bounds.top + (bounds.height / 2) - (size / 2);
        const clampedY = clampY(y, bounds, size, padding);

        if (clampedY === null) {
            return hideButton(btn);
        }

        showButton(btn, x, clampedY);
    }

    // ═══════════════════════════════════════════════════════════════════════
    // STYLES
    // ═══════════════════════════════════════════════════════════════════════

    function injectStyles() {
        if (document.getElementById('fcb-styles')) return;

        const { codeBlock: cb, message: msg, copied } = CONFIG;

        const style = document.createElement('style');
        style.id = 'fcb-styles';
        style.textContent = `
            .fcb-btn {
                position: fixed;
                z-index: 10000;
                cursor: pointer;
                display: flex;
                align-items: center;
                justify-content: center;
                transition: all 0.2s ease;
                opacity: 0;
                visibility: hidden;
                backdrop-filter: blur(8px);
                -webkit-backdrop-filter: blur(8px);
                pointer-events: auto;
                -webkit-user-drag: element;
                -webkit-user-select: none;
                user-select: none;
                -moz-user-select: none;
                touch-action: none;
            }
            .fcb-btn * {
                pointer-events: none !important;
                -webkit-user-drag: none !important;
            }
            .fcb-btn .icon {
                position: absolute;
                transition: opacity 0.2s, transform 0.2s;
            }
            .fcb-btn.holding {
                transform: scale(0.92);
                filter: brightness(0.85);
            }
            .fcb-btn.dragging {
                opacity: 0.6 !important;
                cursor: grabbing;
            }
            .fcb-btn .icon-copy { opacity: 1; transform: scale(1); }
            .fcb-btn .icon-check { opacity: 0; transform: scale(0.5); }
            .fcb-btn.copied .icon-copy { opacity: 0; transform: scale(0.5); }
            .fcb-btn.copied .icon-check { opacity: 1; transform: scale(1); }
            .fcb-btn.copied {
                background: ${copied.background} !important;
                color: ${copied.color} !important;
                border-color: ${copied.border} !important;
            }

            /* Code block */
            .fcb-code {
                width: ${cb.size}px;
                height: ${cb.size}px;
                border-radius: ${cb.borderRadius}px;
                border: 2px solid ${cb.idle.border};
                background: ${cb.idle.background};
                color: ${cb.idle.color};
            }
            .fcb-code:hover {
                background: ${cb.hover.background};
                color: ${cb.hover.color};
                border-color: ${cb.hover.border};
                transform: scale(1.08);
                box-shadow: 0 8px 32px rgba(0,0,0,0.4);
            }

            /* AI message */
            .fcb-ai {
                width: ${msg.size}px;
                height: ${msg.size}px;
                border-radius: ${msg.borderRadius}px;
                border: 2px solid ${msg.ai.idle.border};
                background: ${msg.ai.idle.background};
                color: ${msg.ai.idle.color};
            }
            .fcb-ai:hover {
                background: ${msg.ai.hover.background};
                color: ${msg.ai.hover.color};
                border-color: ${msg.ai.hover.border};
                transform: scale(1.1);
                box-shadow: 0 6px 24px rgba(0,0,0,0.4);
            }

            /* User message */
            .fcb-user {
                width: ${msg.size}px;
                height: ${msg.size}px;
                border-radius: ${msg.borderRadius}px;
                border: 2px solid ${msg.user.idle.border};
                background: ${msg.user.idle.background};
                color: ${msg.user.idle.color};
            }
            .fcb-user:hover {
                background: ${msg.user.hover.background};
                color: ${msg.user.hover.color};
                border-color: ${msg.user.hover.border};
                transform: scale(1.1);
                box-shadow: 0 6px 24px rgba(0,0,0,0.4);
            }
        `;
        document.head.appendChild(style);
    }

    // ═══════════════════════════════════════════════════════════════════════
    // ELEMENT DETECTION
    // ═══════════════════════════════════════════════════════════════════════

    function isAIMessage(el) {
        return el.tagName === 'DIV' &&
               el.classList.contains('bg-surface-primary') &&
               el.querySelector('.sticky .font-mono, .sticky.top-0');
    }

    function isUserMessage(el) {
        return el.tagName === 'DIV' &&
               el.classList.contains('self-end') &&
               el.querySelector('.bg-surface-secondary');
    }

    // ═══════════════════════════════════════════════════════════════════════
    // CONTENT EXTRACTION
    // ═══════════════════════════════════════════════════════════════════════

    function getCodeContent(block) {
        const code = block.querySelector('code');
        return code?.textContent || '';
    }

    function getMessageContent(el, isUser) {
        const selector = isUser
            ? '.bg-surface-secondary .prose'
            : '.no-scrollbar .prose';
        const prose = el.querySelector(selector);
        return prose?.textContent?.trim() || '';
    }

    // Convert prose HTML to Markdown for drag operations
    function getMessageMarkdown(el, isUser) {
        const selector = isUser
            ? '.bg-surface-secondary .prose'
            : '.no-scrollbar .prose';
        const prose = el.querySelector(selector);
        if (!prose) return '';
        return htmlToMarkdown(prose);
    }

    // HTML to Markdown converter
    function htmlToMarkdown(element) {
        if (!element) return '';

        function processNode(node) {
            if (node.nodeType === Node.TEXT_NODE) {
                return node.textContent;
            }
            if (node.nodeType !== Node.ELEMENT_NODE) {
                return '';
            }

            const tag = node.tagName.toLowerCase();
            const children = Array.from(node.childNodes).map(processNode).join('');

            switch (tag) {
                case 'h1': return `# ${children.trim()}\n\n`;
                case 'h2': return `## ${children.trim()}\n\n`;
                case 'h3': return `### ${children.trim()}\n\n`;
                case 'h4': return `#### ${children.trim()}\n\n`;
                case 'h5': return `##### ${children.trim()}\n\n`;
                case 'h6': return `###### ${children.trim()}\n\n`;
                case 'p': return `${children}\n\n`;
                case 'br': return '\n';
                case 'strong':
                case 'b': return `**${children}**`;
                case 'em':
                case 'i': return `*${children}*`;
                case 'code':
                    if (node.closest('pre')) return children;
                    return `\`${children}\``;
                case 'pre':
                    const codeEl = node.querySelector('code');
                    const langMatch = codeEl?.className?.match(/language-(\w+)/);
                    const lang = langMatch ? langMatch[1] : '';
                    const codeContent = codeEl?.textContent || children;
                    return `\`\`\`${lang}\n${codeContent}\n\`\`\`\n\n`;
                case 'a':
                    const href = node.getAttribute('href') || '';
                    return `[${children}](${href})`;
                case 'ul':
                case 'ol':
                    return children + '\n';
                case 'li':
                    const parent = node.parentElement;
                    const isOrdered = parent?.tagName.toLowerCase() === 'ol';
                    if (isOrdered) {
                        const idx = Array.from(parent.children).indexOf(node) + 1;
                        return `${idx}. ${children.trim()}\n`;
                    }
                    return `- ${children.trim()}\n`;
                case 'blockquote':
                    return children.trim().split('\n').map(l => `> ${l}`).join('\n') + '\n\n';
                case 'hr': return '\n---\n\n';
                case 'table':
                    return processTable(node);
                default:
                    return children;
            }
        }

        function processTable(table) {
            const rows = Array.from(table.querySelectorAll('tr'));
            if (rows.length === 0) return '';

            let md = '';
            rows.forEach((row, rowIdx) => {
                const cells = Array.from(row.querySelectorAll('th, td'));
                const cellTexts = cells.map(c => c.textContent.trim());
                md += '| ' + cellTexts.join(' | ') + ' |\n';
                if (rowIdx === 0) {
                    md += '| ' + cells.map(() => '---').join(' | ') + ' |\n';
                }
            });
            return md + '\n';
        }

        const result = processNode(element);
        return result.replace(/\n{3,}/g, '\n\n').trim();
    }

    // ═══════════════════════════════════════════════════════════════════════
    // SETUP FUNCTIONS
    // ═══════════════════════════════════════════════════════════════════════

    function setupCodeBlock(block) {
        if (buttonMap.has(block)) return;

        const { size, padding, iconSize } = CONFIG.codeBlock;
        const container = block.querySelector('.code-block_container__lbMX4, pre') || block;
        const btn = createButton(size, iconSize, 'fcb-code');

        const data = {
            btn,
            type: 'code',
            target: container,
            update: () => positionCentered(btn, container, size, padding),
            getContent: () => getCodeContent(block)
        };

        buttonMap.set(block, data);
        setupDragAndClick(btn, data);
        data.update();
    }

    function setupMessage(el, isUser) {
        if (buttonMap.has(el)) return;

        const { size, padding, iconSize, offset } = CONFIG.message;
        const msgConfig = isUser ? CONFIG.message.user : CONFIG.message.ai;
        const className = isUser ? 'fcb-user' : 'fcb-ai';

        const target = isUser
            ? el.querySelector('.bg-surface-secondary') || el
            : el.querySelector('.no-scrollbar') || el;

        const btn = createButton(size, iconSize, className);

        const data = {
            btn,
            type: isUser ? 'user' : 'ai',
            target,
            sourceEl: el,
            update: () => positionBeside(btn, target, size, padding, msgConfig.side, offset),
            getContent: () => getMessageContent(el, isUser),
            getDragContent: () => getMessageMarkdown(el, isUser),
            getNativeButton: () => findNativeCopyButton(el)
        };

        buttonMap.set(el, data);
        setupDragAndClick(btn, data);
        data.update();
    }

    function setupDragAndClick(btn, data) {
        let mouseDownTime = 0;
        let mouseDownPos = { x: 0, y: 0 };
        let dragDidStart = false;
        let holdTimer = null;
        let isMouseDown = false;
        let moveCount = 0;

        const dbg = `[FCB:${data.type}]`;

        const cleanup = () => {
            clearTimeout(holdTimer);
            holdTimer = null;
            isMouseDown = false;
            moveCount = 0;
            btn.classList.remove('holding');
        };

        console.log(dbg, 'Button created, draggable =', btn.getAttribute('draggable'), 'tagName =', btn.tagName);

        // Track mouse movement to see if user is trying to drag
        btn.addEventListener('mousemove', (e) => {
            if (!isMouseDown) return;
            moveCount++;
            if (moveCount === 1 || moveCount === 5 || moveCount === 10) {
                const dx = Math.abs(e.clientX - mouseDownPos.x);
                const dy = Math.abs(e.clientY - mouseDownPos.y);
                console.log(dbg, `mousemove #${moveCount}, delta: ${dx.toFixed(0)}x${dy.toFixed(0)}px`);
            }
        });

        btn.addEventListener('mousedown', (e) => {
            console.log(dbg, 'mousedown, button =', e.button, 'target =', e.target.tagName);
            if (e.button !== 0) return;

            mouseDownTime = Date.now();
            mouseDownPos = { x: e.clientX, y: e.clientY };
            dragDidStart = false;
            isMouseDown = true;
            moveCount = 0;

            holdTimer = setTimeout(() => {
                console.log(dbg, 'hold threshold approaching, adding .holding class');
                btn.classList.add('holding');
            }, CONFIG.holdThreshold * 0.6);
        });

        btn.addEventListener('dragstart', (e) => {
            const holdDuration = Date.now() - mouseDownTime;
            console.log(dbg, '>>> dragstart fired! holdDuration =', holdDuration, 'threshold =', CONFIG.holdThreshold);

            if (holdDuration < CONFIG.holdThreshold) {
                console.log(dbg, 'dragstart CANCELLED (too quick)');
                e.preventDefault();
                cleanup();
                return;
            }

            // Use getDragContent for markdown if available, otherwise plain content
            const content = data.getDragContent ? data.getDragContent() : data.getContent();
            console.log(dbg, 'drag content length =', content?.length || 0);

            if (!content) {
                console.log(dbg, 'dragstart CANCELLED (no content)');
                e.preventDefault();
                cleanup();
                return;
            }

            console.log(dbg, 'Setting drag data...');
            e.dataTransfer.setData('text/plain', content);
            e.dataTransfer.effectAllowed = 'copyMove';

            // Create drag image
            const dragImage = document.createElement('div');
            dragImage.textContent = `📄 ${content.length > 50 ? content.substring(0, 47) + '...' : content}`;
            dragImage.style.cssText = `
                position: absolute;
                left: -9999px;
                top: -9999px;
                padding: 8px 12px;
                background: rgba(30, 30, 30, 0.95);
                color: #fff;
                border-radius: 6px;
                font-size: 12px;
                font-family: system-ui, sans-serif;
                max-width: 300px;
                white-space: nowrap;
                overflow: hidden;
                text-overflow: ellipsis;
                box-shadow: 0 4px 12px rgba(0,0,0,0.3);
                z-index: 99999;
            `;
            document.body.appendChild(dragImage);

            try {
                e.dataTransfer.setDragImage(dragImage, 10, 10);
                console.log(dbg, 'setDragImage called');
            } catch (err) {
                console.log(dbg, 'setDragImage error:', err);
            }

            requestAnimationFrame(() => dragImage.remove());

            dragDidStart = true;
            cleanup();
            btn.classList.add('dragging');
            console.log(dbg, 'Drag started successfully!');
        });

        btn.addEventListener('drag', (e) => {
            if (!btn._dragLogged) {
                console.log(dbg, 'drag event firing (drag in progress)');
                btn._dragLogged = true;
            }
        });

        btn.addEventListener('dragend', (e) => {
            console.log(dbg, 'dragend, dropEffect =', e.dataTransfer?.dropEffect);
            btn.classList.remove('dragging');
            btn._dragLogged = false;
            setTimeout(() => {
                dragDidStart = false;
            }, 50);
        });

        btn.addEventListener('mouseup', (e) => {
            const holdDuration = Date.now() - mouseDownTime;
            console.log(dbg, 'mouseup, holdDuration =', holdDuration, 'moveCount =', moveCount);
            cleanup();
        });

        btn.addEventListener('mouseleave', (e) => {
            if (isMouseDown) {
                console.log(dbg, 'mouseleave while mousedown, moveCount =', moveCount);
            }
            cleanup();
        });

        btn.addEventListener('click', async (e) => {
            e.preventDefault();
            e.stopPropagation();
            cleanup();

            const holdDuration = Date.now() - mouseDownTime;
            console.log(dbg, 'click, dragDidStart =', dragDidStart, 'holdDuration =', holdDuration);

            if (dragDidStart) {
                console.log(dbg, 'click IGNORED (drag occurred)');
                return;
            }

            if (holdDuration >= CONFIG.holdThreshold) {
                console.log(dbg, 'click IGNORED (held too long)');
                return;
            }

            console.log(dbg, 'Performing copy...');

            if (data.type === 'user' || data.type === 'ai') {
                const nativeBtn = data.getNativeButton?.();
                if (nativeBtn) {
                    nativeBtn.click();
                    showCopiedFeedback(btn);
                    return;
                }
            }

            const content = data.getContent();
            if (!content) return;

            try {
                await copyText(content);
                showCopiedFeedback(btn);
            } catch (err) {
                console.error('Copy failed:', err);
            }
        });
    }

    // ═══════════════════════════════════════════════════════════════════════
    // PROCESSING
    // ═══════════════════════════════════════════════════════════════════════

    function processAll() {
        // Code blocks
        document.querySelectorAll('[data-code-block="true"]').forEach(setupCodeBlock);

        // Messages
        const ol = document.querySelector('ol.flex-col-reverse');
        if (ol) {
            Array.from(ol.children).forEach(child => {
                if (isAIMessage(child)) setupMessage(child, false);
                else if (isUserMessage(child)) setupMessage(child, true);
            });
        }
    }

    function updateAll() {
        for (const [el, data] of buttonMap) {
            if (!document.body.contains(el)) {
                data.btn.remove();
                buttonMap.delete(el);
            } else {
                data.update();
            }
        }
    }

    // ═══════════════════════════════════════════════════════════════════════
    // INITIALIZATION
    // ═══════════════════════════════════════════════════════════════════════

    injectStyles();
    processAll();
    updateAll();

    // DOM observer
    new MutationObserver(() => {
        processAll();
        updateAll();
    }).observe(document.body, { childList: true, subtree: true });

    // Throttled scroll/resize
    let rafId = null;
    const onScrollResize = () => {
        if (rafId) return;
        rafId = requestAnimationFrame(() => {
            updateAll();
            rafId = null;
        });
    };

    window.addEventListener('scroll', onScrollResize, { passive: true });
    window.addEventListener('resize', onScrollResize, { passive: true });
    document.addEventListener('scroll', onScrollResize, { passive: true, capture: true });
})();