(() => { 'use strict'; // ── Constants ────────────────────────────────────────────────────────────── const DEDUP_THRESHOLD = 5; // px — skip bookmark if one exists within this range const DELETE_TOLERANCE = 30; // px — snap range when deleting nearest bookmark // ── Module state ─────────────────────────────────────────────────────────── const elementStates = new WeakMap(); // Element → ElementState const trackedElements = new Set(); // Strong refs needed for iteration / cleanup let lastMouseX = 0; let lastMouseY = 0; let jumpingMode = false; let jumpingElement = null; // Element currently in jumping mode let jumpingInput = ''; // Digit string being typed for numeric jump let jumpingBannerHost = null; // Separate fixed host for the bottom-centre banner let jumpingBannerEl = null; // Inner .banner div — kept for text updates // ── Shadow DOM stylesheet ────────────────────────────────────────────────── const SHADOW_STYLES = ` :host { all: initial; display: block; } .container { position: relative; width: 100%; height: 100%; overflow: hidden; } .indicator { position: absolute; left: 1px; right: 1px; height: 4px; background: #e05c00; border-radius: 2px; opacity: 0.9; } .container.jumping .indicator { height: 14px; left: 0; right: 0; display: flex; align-items: center; justify-content: center; font: bold 9px/1 monospace; color: #fff; border-radius: 3px; opacity: 1; } `; // ── Scrollable element detection ─────────────────────────────────────────── function isScrollableEl(el) { if (!el || el === document.body) return false; if (el === document.documentElement) { return el.scrollHeight > el.clientHeight; } const oy = window.getComputedStyle(el).overflowY; return (oy === 'auto' || oy === 'scroll') && el.scrollHeight > el.clientHeight; } function findScrollableAncestor(el) { let node = el; while (node && node !== document.documentElement) { if (isScrollableEl(node)) return node; node = node.parentElement; } if (isScrollableEl(document.documentElement)) return document.documentElement; return null; } function getActiveScrollable() { const underMouse = document.elementFromPoint(lastMouseX, lastMouseY); if (underMouse) { const s = findScrollableAncestor(underMouse); if (s) return s; } const focused = document.activeElement; if (focused && focused !== document.body && focused !== document.documentElement) { const s = findScrollableAncestor(focused); if (s) return s; } if (isScrollableEl(document.documentElement)) return document.documentElement; return null; } // ── Overlay geometry ─────────────────────────────────────────────────────── // Minimum strip width — guarantees visibility even with overlay/hidden scrollbars const STRIP_W = 8; function getScrollbarGeometry(element) { if (element === document.documentElement) { const sbWidth = window.innerWidth - document.documentElement.clientWidth; const w = Math.max(sbWidth, STRIP_W); return { left: window.innerWidth - w, // never off-screen top: 0, width: w, height: window.innerHeight, }; } const rect = element.getBoundingClientRect(); const style = window.getComputedStyle(element); const borderRight = parseFloat(style.borderRightWidth) || 0; const sbWidth = element.offsetWidth - element.clientWidth - borderRight; const w = Math.max(sbWidth, STRIP_W); return { left: rect.right - borderRight - w, // never off-screen for the element top: rect.top, width: w, height: rect.height, }; } // ── Per-element state management ─────────────────────────────────────────── function getOrCreateState(element) { if (elementStates.has(element)) return elementStates.get(element); const overlayHost = document.createElement('div'); const shadowRoot = overlayHost.attachShadow({ mode: 'closed' }); const styleEl = document.createElement('style'); styleEl.textContent = SHADOW_STYLES; shadowRoot.appendChild(styleEl); const container = document.createElement('div'); container.className = 'container'; shadowRoot.appendChild(container); document.body.appendChild(overlayHost); const scrollListener = () => updateOverlayPosition(element); const resizeObserver = new ResizeObserver(() => updateOverlayPosition(element)); resizeObserver.observe(element); if (element !== document.documentElement) { element.addEventListener('scroll', scrollListener, { passive: true }); } const state = { bookmarks: [], // [{ id, scrollTop, indicatorEl }] sorted asc overlayHost, shadowRoot, container, resizeObserver, scrollListener, }; elementStates.set(element, state); trackedElements.add(element); updateOverlayPosition(element); return state; } function updateOverlayPosition(element) { if (!elementStates.has(element)) return; const state = elementStates.get(element); let { left, top, width, height } = getScrollbarGeometry(element); // In jumping mode widen the strip so numbers are legible if (jumpingMode && jumpingElement === element) { const jumpWidth = Math.max(width, 28); left -= (jumpWidth - width); width = jumpWidth; } // Apply inline style directly to avoid inheriting host-page styles const h = state.overlayHost; h.style.cssText = [ 'all: initial', 'display: block', 'position: fixed', `left: ${left}px`, `top: ${top}px`, `width: ${width}px`, `height: ${height}px`, 'pointer-events: none', 'z-index: 2147483647', 'overflow: hidden', ].join(' !important; ') + ' !important;'; for (const bm of state.bookmarks) { positionIndicator(bm.indicatorEl, bm.scrollTop, element); } } function positionIndicator(indicatorEl, scrollTop, element) { const scrollHeight = element.scrollHeight; const pct = scrollHeight > 0 ? scrollTop / scrollHeight : 0; const containerH = element === document.documentElement ? window.innerHeight : element.getBoundingClientRect().height; // Half-height differs between normal (4px) and jumping (14px) mode const HALF = (jumpingMode && jumpingElement === element) ? 7 : 2; const y = Math.max(HALF, Math.min(containerH - HALF, pct * containerH)); indicatorEl.style.top = `${y - HALF}px`; } function updateAllOverlayPositions() { for (const el of [...trackedElements]) { if (!el.isConnected) { cleanupElement(el); } else { updateOverlayPosition(el); } } } // ── Element cleanup ──────────────────────────────────────────────────────── function cleanupElement(element) { const state = elementStates.get(element); if (!state) return; state.resizeObserver.disconnect(); if (element !== document.documentElement) { element.removeEventListener('scroll', state.scrollListener); } if (state.overlayHost.parentNode) { state.overlayHost.parentNode.removeChild(state.overlayHost); } elementStates.delete(element); trackedElements.delete(element); if (element === jumpingElement) exitJumpingMode(); } // ── Bookmark operations ──────────────────────────────────────────────────── function createBookmark() { const element = getActiveScrollable(); if (!element) return; const state = getOrCreateState(element); const currentScrollTop = element.scrollTop; // Dedup: skip if a bookmark already exists within DEDUP_THRESHOLD for (const bm of state.bookmarks) { if (Math.abs(bm.scrollTop - currentScrollTop) < DEDUP_THRESHOLD) return; } const id = Math.random().toString(36).slice(2) + Date.now().toString(36); const indicatorEl = document.createElement('div'); indicatorEl.className = 'indicator'; state.container.appendChild(indicatorEl); positionIndicator(indicatorEl, currentScrollTop, element); state.bookmarks.push({ id, scrollTop: currentScrollTop, indicatorEl }); state.bookmarks.sort((a, b) => a.scrollTop - b.scrollTop); } function deleteNearestBookmark() { const element = getActiveScrollable(); if (!element) return; const state = elementStates.get(element); if (!state || state.bookmarks.length === 0) return; const current = element.scrollTop; let nearest = null; let nearestDist = Infinity; for (const bm of state.bookmarks) { const dist = Math.abs(bm.scrollTop - current); if (dist < nearestDist) { nearestDist = dist; nearest = bm; } } if (!nearest || nearestDist > DELETE_TOLERANCE) return; if (nearest.indicatorEl.parentNode) { nearest.indicatorEl.parentNode.removeChild(nearest.indicatorEl); } state.bookmarks = state.bookmarks.filter(bm => bm.id !== nearest.id); if (jumpingMode && jumpingElement === element && state.bookmarks.length < 2) { exitJumpingMode(); } } // ── Jumping mode ─────────────────────────────────────────────────────────── function activateJumping() { const element = getActiveScrollable(); if (!element) return; // Exit any existing jumping mode first if (jumpingMode) exitJumpingMode(); const state = elementStates.get(element); if (!state || state.bookmarks.length === 0) return; if (state.bookmarks.length === 1) { element.scrollTo({ top: state.bookmarks[0].scrollTop, behavior: 'smooth' }); return; } jumpingMode = true; jumpingElement = element; // Label each indicator with its 1-based index (bookmarks already sorted asc) state.bookmarks.forEach((bm, i) => { bm.indicatorEl.textContent = String(i + 1); }); state.container.classList.add('jumping'); updateOverlayPosition(element); // apply wider strip + reposition taller indicators showJumpingBanner(); } function showJumpingBanner() { if (jumpingBannerHost) return; jumpingBannerHost = document.createElement('div'); jumpingBannerHost.style.cssText = [ 'all: initial', 'display: block', 'position: fixed', 'bottom: 28px', 'left: 50%', 'transform: translateX(-50%)', 'z-index: 2147483647', 'pointer-events: none', ].join(' !important; ') + ' !important;'; const shadow = jumpingBannerHost.attachShadow({ mode: 'closed' }); const style = document.createElement('style'); style.textContent = ` .banner { background: rgba(0, 0, 0, 0.65); color: #fff; font: 13px/1 system-ui, sans-serif; padding: 15px 20px; border-radius: 8px; letter-spacing: 0.02em; white-space: nowrap; } `; shadow.appendChild(style); jumpingBannerEl = document.createElement('div'); jumpingBannerEl.className = 'banner'; shadow.appendChild(jumpingBannerEl); updateBannerText(); document.documentElement.appendChild(jumpingBannerHost); } function updateBannerText() { if (!jumpingBannerEl) return; jumpingBannerEl.textContent = jumpingInput === '' ? '\u2191\u2193\u00a0 navigate \u2022 type number + Enter \u2022 Esc exit' : 'Jump to: ' + jumpingInput + '\u2038'; } function confirmJump() { if (!jumpingInput || !jumpingElement) return; const n = parseInt(jumpingInput, 10); const state = elementStates.get(jumpingElement); if (state && n >= 1 && n <= state.bookmarks.length) { // Capture before exitJumpingMode nulls these out const el = jumpingElement; const top = state.bookmarks[n - 1].scrollTop; exitJumpingMode(); // clean up first so DOM ops don't interrupt the scroll el.scrollTo({ top, behavior: 'smooth' }); } else { // Invalid — clear input and let the user try again jumpingInput = ''; updateBannerText(); } } function exitJumpingMode() { const prevEl = jumpingElement; jumpingMode = false; jumpingElement = null; jumpingInput = ''; if (prevEl) { const state = elementStates.get(prevEl); if (state) { state.container.classList.remove('jumping'); state.bookmarks.forEach(bm => { bm.indicatorEl.textContent = ''; }); } updateOverlayPosition(prevEl); // shrink back and reposition 4px indicators } if (jumpingBannerHost && jumpingBannerHost.parentNode) { jumpingBannerHost.parentNode.removeChild(jumpingBannerHost); } jumpingBannerHost = null; jumpingBannerEl = null; } function handleJumpDown(e) { e.preventDefault(); if (!jumpingElement) return; const state = elementStates.get(jumpingElement); if (!state) return; const current = jumpingElement.scrollTop; const next = state.bookmarks.find(bm => bm.scrollTop > current + 1) || state.bookmarks[0]; jumpingElement.scrollTo({ top: next.scrollTop, behavior: 'smooth' }); } function handleJumpUp(e) { e.preventDefault(); if (!jumpingElement) return; const state = elementStates.get(jumpingElement); if (!state) return; const current = jumpingElement.scrollTop; const above = state.bookmarks.filter(bm => bm.scrollTop < current - 1); const next = above.length > 0 ? above[above.length - 1] : state.bookmarks[state.bookmarks.length - 1]; jumpingElement.scrollTo({ top: next.scrollTop, behavior: 'smooth' }); } // ── Window-level event handlers ──────────────────────────────────────────── function onMouseMove(e) { lastMouseX = e.clientX; lastMouseY = e.clientY; } function onKeyDown(e) { // Jumping mode captures arrow keys and Escape unconditionally if (jumpingMode) { if (e.key === 'ArrowDown') { handleJumpDown(e); return; } if (e.key === 'ArrowUp') { handleJumpUp(e); return; } if (e.key === 'Escape') { exitJumpingMode(); e.preventDefault(); return; } if (/^[0-9]$/.test(e.key)) { e.preventDefault(); jumpingInput += e.key; updateBannerText(); return; } if (e.key === 'Enter' || e.code === 'NumpadEnter') { e.preventDefault(); confirmJump(); return; } if (e.key === 'Backspace') { e.preventDefault(); jumpingInput = jumpingInput.slice(0, -1); updateBannerText(); return; } } if (!e.altKey || e.metaKey) return; if (e.code === 'Period' && !e.shiftKey && !e.ctrlKey) { // Alt+. — create bookmark e.preventDefault(); createBookmark(); } else if (e.code === 'Comma' && !e.shiftKey && !e.ctrlKey) { // Alt+, — jump / enter jumping mode e.preventDefault(); activateJumping(); } else if (e.code === 'Comma' && e.shiftKey && !e.ctrlKey) { // Alt+Shift+, — delete nearest bookmark e.preventDefault(); deleteNearestBookmark(); } } function onScroll() { updateAllOverlayPositions(); } function onResize() { updateAllOverlayPositions(); } // ── Full cleanup (page unload / extension disable) ───────────────────────── function cleanup() { exitJumpingMode(); for (const el of [...trackedElements]) cleanupElement(el); window.removeEventListener('mousemove', onMouseMove, { capture: true }); window.removeEventListener('keydown', onKeyDown, { capture: true }); window.removeEventListener('scroll', onScroll, { capture: true }); window.removeEventListener('resize', onResize); window.removeEventListener('pagehide', cleanup); } // ── Bootstrap ───────────────────────────────────────────────────────────── window.addEventListener('mousemove', onMouseMove, { capture: true, passive: true }); window.addEventListener('keydown', onKeyDown, { capture: true }); window.addEventListener('scroll', onScroll, { capture: true, passive: true }); window.addEventListener('resize', onResize, { passive: true }); window.addEventListener('pagehide', cleanup); })();