Adds floating copy buttons for code blocks (centered) and chat messages (beside/overlay)
// ==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 });
})();