Adds collapsible code blocks with clickable headers, footer controls, and a global toolbar toggle
目前為
// ==UserScript==
// @name LMArena | Collapsible Code Blocks
// @namespace https://greasyfork.org/en/users/1462137-piknockyou
// @version 3.9
// @author Piknockyou (vibe-coded)
// @license AGPL-3.0
// @description Adds collapsible code blocks with clickable headers, footer controls, and a global toolbar toggle
// @match *://*lmarena.ai/*
// @icon https://lmarena.ai/favicon.ico
// @grant none
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
/*
What this script does
---------------------
- Makes the code block header clickable to toggle (collapse/expand).
- Adds an in-flow footer "Collapse" bar per code block (never covers code).
- Uses a single global fixed footer above the input when the in-flow footer would be obscured.
(The in-flow footer hides via visibility to prevent layout/scroll jitter.)
- Adds a dual toolbar button next to the input controls:
* Collapse all
* Expand all
* Hold collapse to toggle persistent auto-collapse
Detection/updates model
-----------------------
- Initial scan: finds all current code blocks once on load.
- Incremental scan: MutationObserver looks only at added DOM nodes and initializes new code blocks.
- Safety net: periodic resync detects missed blocks and runs a full scan.
- Fixed footer updates on scroll/resize (throttled via requestAnimationFrame).
*/
// ═══════════════════════════════════════════════════════════════════════
// CHANGELOG
// ═══════════════════════════════════════════════════════════════════════
// v1.3
// - Switched to incremental MutationObserver (only processes added nodes)
// - Added 5-second fallback resync to catch edge cases
// - Performance: avoids repeated full DOM rescans during streaming
//
// v1.4
// - Batched global collapse/expand across frames to avoid long UI stalls
//
// v1.5
// - Commenting pass / documentation improvements (no functional changes)
// - Corrected init log version string
//
// v1.8
// - Fixed UI flicker/stutter when toggling all code blocks
// - Removed requestAnimationFrame batching from collapseAll/expandAll
// (spreading DOM changes across frames caused multiple layout recalcs,
// each shifting content by 1-2px before settling; synchronous is smoother
// since display:none doesn't trigger expensive reflows)
// - Removed auto-expand feature (collapse-only is cleaner UX)
// - Simplified context menu
//
// v1.9
// - Cleanup: removed unused batch state variables left over from pre-v1.8 batching
// - Cleanup: removed stale "auto-collapse/auto-expand" wording
//
// v2.0
// - Cleanup: removed unused animation config (no longer used after switching to display:none collapse)
// - Cleanup: removed unused destructured `animation` variable in injectStyles()
// - Optional cleanup: removed unused debug helpers (getState/getAutoMode) and ContextMenu.isOpen
//
// v2.1
// - Fixed: code blocks now preserve their original width when collapsed
// - Changed: global toggle button moved to bottom-right (left of submit button)
//
// v2.2
// - Added: scroll anchoring to preserve visual position when collapsing
// (prevents jarring scroll jumps when collapsing large code blocks)
//
// v2.3
// - Fixed: scroll anchoring when viewing middle of a large code block
// (now detects "inside block" state and scrolls to show header after collapse)
//
// v2.4
// - Redesigned: global toggle is now a dual button (collapse/expand side by side)
// - Added: hold collapse button for 1s to toggle persistent auto-collapse mode
// - Added: tooltip hint appears at 0.5s explaining hold-to-activate feature
// - Removed: right-click context menu (replaced by hold gesture)
//
// v2.5
// - Changed: expand button icon to unfold style (avoids dropdown menu confusion)
// - Added: fill animation on collapse button during hold gesture
// - Changed: tooltips now appear above buttons (not below/as title attributes)
// - Added: hover tooltips for both buttons explaining their function
//
// v2.6
// - Fixed: tooltips now properly appear on hover
// - Changed: collapse button icon to fold style (two chevrons pointing inward)
//
// v2.7
// - Fixed: hover tooltips were hidden by inline visibility/opacity styles (now cleared after measurement)
//
// v2.8
// - Tooltips now explicitly mention "code blocks" and toolbar buttons include aria-labels
//
// v2.9
// - Replaced floating collapse button with sticky footer bar
// - Footer bar is part of code block structure (like the header)
// - Footer sticks to viewport bottom while scrolling through tall blocks
// - Footer sits at natural position when code block bottom is visible (never covers code)
// - Removed all floating button positioning logic (simpler, more performant)
//
// v3.0
// - Fixed: footer now properly follows viewport when scrolling through tall code blocks
// - CSS sticky alone doesn't work for "show at viewport bottom when natural position is below"
// - Re-added JavaScript positioning: fixed position when block bottom is below viewport,
// natural position when block bottom is visible (never covers code)
// - Re-added scroll/resize handlers with rAF throttling for footer positioning
//
// v3.1
// - Styled footer bar to match header (uses site's CSS variables for colors/borders)
// - Fixed: footer now positions above input area (not at viewport bottom)
// - Fixed: footer keeps rounded corners when in fixed mode
// - Removed hardcoded footer colors from CONFIG (now uses site theme)
//
// v3.2
// - Fixed: flickering during transition between natural and fixed footer positions
// - Added hysteresis: switch to fixed only when natural is FULLY hidden behind input
// - Added hysteresis: switch to natural only when natural would be FULLY visible
// - Natural footer now allowed to scroll partially behind input area (no early switch)
//
// v3.3
// - Changed transition logic: switch to fixed as soon as natural footer TOUCHES input area
// - Natural footer is never partially covered (cleaner visual)
// - Switch back to natural only when full room available (hysteresis prevents flicker)
//
// v3.4
// - Fixed: robust footer swap (in-flow footer hides, separate fixed footer shows) to eliminate flicker/partial overlap
// - Fixed footer never covers the block header: hides as soon as it would touch the header
// - In-flow footer is hidden via visibility (keeps layout stable; prevents scroll/height jitter)
//
// v3.5
// - Fixed: during smooth auto-scroll, fixed footer now disappears immediately on header collision
// - Prevents "jumping" the fixed footer to a different code block when the bottom-most one collides
// - More robust height math: uses in-flow footer height as fallback when fixed footer is hidden
// v3.6
// - Cleanup/docs: removed leftover floating-button wording and unused bits
// v3.7
// - Script name changed from "LMArena | Code Block Collapse" to "LMArena | Collapsible Code Blocks"
// v3.8
// - Fixed: conflict with other userscripts (Multi-Provider Chat Export, etc.)
// - Added 1000ms initialization delay to avoid React Hydration Error #418
// v3.9
// - Replaced hardcoded delay with dynamic Stabilization Observer
// - Detects React hydration completion by watching for the chat input bar
// - Uses requestIdleCallback for optimal performance/compatibility
// ═══════════════════════════════════════════════════════════════════════
// DEBUG
// ═══════════════════════════════════════════════════════════════════════
// Toggle this to silence console output from this script.
const DEBUG = true;
// Simple structured logger (colored prefix + levels). When DEBUG=false, it’s a no-op.
const log = (msg, type = 'info', data = null) => {
if (!DEBUG) return;
const prefix = '[CBC]';
const styles = {
info: 'color: #8ab4f8',
success: 'color: #34a853',
warn: 'color: #fbbc04',
error: 'color: #ea4335'
};
if (data) {
console.log(`%c${prefix} ${msg}`, styles[type], data);
} else {
console.log(`%c${prefix} ${msg}`, styles[type]);
}
};
// ═══════════════════════════════════════════════════════════════════════
// CONFIGURATION
// ═══════════════════════════════════════════════════════════════════════
// Notes:
// - `resyncInterval` is a safety net for missed mutations (rare edge cases).
const CONFIG = {
header: {
hoverBg: 'rgba(255,255,255,0.04)',
activeBg: 'rgba(255,255,255,0.08)',
collapsedHoverBg: 'rgba(255,255,255,0.06)',
collapsedActiveBg: 'rgba(255,255,255,0.10)'
},
storageKey: 'lmarena_codeblock_collapse_settings',
resyncInterval: 5000 // Fallback resync every 5 seconds
};
// ═══════════════════════════════════════════════════════════════════════
// STATE & STORAGE
// ═══════════════════════════════════════════════════════════════════════
// Tracks each initialized code block element -> per-block state object.
// This is what prevents us from re-initializing blocks on every mutation.
const blockState = new Map();
// Persisted user preference: should new blocks auto-collapse?
const DEFAULT_SETTINGS = {
autoMode: 'off' // 'off' or 'collapse'
};
let settings = { ...DEFAULT_SETTINGS };
// Load settings from localStorage (best-effort).
function loadSettings() {
try {
const saved = localStorage.getItem(CONFIG.storageKey);
if (saved) {
settings = { ...DEFAULT_SETTINGS, ...JSON.parse(saved) };
log(`Settings loaded: autoMode=${settings.autoMode}`, 'success');
}
} catch (e) {
log('Failed to load settings: ' + e.message, 'error');
}
}
// Save settings to localStorage (best-effort).
function saveSettings() {
try {
localStorage.setItem(CONFIG.storageKey, JSON.stringify(settings));
log(`Settings saved: autoMode=${settings.autoMode}`, 'success');
} catch (e) {
log('Failed to save settings: ' + e.message, 'error');
}
}
// ═══════════════════════════════════════════════════════════════════════
// STYLES
// ═══════════════════════════════════════════════════════════════════════
// Inject our stylesheet once. We key off #cbc-styles to avoid duplicates
// across SPA navigation/rerenders.
function injectStyles() {
if (document.getElementById('cbc-styles')) return;
const { header } = CONFIG;
const style = document.createElement('style');
style.id = 'cbc-styles';
style.textContent = `
/* Code container - instant toggle, no animation */
.cbc-container {
overflow: hidden;
}
.cbc-container.cbc-collapsed {
display: none !important;
}
/* Clickable header - always interactive */
.cbc-header-interactive {
transition: background-color 0.15s ease, border-radius 0.15s ease;
position: relative;
cursor: pointer;
contain: layout style;
}
.cbc-header-interactive:hover {
background-color: ${header.hoverBg};
}
.cbc-header-interactive:active {
background-color: ${header.activeBg};
}
/* Collapsed state styling */
.cbc-header-interactive.cbc-header-collapsed {
border-bottom-color: transparent !important;
border-radius: 0 0 0 0;
}
.cbc-header-interactive.cbc-header-collapsed:hover {
background-color: ${header.collapsedHoverBg};
}
.cbc-header-interactive.cbc-header-collapsed:active {
background-color: ${header.collapsedActiveBg};
}
/* State hint indicator (appears on header hover) - centered */
.cbc-state-hint {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
display: flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
font-size: 11px;
color: rgba(255,255,255,0.3);
font-family: system-ui, -apple-system, sans-serif;
background: rgba(30,30,30,0.95);
border-radius: 4px;
opacity: 0;
pointer-events: none;
z-index: 10;
white-space: nowrap;
}
.cbc-header-interactive:hover .cbc-state-hint {
opacity: 1;
color: rgba(255,255,255,0.7);
}
.cbc-state-hint svg {
opacity: 0.7;
}
/* Hint text changes based on state */
.cbc-state-hint .hint-expand { display: none; }
.cbc-state-hint .hint-collapse { display: flex; align-items: center; gap: 6px; }
.cbc-header-collapsed .cbc-state-hint .hint-expand { display: flex; align-items: center; gap: 6px; }
.cbc-header-collapsed .cbc-state-hint .hint-collapse { display: none; }
/* Footer bar for collapse action - matches header style */
.cbc-footer {
position: relative;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 16px;
border-top: 1px solid var(--border, rgba(255,255,255,0.1));
border-radius: 0 0 6px 6px;
cursor: pointer;
transition: background-color 0.15s ease;
user-select: none;
font-size: 13px;
font-weight: 500;
}
/* Use site's text-secondary color */
.cbc-footer {
color: var(--text-secondary, rgba(255,255,255,0.5));
}
.cbc-footer:hover {
background-color: ${header.hoverBg};
color: var(--text-primary, rgba(255,255,255,0.9));
}
.cbc-footer:active {
background-color: ${header.activeBg};
}
.cbc-footer svg {
opacity: 0.7;
transition: opacity 0.15s ease;
}
.cbc-footer:hover svg {
opacity: 1;
}
.cbc-footer-hidden {
display: none !important;
}
/* Hidden because a separate global fixed footer is active (keeps layout stable) */
.cbc-footer-hidden-by-fixed {
visibility: hidden !important;
pointer-events: none !important;
}
/* Footer in fixed mode (global, appended to <body>) */
.cbc-footer.cbc-footer-fixed {
position: fixed;
border-radius: 0 0 6px 6px;
box-shadow: 0 -2px 8px rgba(0,0,0,0.25);
background-color: var(--surface-primary, #1a1a1a);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
/* When header is collapsed, give it bottom border radius */
.cbc-header-interactive.cbc-header-collapsed {
border-radius: 6px !important;
}
/* Dual toolbar button container */
.cbc-dual-btn {
display: inline-flex;
align-items: stretch;
border-radius: 6px;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.03);
height: 32px;
}
.cbc-dual-btn button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 100%;
border: none;
background: transparent;
color: rgba(255,255,255,0.6);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
padding: 0;
margin: 0;
outline: none;
}
.cbc-dual-btn button:hover {
background: rgba(255,255,255,0.08);
color: rgba(255,255,255,0.9);
}
.cbc-dual-btn button:active {
background: rgba(255,255,255,0.12);
}
.cbc-dual-btn .cbc-collapse-btn {
border-right: 1px solid rgba(255,255,255,0.1);
position: relative;
overflow: hidden;
}
/* Fill animation for hold gesture */
.cbc-collapse-btn::before {
content: '';
position: absolute;
left: 0;
right: 0;
height: 0%;
pointer-events: none;
z-index: 0;
}
.cbc-collapse-btn.filling-up::before {
bottom: 0;
background: rgba(249, 115, 22, 0.35);
animation: cbcFillUp 500ms linear forwards;
}
.cbc-collapse-btn.filling-down::before {
top: 0;
background: rgba(120, 120, 120, 0.35);
animation: cbcFillDown 500ms linear forwards;
}
.cbc-collapse-btn svg {
position: relative;
z-index: 1;
}
@keyframes cbcFillUp {
from { height: 0%; }
to { height: 100%; }
}
@keyframes cbcFillDown {
from { height: 0%; }
to { height: 100%; }
}
/* Auto-collapse active state */
.cbc-dual-btn.auto-active {
border-color: rgba(249, 115, 22, 0.4);
}
.cbc-dual-btn.auto-active .cbc-collapse-btn {
background: rgba(249, 115, 22, 0.15);
color: #f97316;
}
.cbc-dual-btn.auto-active .cbc-collapse-btn:hover {
background: rgba(249, 115, 22, 0.25);
color: #fb923c;
}
.cbc-dual-btn.auto-active .cbc-collapse-btn:active {
background: rgba(249, 115, 22, 0.35);
}
/* Custom tooltip (positioned above buttons) */
.cbc-hold-tooltip {
position: fixed;
background: #1a1a1a;
border: 1px solid #3a3a3a;
border-radius: 6px;
padding: 6px 10px;
font-family: system-ui, -apple-system, sans-serif;
font-size: 11px;
color: #e0e0e0;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
z-index: 10001;
pointer-events: none;
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease, visibility 0.15s ease;
white-space: nowrap;
}
.cbc-hold-tooltip.visible {
opacity: 1;
visibility: visible;
}
/* Arrow pointing down (tooltip is above button) */
.cbc-hold-tooltip::before {
content: '';
position: absolute;
bottom: -6px;
left: 50%;
transform: translateX(-50%);
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 6px solid #3a3a3a;
}
.cbc-hold-tooltip::after {
content: '';
position: absolute;
bottom: -5px;
left: 50%;
transform: translateX(-50%);
border-left: 5px solid transparent;
border-right: 5px solid transparent;
border-top: 5px solid #1a1a1a;
}
.cbc-hold-tooltip.confirmed {
background: rgba(249, 115, 22, 0.9);
border-color: #f97316;
color: #fff;
font-weight: 500;
}
.cbc-hold-tooltip.confirmed::before {
border-top-color: #f97316;
}
.cbc-hold-tooltip.confirmed::after {
border-top-color: rgba(249, 115, 22, 0.9);
}
.cbc-hold-tooltip.deactivated {
background: rgba(100, 100, 100, 0.95);
border-color: #666;
color: #fff;
font-weight: 500;
}
.cbc-hold-tooltip.deactivated::before {
border-top-color: #666;
}
.cbc-hold-tooltip.deactivated::after {
border-top-color: rgba(100, 100, 100, 0.95);
}
`;
document.head.appendChild(style);
}
// ═══════════════════════════════════════════════════════════════════════
// SCROLL ANCHORING
// ═══════════════════════════════════════════════════════════════════════
// When collapsing code blocks, content below shifts up. Without anchoring,
// this causes jarring scroll jumps. We fix this by:
// 1. Finding a visible "anchor" element before collapse
// 2. Recording its viewport position
// 3. After collapse, adjusting scroll to restore anchor's visual position
// Get the scrollable container (Radix scroll area viewport).
function getScrollContainer() {
return document.querySelector('[data-radix-scroll-area-viewport]');
}
// Find the best anchor element currently visible in the viewport.
// Returns { element, offsetTop } or null.
// Prefers code block headers since they stay visible when collapsed.
function findScrollAnchor() {
const scrollContainer = getScrollContainer();
if (!scrollContainer) return null;
const containerRect = scrollContainer.getBoundingClientRect();
const viewportTop = containerRect.top;
const viewportBottom = containerRect.bottom;
const viewportCenter = viewportTop + containerRect.height / 2;
// Priority: code block headers (stay visible), then message prose
const candidates = [
...document.querySelectorAll('.cbc-header-interactive'),
...document.querySelectorAll('.prose > p'),
];
let bestAnchor = null;
let bestDistance = Infinity;
for (const el of candidates) {
const rect = el.getBoundingClientRect();
// Must be at least partially visible in the scroll container
if (rect.bottom < viewportTop || rect.top > viewportBottom) continue;
// Prefer elements closer to center of viewport (more stable feel)
const elCenter = rect.top + rect.height / 2;
const distance = Math.abs(elCenter - viewportCenter);
if (distance < bestDistance) {
bestDistance = distance;
bestAnchor = el;
}
}
if (!bestAnchor) return null;
return {
element: bestAnchor,
offsetTop: bestAnchor.getBoundingClientRect().top
};
}
// Restore scroll position to keep anchor element at its original viewport position.
function restoreScrollAnchor(anchor) {
if (!anchor || !anchor.element) return;
const scrollContainer = getScrollContainer();
if (!scrollContainer) return;
// Element may have been removed from DOM (edge case)
if (!document.body.contains(anchor.element)) return;
const newRect = anchor.element.getBoundingClientRect();
const delta = newRect.top - anchor.offsetTop;
// Only adjust if there's meaningful shift (avoid sub-pixel noise)
if (Math.abs(delta) > 1) {
scrollContainer.scrollTop += delta;
}
}
// Check if user is currently viewing "inside" a code block's content.
// True when: header is above/at viewport top, but code content is visible.
// This happens when scrolled partway through a tall code block.
function isViewingBlockContent(state) {
const scrollContainer = getScrollContainer();
if (!scrollContainer) return false;
const header = state.header;
const container = state.container;
const scrollRect = scrollContainer.getBoundingClientRect();
const headerRect = header.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
// Header is above or at the very top of the scroll viewport (with small buffer)
const headerAboveViewport = headerRect.bottom <= scrollRect.top + 5;
// But the code container extends into the visible viewport
const contentVisible = containerRect.top < scrollRect.bottom &&
containerRect.bottom > scrollRect.top;
return headerAboveViewport && contentVisible;
}
// Get the visible bounds of the scroll container (safe zone for fixed elements).
// Also accounts for the input area at the bottom.
function getScrollBounds() {
const scrollContainer = getScrollContainer();
let top = 0;
let bottom = window.innerHeight;
if (scrollContainer) {
const rect = scrollContainer.getBoundingClientRect();
top = rect.top;
bottom = rect.bottom;
}
// Account for input area at bottom
const inputArea = document.querySelector('.relative.flex.flex-col.items-center.pb-6') ||
document.querySelector('form')?.closest('div[class*="pb-"]');
if (inputArea) {
const inputRect = inputArea.getBoundingClientRect();
bottom = Math.min(bottom, inputRect.top);
}
return { top, bottom };
}
// Scroll so the block's header is positioned at the top of the viewport.
// Used after collapsing a block we were "inside" of.
function scrollHeaderToTop(state) {
const scrollContainer = getScrollContainer();
if (!scrollContainer) return;
const header = state.header;
const scrollRect = scrollContainer.getBoundingClientRect();
const headerRect = header.getBoundingClientRect();
// Calculate offset from header's current position to top of scroll container
// Add a small margin (8px) so header isn't flush against the top
const offset = headerRect.top - scrollRect.top - 8;
if (Math.abs(offset) > 1) {
scrollContainer.scrollTop += offset;
}
}
// ═══════════════════════════════════════════════════════════════════════
// COLLAPSE/EXPAND LOGIC
// ═══════════════════════════════════════════════════════════════════════
// Collapse a single code block (instant, no animation).
// If anchor is provided, scroll anchoring is handled externally (bulk operations).
// If anchor is null and not explicitly skipped, we find and restore our own anchor.
function collapseBlock(state, externalAnchor = undefined) {
log(`Collapsing block`, 'info');
const { block, container, header, footer } = state;
// For single-block collapse, determine scroll strategy BEFORE modifying DOM
let scrollStrategy = null; // 'anchor' | 'scrollToHeader' | null
let anchor = null;
if (externalAnchor === undefined) {
// Check if we're viewing inside this block (header above viewport, content visible)
if (isViewingBlockContent(state)) {
scrollStrategy = 'scrollToHeader';
log('Collapse: inside block, will scroll to header', 'info');
} else {
anchor = findScrollAnchor();
if (anchor) {
scrollStrategy = 'anchor';
}
}
} else {
anchor = externalAnchor;
}
// Preserve original width to prevent layout shift when content is hidden
if (!state.originalWidth) {
state.originalWidth = block.offsetWidth;
}
block.style.minWidth = state.originalWidth + 'px';
container.classList.add('cbc-collapsed');
header.classList.add('cbc-header-collapsed');
footer.classList.add('cbc-footer-hidden');
footer.classList.remove('cbc-footer-hidden-by-fixed');
// If this block currently owns the global fixed footer, hide it now.
if (typeof activeFixedState !== 'undefined' && activeFixedState === state) {
hideGlobalFixedFooter();
}
state.collapsed = true;
// Handle scroll adjustment for single-block operations
if (externalAnchor === undefined) {
if (scrollStrategy === 'scrollToHeader') {
scrollHeaderToTop(state);
} else if (scrollStrategy === 'anchor' && anchor) {
restoreScrollAnchor(anchor);
}
}
}
// Expand a single code block (instant, no animation).
// Same anchor semantics as collapseBlock.
function expandBlock(state, externalAnchor = undefined) {
log(`Expanding block`, 'info');
const { block, container, header, footer } = state;
// For single-block expand, handle scroll anchoring ourselves
const anchor = externalAnchor === undefined ? findScrollAnchor() : externalAnchor;
// Remove forced width; content will determine natural width
block.style.minWidth = '';
container.classList.remove('cbc-collapsed');
header.classList.remove('cbc-header-collapsed');
footer.classList.remove('cbc-footer-hidden');
state.collapsed = false;
// Restore scroll position for single-block operations
if (externalAnchor === undefined && anchor) {
restoreScrollAnchor(anchor);
}
// Update global fixed footer after expand
requestAnimationFrame(() => {
updateFixedFooter();
});
}
// Simple toggle helper for header click handling.
function toggleBlock(state) {
if (state.collapsed) {
expandBlock(state);
} else {
collapseBlock(state);
}
}
// ═══════════════════════════════════════════════════════════════════════
// PUBLIC API
// ═══════════════════════════════════════════════════════════════════════
// Exposed on window for debugging/automation:
// window.CodeBlockCollapse.collapseAll()
// window.CodeBlockCollapse.expandAll()
// window.CodeBlockCollapse.toggleAll()
// window.CodeBlockCollapse.setAutoMode('collapse'|'off')
const CodeBlockCollapse = {
// Collapse all expanded blocks in a single synchronous pass.
//
// Why NOT batched across frames (requestAnimationFrame)?
// -------------------------------------------------------
// Previously we chunked DOM updates across multiple frames to avoid
// "long task" warnings. However, this caused visible UI flicker:
// each frame triggered a separate layout recalculation, shifting
// content by 1-2 pixels before settling.
//
// Synchronous single-pass works better here because:
// 1. We use display:none which is cheap (no reflow of hidden content)
// 2. Browser batches all changes into one layout pass automatically
// 3. No intermediate states = no flicker
//
// Scroll Anchoring:
// We find a single anchor BEFORE collapsing, then restore AFTER all
// collapses complete. This prevents jarring scroll jumps.
//
// Special case: if we're "inside" one of the blocks being collapsed
// (header above viewport, content visible), we scroll to show that
// block's header after all collapses complete.
collapseAll() {
const blocks = Array.from(blockState.values()).filter(s => !s.collapsed);
if (blocks.length === 0) return;
// BEFORE any DOM changes: determine scroll strategy
// Priority: if inside a block being collapsed, scroll to its header
const insideBlock = blocks.find(s => isViewingBlockContent(s));
const anchor = insideBlock ? null : findScrollAnchor();
if (insideBlock) {
log('CollapseAll: detected inside block, will scroll to header', 'info');
}
log(`Collapsing ${blocks.length} blocks`, 'info');
// Pass null to skip per-block anchoring; we handle it once at the end
blocks.forEach(state => collapseBlock(state, null));
// AFTER all collapses: adjust scroll position
if (insideBlock) {
scrollHeaderToTop(insideBlock);
} else if (anchor) {
restoreScrollAnchor(anchor);
}
log(`Collapsed ${blocks.length} blocks`, 'success');
},
// Expand all collapsed blocks in a single synchronous pass.
// See collapseAll() for rationale on why we don't batch across frames.
// Same scroll anchoring strategy as collapseAll().
expandAll() {
const blocks = Array.from(blockState.values()).filter(s => s.collapsed);
if (blocks.length === 0) return;
// Find anchor before any DOM changes
const anchor = findScrollAnchor();
log(`Expanding ${blocks.length} blocks`, 'info');
// Pass null to skip per-block anchoring; we handle it once at the end
blocks.forEach(state => expandBlock(state, null));
// Restore scroll position once after all expands
restoreScrollAnchor(anchor);
log(`Expanded ${blocks.length} blocks`, 'success');
},
// Toggle all code blocks:
// - If any are expanded → collapse all
// - If all are collapsed → expand all
toggleAll() {
const anyExpanded = Array.from(blockState.values()).some(s => !s.collapsed);
if (anyExpanded) {
this.collapseAll();
} else {
this.expandAll();
}
},
// Persisted auto-collapse mode:
// - 'collapse': auto-collapse existing + new code blocks
// - 'off': do nothing automatically
setAutoMode(mode) {
log(`Setting auto mode: ${mode}`, 'info');
settings.autoMode = mode;
saveSettings();
this.applyAutoMode();
updateToolbarButton();
},
// Apply the current auto-mode to all currently tracked blocks.
applyAutoMode() {
if (settings.autoMode === 'collapse') {
this.collapseAll();
}
},
// Apply auto-mode to a single newly added block at setup time.
applyAutoModeToBlock(state) {
if (settings.autoMode === 'collapse' && !state.collapsed) {
collapseBlock(state);
}
}
};
// Expose globally (useful for debugging/automation).
window.CodeBlockCollapse = CodeBlockCollapse;
// ═══════════════════════════════════════════════════════════════════════
// FOOTER POSITIONING
// ═══════════════════════════════════════════════════════════════════════
// The footer bar needs special handling:
// - When code block's bottom is visible in viewport → footer at natural position (end of block)
// - When code block's bottom is BELOW viewport → footer fixed at viewport bottom
// This ensures the collapse button is always accessible while never covering code.
// Global fixed footer (single element) that is shown for the "active" code block.
// This avoids flicker/partial overlap because:
// - the in-flow footer can be hidden (visibility) without changing layout
// - the fixed footer is a separate DOM node (not fighting with in-flow geometry)
let globalFixedFooter = null;
let activeFixedState = null;
function getGlobalFixedFooter() {
if (globalFixedFooter && document.body.contains(globalFixedFooter)) {
return globalFixedFooter;
}
const el = document.createElement('div');
el.className = 'cbc-footer cbc-footer-fixed cbc-footer-hidden';
el.setAttribute('role', 'button');
el.setAttribute('tabindex', '0');
el.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="18 15 12 9 6 15"></polyline>
</svg>
<span>Collapse</span>
`;
el.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (activeFixedState && !activeFixedState.collapsed) {
collapseBlock(activeFixedState);
}
});
el.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (activeFixedState && !activeFixedState.collapsed) {
collapseBlock(activeFixedState);
}
}
});
document.body.appendChild(el);
globalFixedFooter = el;
return el;
}
function showGlobalFixedFooterForState(state, blockRect, scrollBounds) {
const fixed = getGlobalFixedFooter();
// Restore previous state's in-flow footer visibility
if (activeFixedState && activeFixedState !== state && activeFixedState.footer && !activeFixedState.collapsed) {
activeFixedState.footer.classList.remove('cbc-footer-hidden-by-fixed');
}
activeFixedState = state;
// Hide in-flow footer without changing layout/height
if (state.footer) {
state.footer.classList.add('cbc-footer-hidden-by-fixed');
}
fixed.classList.remove('cbc-footer-hidden');
fixed.style.left = `${blockRect.left}px`;
fixed.style.width = `${blockRect.width}px`;
fixed.style.bottom = `${window.innerHeight - scrollBounds.bottom}px`;
}
function hideGlobalFixedFooter() {
const fixed = getGlobalFixedFooter();
fixed.classList.add('cbc-footer-hidden');
fixed.style.left = '';
fixed.style.width = '';
fixed.style.bottom = '';
if (activeFixedState && activeFixedState.footer && !activeFixedState.collapsed) {
activeFixedState.footer.classList.remove('cbc-footer-hidden-by-fixed');
}
activeFixedState = null;
}
function updateFixedFooter() {
// Early exit: don't create/manipulate fixed footer if no code blocks exist
if (blockState.size === 0) {
if (globalFixedFooter && document.body.contains(globalFixedFooter)) hideGlobalFixedFooter();
return;
}
const fixed = getGlobalFixedFooter();
const scrollBounds = getScrollBounds();
// Height can be 0 if the fixed footer is hidden via display:none.
// Fall back to any in-flow footer height (those are always measurable even if visibility:hidden).
let footerHeight = fixed.offsetHeight || 0;
if (!footerHeight) {
for (const s of blockState.values()) {
if (s && s.footer && document.body.contains(s.footer)) {
footerHeight = Math.max(footerHeight, s.footer.offsetHeight || 0);
}
}
}
footerHeight = footerHeight || 32;
const collisionPadding = 1; // hide fixed slightly BEFORE it would touch the header
const fixedTop = scrollBounds.bottom - footerHeight;
let lowestTouchingState = null;
let lowestTouchingRect = null;
let bestScore = -Infinity;
for (const state of blockState.values()) {
if (!state || state.collapsed) continue;
if (!state.block || !document.body.contains(state.block)) continue;
const rect = state.block.getBoundingClientRect();
// Not visible at all in the scroll viewport
if (rect.bottom < scrollBounds.top || rect.top > scrollBounds.bottom) {
if (state.footer && state !== activeFixedState) {
state.footer.classList.remove('cbc-footer-hidden-by-fixed');
}
continue;
}
// Only consider fixed-mode when the block bottom touches/enters the input area zone
if (rect.bottom < scrollBounds.bottom) {
if (state.footer && state !== activeFixedState) {
state.footer.classList.remove('cbc-footer-hidden-by-fixed');
}
continue;
}
// Candidate: pick the lowest-on-screen touching block (closest to input)
const score = rect.top;
if (score > bestScore) {
bestScore = score;
lowestTouchingState = state;
lowestTouchingRect = rect;
}
}
if (!lowestTouchingState) {
hideGlobalFixedFooter();
return;
}
// If the fixed footer would touch/cover THIS block's header, hide fixed entirely.
// (Do not "jump" to another block's fixed footer; that feels wrong during auto-scroll.)
const headerRect = lowestTouchingState.header.getBoundingClientRect();
if (headerRect.bottom >= fixedTop - collisionPadding) {
hideGlobalFixedFooter();
return;
}
showGlobalFixedFooterForState(lowestTouchingState, lowestTouchingRect, scrollBounds);
}
// ═══════════════════════════════════════════════════════════════════════
// TOOLBAR BUTTON (Dual Collapse/Expand with Hold-to-Activate)
// ═══════════════════════════════════════════════════════════════════════
let toolbarContainer = null;
let holdTooltip = null;
let holdTimer = null;
let tooltipTimer = null;
let confirmTimer = null;
// Creates or returns the hold tooltip element.
function getHoldTooltip() {
if (holdTooltip && document.body.contains(holdTooltip)) {
return holdTooltip;
}
holdTooltip = document.createElement('div');
holdTooltip.className = 'cbc-hold-tooltip';
document.body.appendChild(holdTooltip);
return holdTooltip;
}
// Shows tooltip above the button with specified message.
function showHoldTooltip(btn, message, isConfirmation = false, isDeactivation = false) {
const tooltip = getHoldTooltip();
tooltip.textContent = message;
tooltip.classList.remove('confirmed', 'deactivated');
if (isConfirmation) {
tooltip.classList.add('confirmed');
} else if (isDeactivation) {
tooltip.classList.add('deactivated');
}
// Position above the button
const rect = btn.getBoundingClientRect();
tooltip.style.left = `${rect.left + rect.width / 2}px`;
tooltip.style.transform = 'translateX(-50%)';
// Need to measure tooltip height, so render it (in layout) but hidden.
// IMPORTANT: clear inline styles after measuring, otherwise they override `.visible`.
tooltip.classList.remove('visible');
tooltip.style.display = 'block';
tooltip.style.visibility = 'hidden';
tooltip.style.opacity = '0';
const tooltipHeight = tooltip.offsetHeight;
tooltip.style.top = `${rect.top - tooltipHeight - 8}px`;
// Hand control back to CSS so `.visible` can actually show it
tooltip.style.visibility = '';
tooltip.style.opacity = '';
tooltip.style.display = '';
// Trigger transition
tooltip.offsetHeight; // Force reflow
tooltip.classList.add('visible');
}
// Hides the tooltip.
function hideHoldTooltip() {
if (holdTooltip) {
holdTooltip.classList.remove('visible');
}
}
// Clears all timers related to hold gesture.
function clearHoldTimers() {
if (tooltipTimer) {
clearTimeout(tooltipTimer);
tooltipTimer = null;
}
if (holdTimer) {
clearTimeout(holdTimer);
holdTimer = null;
}
if (confirmTimer) {
clearTimeout(confirmTimer);
confirmTimer = null;
}
}
// Updates toolbar button visuals based on current autoMode.
function updateToolbarButton() {
if (!toolbarContainer) return;
if (settings.autoMode === 'collapse') {
toolbarContainer.classList.add('auto-active');
} else {
toolbarContainer.classList.remove('auto-active');
}
}
// Creates the dual toolbar button (collapse | expand).
// Hold collapse button for 1s to toggle auto-collapse mode.
function createToolbarButton() {
if (document.getElementById('cbc-toolbar-toggle')) return false;
const form = document.querySelector('form');
if (!form) return false;
const submitBtn = form.querySelector('button[type="submit"]');
if (!submitBtn) return false;
const targetContainer = submitBtn.closest('.flex.items-center.gap-2');
if (!targetContainer) return false;
log('Creating dual toolbar button', 'info');
toolbarContainer = document.createElement('div');
toolbarContainer.id = 'cbc-toolbar-toggle';
toolbarContainer.className = 'cbc-dual-btn';
// No title attributes - we use custom tooltips instead
// Collapse icon: fold style (two chevrons pointing inward)
// Expand icon: unfold style (two chevrons pointing outward)
toolbarContainer.innerHTML = `
<button class="cbc-collapse-btn" type="button" aria-label="Collapse code blocks (hold to toggle auto-collapse)">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="7 4 12 9 17 4"></polyline>
<polyline points="7 20 12 15 17 20"></polyline>
</svg>
</button>
<button class="cbc-expand-btn" type="button" aria-label="Expand code blocks">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="7 8 12 3 17 8"></polyline>
<polyline points="7 16 12 21 17 16"></polyline>
</svg>
</button>
`;
const collapseBtn = toolbarContainer.querySelector('.cbc-collapse-btn');
const expandBtn = toolbarContainer.querySelector('.cbc-expand-btn');
// Track if hold gesture completed (to prevent click action)
let holdCompleted = false;
// Collapse button: click = collapse all, hold 1s = toggle auto-collapse
collapseBtn.addEventListener('mousedown', (e) => {
if (e.button !== 0) return; // Left click only
e.preventDefault();
holdCompleted = false;
const isCurrentlyActive = settings.autoMode === 'collapse';
// Show hint tooltip and start fill animation after 500ms
tooltipTimer = setTimeout(() => {
const hintMessage = isCurrentlyActive
? 'Keep holding to disable auto-collapse for code blocks...'
: 'Keep holding to enable auto-collapse for code blocks...';
showHoldTooltip(collapseBtn, hintMessage);
// Start fill animation (500ms duration, completes at 1000ms total)
collapseBtn.classList.remove('filling-up', 'filling-down');
collapseBtn.classList.add(isCurrentlyActive ? 'filling-down' : 'filling-up');
}, 500);
// Toggle auto-collapse after 1000ms
holdTimer = setTimeout(() => {
holdCompleted = true;
clearHoldTimers();
const newMode = isCurrentlyActive ? 'off' : 'collapse';
const confirmMessage = newMode === 'collapse'
? '✓ Auto-collapse code blocks ON'
: '✓ Auto-collapse code blocks OFF';
log(`Hold completed: setting autoMode to ${newMode}`, 'success');
CodeBlockCollapse.setAutoMode(newMode);
// Show confirmation briefly
showHoldTooltip(collapseBtn, confirmMessage, newMode === 'collapse', newMode === 'off');
confirmTimer = setTimeout(() => {
hideHoldTooltip();
}, 1200);
}, 1000);
});
collapseBtn.addEventListener('mouseup', (e) => {
if (e.button !== 0) return;
clearHoldTimers();
hideHoldTooltip();
collapseBtn.classList.remove('filling-up', 'filling-down');
// Only collapse if hold didn't complete
if (!holdCompleted) {
log('Collapse button clicked (no hold)', 'info');
CodeBlockCollapse.collapseAll();
}
holdCompleted = false;
});
collapseBtn.addEventListener('mouseleave', () => {
clearHoldTimers();
hideHoldTooltip();
collapseBtn.classList.remove('filling-up', 'filling-down');
holdCompleted = false;
});
// Hover tooltips for collapse button
let hoverTooltipTimer = null;
collapseBtn.addEventListener('mouseenter', () => {
// Small delay to avoid flickering
hoverTooltipTimer = setTimeout(() => {
if (holdTimer) return; // Don't show hover tooltip during hold gesture
const msg = settings.autoMode === 'collapse'
? 'Collapse code blocks (hold to disable auto)'
: 'Collapse code blocks (hold to enable auto)';
showHoldTooltip(collapseBtn, msg);
}, 100);
});
const clearCollapseHover = () => {
if (hoverTooltipTimer) {
clearTimeout(hoverTooltipTimer);
hoverTooltipTimer = null;
}
};
// Update the existing mouseleave to also clear hover timer
collapseBtn.addEventListener('mouseleave', () => {
clearCollapseHover();
if (!holdCompleted) {
hideHoldTooltip();
}
});
// Hide tooltip when starting to hold
collapseBtn.addEventListener('mousedown', () => {
clearCollapseHover();
});
// Prevent context menu on collapse button
collapseBtn.addEventListener('contextmenu', (e) => {
e.preventDefault();
});
// Expand button: simple click
expandBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
log('Expand button clicked', 'info');
CodeBlockCollapse.expandAll();
});
// Hover tooltip for expand button
let expandHoverTimer = null;
expandBtn.addEventListener('mouseenter', () => {
expandHoverTimer = setTimeout(() => {
showHoldTooltip(expandBtn, 'Expand code blocks');
}, 100);
});
expandBtn.addEventListener('mouseleave', () => {
if (expandHoverTimer) {
clearTimeout(expandHoverTimer);
expandHoverTimer = null;
}
hideHoldTooltip();
});
// Prevent context menu on expand button
expandBtn.addEventListener('contextmenu', (e) => {
e.preventDefault();
});
// Insert at the beginning of the container
targetContainer.insertBefore(toolbarContainer, targetContainer.firstChild);
updateToolbarButton();
log('Dual toolbar button created', 'success');
return true;
}
// ═══════════════════════════════════════════════════════════════════════
// CODE BLOCK SETUP
// ═══════════════════════════════════════════════════════════════════════
// Initializes a single code block if it hasn’t been processed.
// Adds:
// - header toggle behavior
// - state hint element
// - per-block footer bar (plus global fixed footer when needed)
function setupCodeBlock(block) {
if (blockState.has(block)) return;
// LMArena code blocks are wrapped with [data-code-block="true"].
// We position relative to the code container and use the header bar for toggling.
const container = block.querySelector('.code-block_container__lbMX4') ||
block.querySelector('pre:last-child');
const header = block.querySelector('.border-b');
if (!container || !header) return;
log('Setting up code block', 'info');
container.classList.add('cbc-container');
header.classList.add('cbc-header-interactive');
// Header hint (changes text depending on collapsed state via CSS).
const hint = document.createElement('span');
hint.className = 'cbc-state-hint';
hint.innerHTML = `
<span class="hint-collapse">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="18 15 12 9 6 15"></polyline>
</svg>
<span>Click to collapse code</span>
</span>
<span class="hint-expand">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
<span>Click to expand code</span>
</span>
`;
header.appendChild(hint);
// Create sticky footer bar for collapse action
const footer = document.createElement('div');
footer.className = 'cbc-footer';
footer.setAttribute('role', 'button');
footer.setAttribute('tabindex', '0');
footer.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="18 15 12 9 6 15"></polyline>
</svg>
<span>Collapse</span>
`;
block.appendChild(footer);
// Per-block state record used by toggle logic.
const state = {
block,
container,
header,
footer,
collapsed: false
};
blockState.set(block, state);
// Footer bar click: collapse the code block
footer.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
collapseBlock(state);
});
// Keyboard accessibility for footer: Enter/Space collapses.
footer.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
collapseBlock(state);
}
});
// Header behavior: toggles both directions.
// Important: ignore clicks on native buttons inside header (copy button, etc.).
header.addEventListener('click', (e) => {
if (e.target.closest('button')) return;
e.preventDefault();
e.stopPropagation();
toggleBlock(state);
});
// Keyboard accessibility: Enter/Space toggles.
header.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggleBlock(state);
}
});
// Make header focusable for keyboard use.
header.setAttribute('tabindex', '0');
header.setAttribute('role', 'button');
// Apply persistent mode to newly created blocks.
CodeBlockCollapse.applyAutoModeToBlock(state);
// Initial fixed-footer evaluation
requestAnimationFrame(() => {
updateFixedFooter();
});
}
// Full scan:
// - Used on initial load.
// - Also used by the safety resync.
function processAll() {
document.querySelectorAll('[data-code-block="true"]').forEach(setupCodeBlock);
createToolbarButton();
}
// Removes entries for blocks that disappeared from DOM and updates footer positions.
function updateAll() {
for (const [el, state] of blockState) {
if (!document.body.contains(el)) {
// Code block removed due to chat switch / rerender; clean up.
if (state.footer && state.footer.parentNode) {
state.footer.remove();
}
blockState.delete(el);
}
}
// Update global fixed footer state/position
updateFixedFooter();
}
// ═══════════════════════════════════════════════════════════════════════
// INCREMENTAL MUTATION OBSERVER (v1.3)
// ═══════════════════════════════════════════════════════════════════════
// Efficient approach:
// - Do NOT scan the entire document on every mutation.
// - Only inspect added nodes and their descendants for code blocks.
const observer = new MutationObserver((mutations) => {
let newBlocksFound = 0;
let toolbarChecked = false;
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
// If the added node itself is a code block.
if (node.matches?.('[data-code-block="true"]')) {
setupCodeBlock(node);
newBlocksFound++;
}
// Otherwise scan inside it for code blocks.
if (node.querySelectorAll) {
const blocks = node.querySelectorAll('[data-code-block="true"]');
blocks.forEach(block => {
setupCodeBlock(block);
newBlocksFound++;
});
// The input area (and thus toolbar row) can be re-rendered; re-create if needed.
if (!toolbarChecked && (node.matches?.('form') || node.querySelector?.('form'))) {
createToolbarButton();
toolbarChecked = true;
}
}
}
}
if (newBlocksFound > 0) {
log(`Mutation: processed ${newBlocksFound} new code block(s)`, 'success');
}
// Always update positions and cleanup (cheap relative to full rescan).
updateAll();
});
// ═══════════════════════════════════════════════════════════════════════
// INITIALIZATION (Dynamic Stabilization Observer)
// ═══════════════════════════════════════════════════════════════════════
const start = () => {
log('Initializing...', 'info');
loadSettings();
injectStyles();
// Initial full scan.
processAll();
updateAll();
// Apply persisted mode immediately on load.
CodeBlockCollapse.applyAutoMode();
// Start observers and listeners only after initial setup
observer.observe(document.body, { childList: true, subtree: true });
setInterval(() => {
const currentCount = blockState.size;
const totalOnPage = document.querySelectorAll('[data-code-block="true"]').length;
if (totalOnPage !== currentCount) {
processAll();
updateAll();
}
}, CONFIG.resyncInterval);
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 });
log(`Initialized. Auto-collapse: ${settings.autoMode === 'collapse' ? 'ON' : 'OFF'}`, 'success');
};
/**
* React Stabilization Watcher:
* Instead of a hardcoded delay, we wait for the main chat form to appear.
* This signals that React hydration is complete and it is safe to touch the DOM.
*/
const initWhenReady = () => {
// Selector for the main chat input - presence implies hydration is finished
const READY_SELECTOR = 'form textarea';
if (document.querySelector(READY_SELECTOR)) {
// Already ready, use idle callback for safety
(window.requestIdleCallback || setTimeout)(start, { timeout: 1000 });
return;
}
// Otherwise, observe until the form appears
const readyObserver = new MutationObserver(() => {
if (document.querySelector(READY_SELECTOR)) {
readyObserver.disconnect();
// Give React a small 200ms "settle" window after the element appears
setTimeout(() => {
(window.requestIdleCallback || setTimeout)(start, { timeout: 1000 });
}, 200);
}
});
readyObserver.observe(document.body, { childList: true, subtree: true });
};
initWhenReady();
})();