From 1d7db41bee63ad63be05b3a177b80d24e0dd2124 Mon Sep 17 00:00:00 2001 From: Sisi Date: Mon, 6 Apr 2026 20:11:48 +0200 Subject: [PATCH] init --- content.js | 498 ++++++++++++++++++++++++++++++++++++++++++++++++++ manifest.json | 13 ++ 2 files changed, 511 insertions(+) create mode 100644 content.js create mode 100644 manifest.json diff --git a/content.js b/content.js new file mode 100644 index 0000000..9e91a16 --- /dev/null +++ b/content.js @@ -0,0 +1,498 @@ +(() => { + '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); + +})(); diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..4afff9a --- /dev/null +++ b/manifest.json @@ -0,0 +1,13 @@ +{ + "manifest_version": 3, + "name": "Scroll Bookmark", + "version": "1.0", + "description": "Bookmark scroll positions in any scrollable element", + "content_scripts": [ + { + "matches": [""], + "js": ["content.js"], + "run_at": "document_idle" + } + ] +}