init
This commit is contained in:
+498
@@ -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);
|
||||
|
||||
})();
|
||||
Reference in New Issue
Block a user