This commit is contained in:
2026-04-06 20:11:48 +02:00
commit 1d7db41bee
2 changed files with 511 additions and 0 deletions
+498
View File
@@ -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);
})();
+13
View File
@@ -0,0 +1,13 @@
{
"manifest_version": 3,
"name": "Scroll Bookmark",
"version": "1.0",
"description": "Bookmark scroll positions in any scrollable element",
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle"
}
]
}