您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Minimal, fast, layout-only minimap in a Shadow DOM that follows hovered scrollable areas. M toggles map. 1 jumps to top (0%), 0 jumps to end (100%). Click/drag to scroll. Hydration-safe for React/Next sites.
// ==UserScript== // @name VSCode Like Browser Minimap — Shadow DOM, Multi-Scroll, Hydration-Safe // @namespace http://tampermonkey.net/ // @version 2.4 // @description Minimal, fast, layout-only minimap in a Shadow DOM that follows hovered scrollable areas. M toggles map. 1 jumps to top (0%), 0 jumps to end (100%). Click/drag to scroll. Hydration-safe for React/Next sites. // @author Your Name // @match *://*/* // @run-at document-idle // @grant none // @license MIT // ==/UserScript== (function () { 'use strict'; // --------------------------- // Config // --------------------------- const MM_WIDTH = 120; const MIN_VP_PX = 18; const MAX_PER_RENDER = 5000; const HOVER_THROTTLE_MS = 200; // User shortcuts/config // User config const START_HIDDEN = true; // start minimap hidden after refresh const TOGGLE_KEY = 'm'; // toggle key (case-insensitive) const TOGGLE_MOD = { // require a modifier? set to true ctrl: false, alt: false, shift: false, meta: false }; // Exclude websites (any match will disable the script) const EXCLUDE_HOSTS = ['www.youtube.com', 'twitter.com', 'www.facebook.com', 'www.reddit.com']; const EXCLUDE_PATTERNS = [ // /^https?:\/\/(\w+\.)?google\./i, // any google.* domain // /:\/\/localhost:\d+\//, ]; // Helper: returns true if current page should be excluded function isExcluded(loc) { const href = typeof loc === 'string' ? loc : loc.href; let host; try { host = typeof loc === 'string' ? new URL(href).hostname : loc.hostname; } catch { host = location.hostname; } if (EXCLUDE_HOSTS.includes(host)) return true; for (const re of EXCLUDE_PATTERNS) { try { if (re.test(href) || re.test(host)) return true; } catch { } } return false; } const SELECTORS = [ 'main', 'article', 'section', 'nav', 'aside', 'header', 'footer', 'div', 'p', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'pre', 'code', 'blockquote', 'figure', 'figcaption', 'table', 'thead', 'tbody', 'tr', 'td', 'th', 'a', 'span', 'img', 'canvas', 'video', 'iframe' ].join(','); // requestIdleCallback polyfill (for older browsers) const ric = window.requestIdleCallback ? window.requestIdleCallback.bind(window) : (cb, opts) => setTimeout(() => cb({ didTimeout: true, timeRemaining: () => 0 }), (opts && opts.timeout) || 1); // --------------------------- // State // --------------------------- let initialized = false; let activeEl = null; // current scroll container let dragging = false; let dragStartY = null; let dpr = Math.max(1, Math.min(3, window.devicePixelRatio || 1)); let scheduled = false; let scrollDebounce = null; let mutationDebounce = null; // element cache (per container) let cachedElements = null; let cacheInvalidated = true; // DOM refs let host, shadow, mm, canvas, ctx, viewport; // --------------------------- // Shadow CSS (scoped) // --------------------------- const CSS = ` :host { position: fixed; top: 0; right: 0; width: ${MM_WIDTH}px; height: 100vh; z-index: 2147483647; transform: none !important; contain: layout style paint; display: block; pointer-events: auto; } :host(.hidden) { display: none !important; pointer-events: none !important; } #mm { position: relative; width: 100%; height: 100%; box-sizing: border-box; pointer-events: auto; cursor: crosshair; background: rgba(15, 15, 20, 0.45); border-left: 1px solid rgba(255,255,255,0.06); box-shadow: -2px 0 12px rgba(0,0,0,0.35); backdrop-filter: blur(6px) saturate(1.15); -webkit-backdrop-filter: blur(6px) saturate(1.15); } #canvas { position: absolute; inset: 0; width: 100%; height: 100%; image-rendering: crisp-edges; } #viewport { position: absolute; left: 0; width: 100%; pointer-events: none; box-sizing: border-box; background: rgba(255, 255, 255, 0.10); border: none; outline: none; } #mm::before { content: ""; position: absolute; inset: 0; background-image: linear-gradient(rgba(255,255,255,0.04) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px); background-size: 100% 8px, 8px 100%; pointer-events: none; } `; // --------------------------- // Init (Hydration-safe) // --------------------------- function startWhenSafe() { const kick = () => { if (initialized) return; if (!document.body) return setTimeout(kick, 200); if (document.readyState !== 'complete') { window.addEventListener('load', () => ric(init, { timeout: 2000 }), { once: true }); return; } ric(init, { timeout: 2000 }); }; kick(); } function init() { if (initialized) return; if (!document.body) return setTimeout(init, 200); initialized = true; createShadowMinimap(); // Start with main scrolling element const rootScroll = document.scrollingElement || document.documentElement; setActive(rootScroll); attachGlobalListeners(); sizeCanvas(); scheduleRender(); } // --------------------------- // Shadow DOM component // --------------------------- function createShadowMinimap() { host = document.createElement('mm-vscode-minimap'); shadow = host.attachShadow({ mode: 'open' }); const style = document.createElement('style'); style.textContent = CSS; mm = document.createElement('div'); mm.id = 'mm'; if (START_HIDDEN) { host.classList.add('hidden'); // fully disable overlay area } canvas = document.createElement('canvas'); canvas.id = 'canvas'; viewport = document.createElement('div'); viewport.id = 'viewport'; mm.appendChild(canvas); mm.appendChild(viewport); shadow.appendChild(style); shadow.appendChild(mm); document.body.appendChild(host); ctx = canvas.getContext('2d', { alpha: false }); mm.addEventListener('mousedown', (e) => { if (e.button !== 0) return; e.stopPropagation(); e.stopImmediatePropagation(); dragging = true; dragStartY = e.clientY; e.preventDefault(); handlePointer(e.clientY); }); window.addEventListener('mousemove', (e) => { if (!dragging) return; e.stopPropagation(); handlePointer(e.clientY); }, { passive: true, capture: true }); window.addEventListener('mouseup', (e) => { if (!dragging) return; e.stopPropagation(); dragging = false; }, { passive: true, capture: true }); mm.addEventListener('click', (e) => { e.stopPropagation(); if (dragStartY !== null && Math.abs(e.clientY - dragStartY) < 3) { handlePointer(e.clientY); } dragStartY = null; }); mm.addEventListener('wheel', (e) => { e.stopPropagation(); e.preventDefault(); const { scrollHeight, viewportHeight } = getMetrics(); const delta = Math.sign(e.deltaY) * Math.max(24, viewportHeight * 0.15); scrollToY(clamp(getMetrics().scrollTop + delta, 0, Math.max(0, scrollHeight - viewportHeight))); }, { passive: false }); } function isEventInsideMinimap(e) { const path = (typeof e.composedPath === 'function') ? e.composedPath() : []; return path.includes(host) || (mm && path.includes(mm)); } function sizeCanvas() { const cssW = MM_WIDTH; const cssH = host.clientHeight || window.innerHeight || 0; canvas.style.width = cssW + 'px'; canvas.style.height = cssH + 'px'; canvas.width = Math.max(1, Math.floor(cssW * dpr)); canvas.height = Math.max(1, Math.floor(cssH * dpr)); if (ctx && ctx.setTransform) ctx.setTransform(dpr, 0, 0, dpr, 0, 0); } // --------------------------- // Active Scroll Container // --------------------------- function setActive(el) { const rootScroll = document.scrollingElement || document.documentElement; const newActive = el || rootScroll; if (newActive === activeEl) return; // Ignore our own UI (host or descendants), but allow ancestors like <body>/<html> if (host && (newActive === host || (host.contains && host.contains(newActive)))) return; // Only activate visible, scrollable containers (unless it's the document scrolling element) if (newActive !== rootScroll && !isVisibleContainer(newActive)) { return; } const prev = activeEl; activeEl = newActive; if (prev) detachContainerListeners(prev); attachContainerListeners(); cacheInvalidated = true; sizeCanvas(); scheduleRender(); } function isVisibleContainer(node) { if (!node || node.nodeType !== 1) return false; const cs = getComputedStyle(node); if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false; const r = node.getBoundingClientRect(); if (r.width <= 0 || r.height <= 0) return false; return isScrollable(node); } function isScrollable(node) { if (!node || node.nodeType !== 1) return false; const cs = getComputedStyle(node); if (cs.display === 'none') return false; const overflowY = cs.overflowY; const overflowX = cs.overflowX; const overflow = cs.overflow; const canScrollY = /(auto|scroll)/.test(overflowY) || /(auto|scroll)/.test(overflow); const canScrollX = /(auto|scroll)/.test(overflowX) || /(auto|scroll)/.test(overflow); if (!canScrollY && !canScrollX) return false; const hasScrollableY = node.scrollHeight > Math.ceil(node.clientHeight); const hasScrollableX = node.scrollWidth > Math.ceil(node.clientWidth); return (canScrollY && hasScrollableY) || (canScrollX && hasScrollableX); } function findScrollableParent(el) { if (!el) return document.scrollingElement || document.documentElement; let cur = el; while (cur && cur !== document.documentElement) { if (cur === document.body) return document.scrollingElement || document.documentElement; if (cur === host) return activeEl || (document.scrollingElement || document.documentElement); if (isScrollable(cur)) return cur; cur = cur.parentElement; } return document.scrollingElement || document.documentElement; } // --------------------------- // Metrics // --------------------------- function getMetrics() { const scrollingRoot = document.scrollingElement || document.documentElement; // Fallback if current active el is missing or detached if (!activeEl || !activeEl.isConnected) activeEl = scrollingRoot; const isMain = activeEl === scrollingRoot; let scrollTop, scrollHeight, viewportHeight, containerRect, containerWidth; if (isMain) { scrollTop = scrollingRoot.scrollTop || window.pageYOffset || 0; scrollHeight = scrollingRoot.scrollHeight || (document.body && document.body.scrollHeight) || 0; viewportHeight = window.innerHeight || scrollingRoot.clientHeight || 0; containerRect = { top: 0, left: 0 }; containerWidth = window.innerWidth || scrollingRoot.clientWidth || 1; } else { scrollTop = activeEl.scrollTop; scrollHeight = activeEl.scrollHeight; viewportHeight = activeEl.clientHeight; containerRect = activeEl.getBoundingClientRect(); containerWidth = activeEl.clientWidth || 1; } return { isMain, scrollTop, scrollHeight, viewportHeight, containerRect, containerWidth }; } // --------------------------- // Render Scheduling // --------------------------- function withRAF(fn) { if (scheduled) return; scheduled = true; requestAnimationFrame(() => { try { fn(); } catch (err) { console.error('[Minimap]', err); } finally { scheduled = false; } }); } function scheduleRender() { withRAF(render); } function scheduleRenderLight() { clearTimeout(scrollDebounce); scrollDebounce = setTimeout(() => withRAF(render), 120); } // --------------------------- // Rendering (Layout-Only) // --------------------------- function render() { if (!ctx) return; const cssW = canvas.clientWidth || 0; const cssH = canvas.clientHeight || 0; if (cssW === 0 || cssH === 0) return; // Clear ctx.fillStyle = 'rgba(18,18,22,0.9)'; ctx.fillRect(0, 0, cssW, cssH); const { isMain, scrollHeight, containerRect, containerWidth } = getMetrics(); const scaleY = cssH / Math.max(1, scrollHeight); const root = isMain ? (document.body || document.documentElement) : activeEl; if (!root || !root.querySelectorAll) { updateViewport(); return; } // Cache elements per container if (cacheInvalidated) { try { cachedElements = Array.from(root.querySelectorAll(SELECTORS)) .filter(el => isVisibleElement(el)); } catch { cachedElements = []; } cacheInvalidated = false; } let elements = cachedElements || []; if (elements.length > MAX_PER_RENDER) { const step = Math.ceil(elements.length / MAX_PER_RENDER); elements = elements.filter((_, i) => i % step === 0); } const cw = Math.max(1, containerWidth); for (const el of elements) { const rect = el.getBoundingClientRect(); let top, left, height, width; if (isMain) { const scrollingRoot = document.scrollingElement || document.documentElement; top = rect.top + (scrollingRoot.scrollTop || window.pageYOffset || 0); left = rect.left; } else { top = (rect.top - containerRect.top) + activeEl.scrollTop; left = (rect.left - containerRect.left) + activeEl.scrollLeft; } height = rect.height; width = rect.width; if (height <= 0 || width <= 8) continue; const x = Math.max(0, Math.min(cssW, (left / cw) * cssW)); const w = Math.max(1, Math.min(cssW - x, (width / cw) * cssW)); const y = Math.max(0, top * scaleY); const h = Math.max(1, height * scaleY); ctx.fillStyle = colorFor(el.tagName.toLowerCase()); ctx.fillRect(x, y, w, h); } updateViewport(); } function isVisibleElement(el) { if (!el || el.nodeType !== 1) return false; const t = el.tagName; if (!t || t === 'SCRIPT' || t === 'STYLE' || t === 'LINK' || t === 'META' || t === 'NOSCRIPT') return false; const cs = getComputedStyle(el); if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false; // Exclude fixed elements (not part of scroll layout mapping) if (cs.position === 'fixed') return false; const r = el.getBoundingClientRect(); if (r.width <= 0 || r.height <= 0) return false; return true; } function colorFor(tag) { switch (tag) { case 'h1': return '#5BC0EB'; case 'h2': return '#4FA8E0'; case 'h3': return '#3F95D0'; case 'h4': return '#2E86C1'; case 'h5': return '#2278B6'; case 'h6': return '#1B6AA7'; case 'a': return '#4EC9B0'; case 'code': case 'pre': return '#CE9178'; case 'blockquote': return '#7CB342'; case 'img': return '#C792EA'; case 'table': return '#A5D6A7'; case 'ul': case 'ol': case 'li': return '#B2CCD6'; case 'section': case 'article': case 'main': case 'aside': case 'nav': case 'header': case 'footer': return '#9CDCFE'; case 'div': return '#8A8A8A'; case 'span': return '#6E6E6E'; case 'iframe': return '#F78C6C'; default: return '#7A7A7A'; } } // --------------------------- // Viewport box // --------------------------- function updateViewport() { if (!viewport) return; const { scrollTop, scrollHeight, viewportHeight } = getMetrics(); const canvasH = canvas.clientHeight || 0; if (!canvasH || !scrollHeight) { viewport.style.top = '0px'; viewport.style.height = MIN_VP_PX + 'px'; return; } const scaleY = canvasH / Math.max(1, scrollHeight); const vpTop = Math.max(0, Math.floor(scrollTop * scaleY)); const vpH = Math.max(MIN_VP_PX, Math.floor(viewportHeight * scaleY)); viewport.style.top = vpTop + 'px'; viewport.style.height = vpH + 'px'; } // --------------------------- // Pointer -> Scroll // --------------------------- function handlePointer(clientY) { const rect = canvas.getBoundingClientRect(); const h = Math.max(1, canvas.clientHeight || (rect.bottom - rect.top)); const y = clamp(clientY - rect.top, 0, h); const { scrollHeight, viewportHeight } = getMetrics(); if (!scrollHeight) return; const target = (y / h) * scrollHeight - (viewportHeight / 2); scrollToY(clamp(target, 0, Math.max(0, scrollHeight - viewportHeight))); } function scrollToY(y) { const scrollingRoot = document.scrollingElement || document.documentElement; const el = (!activeEl || !activeEl.isConnected) ? scrollingRoot : activeEl; if (el === scrollingRoot) { scrollingRoot.scrollTop = y; } else { el.scrollTop = y; } } function jumpToPercent(p) { const { scrollHeight, viewportHeight } = getMetrics(); const max = Math.max(0, scrollHeight - viewportHeight); scrollToY(p * max); } // --------------------------- // Helpers // --------------------------- function clamp(v, min, max) { return v < min ? min : (v > max ? max : v); } function attachGlobalListeners() { let lastHoverCheck = 0; // Hover to switch active scroll container (throttled) document.addEventListener('mouseover', (e) => { if (isEventInsideMinimap(e)) return; const now = Date.now(); if (now - lastHoverCheck < HOVER_THROTTLE_MS) return; lastHoverCheck = now; const el = findScrollableParent(e.target); if (el !== activeEl) setActive(el); }, true); // Wheel inside nested scrollables also switches document.addEventListener('wheel', (e) => { if (isEventInsideMinimap(e)) return; const el = findScrollableParent(e.target); if (el !== activeEl) setActive(el); }, { passive: true }); // Keyboard document.addEventListener('keydown', (e) => { if (isEventInsideMinimap(e)) { // Allow toggles even if focus is elsewhere } const tag = e.target && e.target.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || (e.target && e.target.isContentEditable)) return; const key = (e.key || '').toLowerCase(); const wants = TOGGLE_KEY.toLowerCase(); // Toggle minimap using configurable shortcut if ( key === wants && (!TOGGLE_MOD.ctrl || e.ctrlKey) && (!TOGGLE_MOD.alt || e.altKey) && (!TOGGLE_MOD.shift || e.shiftKey) && (!TOGGLE_MOD.meta || e.metaKey) ) { e.preventDefault(); host.classList.toggle('hidden'); if (!host.classList.contains('hidden')) { // When showing, trigger a render cacheInvalidated = true; scheduleRender(); } return; } // Keep number jumps behavior (optional to keep/change) if (key >= '0' && key <= '9') { e.preventDefault(); if (key === '1') { jumpToPercent(0); return; } if (key === '0') { jumpToPercent(1); return; } jumpToPercent(parseInt(key, 10) / 10); } }); // Window resize window.addEventListener('resize', () => { dpr = Math.max(1, Math.min(3, window.devicePixelRatio || 1)); sizeCanvas(); cacheInvalidated = true; scheduleRender(); }, { passive: true }); } function attachContainerListeners() { if (!activeEl) return; const onScroll = () => scheduleRenderLight(); const onMut = () => { cacheInvalidated = true; clearTimeout(mutationDebounce); mutationDebounce = setTimeout(() => scheduleRender(), 200); }; // Attach scroll const scrollingRoot = document.scrollingElement || document.documentElement; if (activeEl === scrollingRoot) { const options = { passive: true, capture: true }; window.addEventListener('scroll', onScroll, options); activeEl.__mmScrollHandler = onScroll; activeEl.__mmScrollOptions = options; } else { const options = { passive: true }; activeEl.addEventListener('scroll', onScroll, options); activeEl.__mmScrollHandler = onScroll; activeEl.__mmScrollOptions = options; } // Observe size + DOM changes const observeTarget = (activeEl === scrollingRoot ? (document.body || document.documentElement) : activeEl); if (window.ResizeObserver) { const ro = new ResizeObserver(() => { cacheInvalidated = true; scheduleRender(); }); try { ro.observe(observeTarget); } catch { } activeEl.__mmResizeObserver = ro; } const mo = new MutationObserver(onMut); try { mo.observe(observeTarget, { childList: true, subtree: true, attributes: true }); } catch { } activeEl.__mmMutObserver = mo; } function detachContainerListeners(target) { const prev = target || (document.scrollingElement || document.documentElement); if (prev.__mmResizeObserver) { try { prev.__mmResizeObserver.disconnect(); } catch { } delete prev.__mmResizeObserver; } if (prev.__mmMutObserver) { try { prev.__mmMutObserver.disconnect(); } catch { } delete prev.__mmMutObserver; } if (prev.__mmScrollHandler) { const scrollingRoot = document.scrollingElement || document.documentElement; const isMain = prev === scrollingRoot; const scrollTarget = isMain ? window : prev; const options = prev.__mmScrollOptions || (isMain ? { capture: true } : undefined); try { scrollTarget.removeEventListener('scroll', prev.__mmScrollHandler, options); } catch (_) { scrollTarget.removeEventListener('scroll', prev.__mmScrollHandler); } delete prev.__mmScrollHandler; delete prev.__mmScrollOptions; } } // --------------------------- // Start // --------------------------- if (isExcluded(location)) return; startWhenSafe(); })();