376 lines
12 KiB
JavaScript
376 lines
12 KiB
JavaScript
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
import Box from '@mui/material/Box';
|
|
import IconButton from '@mui/material/IconButton';
|
|
import Portal from '@mui/material/Portal';
|
|
import MapRoundedIcon from '@mui/icons-material/MapRounded';
|
|
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
|
|
|
const MINIMAP_WIDTH = 220;
|
|
const MINIMAP_MAX_HEIGHT = 300;
|
|
const REBUILD_DEBOUNCE_MS = 600;
|
|
/** Hide the minimap this long after the last scroll event. */
|
|
const IDLE_HIDE_MS = 1200;
|
|
|
|
/** Clamp a number into [min, max]. */
|
|
function clamp(value, min, max) {
|
|
return Math.min(max, Math.max(min, value));
|
|
}
|
|
|
|
/**
|
|
* Floating whole-page minimap, centered at the bottom of the viewport.
|
|
*
|
|
* Renders a live scaled-down clone of `sourceSelector` (default `<main>`)
|
|
* with a viewport rectangle that tracks `window.scrollY`. Clicking or
|
|
* dragging inside the thumbnail jumps the page to that position.
|
|
*
|
|
* Only renders when the page is actually scrollable
|
|
* (`documentHeight > 1.3 * viewportHeight`).
|
|
*/
|
|
export default function PageMinimap({ sourceSelector = 'main' }) {
|
|
const [open, setOpen] = useState(true);
|
|
const [enabled, setEnabled] = useState(false);
|
|
const [thumb, setThumb] = useState({ width: MINIMAP_WIDTH, height: 0, scale: 1 });
|
|
const [scrollActive, setScrollActive] = useState(false);
|
|
const [hovering, setHovering] = useState(false);
|
|
const [dragging, setDragging] = useState(false);
|
|
|
|
const thumbBodyRef = useRef(null);
|
|
const viewportRef = useRef(null);
|
|
const draggingRef = useRef(false);
|
|
const rebuildTimerRef = useRef(null);
|
|
const idleTimerRef = useRef(null);
|
|
const metricsRef = useRef({ docH: 1, winH: 1, thumbH: 1 });
|
|
|
|
|
|
/** Move the viewport rectangle without re-rendering (called on scroll). */
|
|
const updateViewport = useCallback(() => {
|
|
const el = viewportRef.current;
|
|
if (!el) return;
|
|
const { docH, winH, thumbH } = metricsRef.current;
|
|
if (docH <= 0 || thumbH <= 0) return;
|
|
const scrollY = window.scrollY || document.documentElement.scrollTop || 0;
|
|
const top = clamp((scrollY / docH) * thumbH, 0, thumbH);
|
|
const height = clamp((winH / docH) * thumbH, 12, thumbH);
|
|
el.style.top = `${clamp(top, 0, Math.max(0, thumbH - height))}px`;
|
|
el.style.height = `${height}px`;
|
|
}, []);
|
|
|
|
/** Scroll the window so the clicked thumbnail offset lands mid-viewport. */
|
|
const jumpToThumbOffset = useCallback((offsetY) => {
|
|
const { docH, winH, thumbH } = metricsRef.current;
|
|
if (thumbH <= 0) return;
|
|
const ratio = clamp(offsetY / thumbH, 0, 1);
|
|
const target = ratio * docH - winH / 2;
|
|
window.scrollTo({ top: clamp(target, 0, Math.max(0, docH - winH)), behavior: 'auto' });
|
|
}, []);
|
|
|
|
const handleThumbPointerDown = useCallback(
|
|
(event) => {
|
|
if (event.button !== 0) return;
|
|
draggingRef.current = true;
|
|
setDragging(true);
|
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
const rect = event.currentTarget.getBoundingClientRect();
|
|
jumpToThumbOffset(event.clientY - rect.top);
|
|
},
|
|
[jumpToThumbOffset],
|
|
);
|
|
|
|
const handleThumbPointerMove = useCallback(
|
|
(event) => {
|
|
if (!draggingRef.current) return;
|
|
const rect = event.currentTarget.getBoundingClientRect();
|
|
jumpToThumbOffset(event.clientY - rect.top);
|
|
},
|
|
[jumpToThumbOffset],
|
|
);
|
|
|
|
const endDrag = useCallback(() => {
|
|
draggingRef.current = false;
|
|
setDragging(false);
|
|
}, []);
|
|
|
|
/** Rebuild the scaled clone and recompute geometry. */
|
|
const rebuild = useCallback(() => {
|
|
const source = document.querySelector(sourceSelector);
|
|
if (!source) return;
|
|
|
|
const docH = document.documentElement.scrollHeight || 1;
|
|
const winH = window.innerHeight || 1;
|
|
const sourceW = source.offsetWidth || source.getBoundingClientRect().width || 1;
|
|
|
|
const isLong = docH > winH * 1.3;
|
|
setEnabled((prev) => (prev === isLong ? prev : isLong));
|
|
if (!isLong) return;
|
|
|
|
// Host only exists once enabled+open render the thumbnail. Geometry is
|
|
// already stored above, so the enabled->render->rebuild pass clones it.
|
|
const host = thumbBodyRef.current;
|
|
if (!host) return;
|
|
|
|
const scale = Math.min(MINIMAP_WIDTH / sourceW, MINIMAP_MAX_HEIGHT / docH);
|
|
const thumbW = Math.max(1, Math.round(sourceW * scale));
|
|
const thumbH = Math.max(1, Math.round(docH * scale));
|
|
metricsRef.current = { docH, winH, thumbH };
|
|
setThumb({ width: thumbW, height: thumbH, scale });
|
|
|
|
host.innerHTML = '';
|
|
const clone = source.cloneNode(true);
|
|
clone.removeAttribute('id');
|
|
clone.querySelectorAll('script, style, noscript').forEach((el) => el.remove());
|
|
clone.querySelectorAll('[id]').forEach((el) => el.removeAttribute('id'));
|
|
clone.querySelectorAll('a').forEach((el) => {
|
|
el.removeAttribute('href');
|
|
});
|
|
clone.setAttribute('aria-hidden', 'true');
|
|
clone.style.width = `${sourceW}px`;
|
|
clone.style.maxWidth = 'none';
|
|
clone.style.margin = '0';
|
|
clone.style.pointerEvents = 'none';
|
|
clone.style.userSelect = 'none';
|
|
clone.style.transform = `scale(${scale})`;
|
|
clone.style.transformOrigin = 'top left';
|
|
host.appendChild(clone);
|
|
|
|
requestAnimationFrame(updateViewport);
|
|
}, [sourceSelector, updateViewport]);
|
|
|
|
const scheduleRebuild = useCallback(() => {
|
|
if (rebuildTimerRef.current) clearTimeout(rebuildTimerRef.current);
|
|
rebuildTimerRef.current = setTimeout(() => {
|
|
rebuildTimerRef.current = null;
|
|
rebuild();
|
|
}, REBUILD_DEBOUNCE_MS);
|
|
}, [rebuild]);
|
|
|
|
// Second pass: first rebuild() only flips `enabled` on (no host yet);
|
|
// once the thumbnail mounts, rebuild again to actually clone content.
|
|
useEffect(() => {
|
|
if (enabled && open) rebuild();
|
|
}, [enabled, open, rebuild]);
|
|
|
|
|
|
useEffect(() => {
|
|
rebuild();
|
|
// Article content loads async (automat cache), so re-check shortly
|
|
// after mount in case the page grows past the threshold late.
|
|
const t1 = setTimeout(rebuild, 500);
|
|
const t2 = setTimeout(rebuild, 1500);
|
|
let raf = 0;
|
|
const onScroll = () => {
|
|
cancelAnimationFrame(raf);
|
|
raf = requestAnimationFrame(updateViewport);
|
|
// Show while scrolling; hide ~1s after the last scroll event.
|
|
setScrollActive(true);
|
|
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
|
idleTimerRef.current = setTimeout(() => {
|
|
idleTimerRef.current = null;
|
|
setScrollActive(false);
|
|
}, IDLE_HIDE_MS);
|
|
};
|
|
const onResize = () => rebuild();
|
|
window.addEventListener('scroll', onScroll, { passive: true });
|
|
window.addEventListener('resize', onResize);
|
|
|
|
const source = document.querySelector(sourceSelector);
|
|
const observer = new MutationObserver(scheduleRebuild);
|
|
if (source) {
|
|
observer.observe(source, { childList: true, subtree: true, characterData: true });
|
|
}
|
|
|
|
return () => {
|
|
cancelAnimationFrame(raf);
|
|
clearTimeout(t1);
|
|
clearTimeout(t2);
|
|
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
|
|
window.removeEventListener('scroll', onScroll);
|
|
window.removeEventListener('resize', onResize);
|
|
observer.disconnect();
|
|
if (rebuildTimerRef.current) clearTimeout(rebuildTimerRef.current);
|
|
};
|
|
}, [rebuild, scheduleRebuild, sourceSelector, updateViewport]);
|
|
|
|
// Keep the viewport rect in sync when the thumbnail geometry changes.
|
|
useEffect(() => {
|
|
updateViewport();
|
|
}, [thumb, open, updateViewport]);
|
|
|
|
// Stays mounted for fade-out; hidden unless scrolling/hovering/dragging.
|
|
const visible = scrollActive || hovering || dragging;
|
|
|
|
if (!enabled) return null;
|
|
|
|
// Collapsed: a single centered pill to reopen the overview.
|
|
if (!open) {
|
|
return (
|
|
<Portal>
|
|
<Box
|
|
sx={{
|
|
position: 'fixed',
|
|
left: '50%',
|
|
bottom: 16,
|
|
transform: 'translateX(-50%)',
|
|
zIndex: 1300,
|
|
opacity: visible ? 1 : 0,
|
|
transition: 'opacity 300ms ease',
|
|
pointerEvents: visible ? 'auto' : 'none',
|
|
}}
|
|
>
|
|
<IconButton
|
|
type="button"
|
|
size="small"
|
|
aria-label="Seiten-Übersicht anzeigen"
|
|
onClick={() => setOpen(true)}
|
|
sx={(theme) => ({
|
|
bgcolor: theme.palette.background.paper,
|
|
border: '1px solid',
|
|
borderColor: theme.palette.divider,
|
|
boxShadow: theme.shadows[6],
|
|
opacity: 0.85,
|
|
'&:hover': { opacity: 1 },
|
|
})}
|
|
>
|
|
<MapRoundedIcon fontSize="small" />
|
|
</IconButton>
|
|
</Box>
|
|
</Portal>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Portal>
|
|
<Box
|
|
sx={{
|
|
position: 'fixed',
|
|
left: '50%',
|
|
bottom: 16,
|
|
transform: 'translateX(-50%)',
|
|
zIndex: 1300,
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
alignItems: 'center',
|
|
gap: 0.5,
|
|
pointerEvents: 'none',
|
|
opacity: visible ? 1 : 0,
|
|
transition: 'opacity 300ms ease',
|
|
}}
|
|
>
|
|
<Box
|
|
onMouseEnter={() => setHovering(true)}
|
|
onMouseLeave={() => setHovering(false)}
|
|
sx={(theme) => ({
|
|
pointerEvents: visible ? 'auto' : 'none',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
overflow: 'hidden',
|
|
borderRadius: 2,
|
|
border: '1px solid',
|
|
borderColor: theme.palette.divider,
|
|
bgcolor: theme.palette.background.paper,
|
|
boxShadow: theme.shadows[8],
|
|
opacity: 0.96,
|
|
})}
|
|
>
|
|
<Box
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 1,
|
|
px: 1,
|
|
py: 0.5,
|
|
borderBottom: '1px solid',
|
|
borderColor: 'divider',
|
|
}}
|
|
>
|
|
<IconButton
|
|
type="button"
|
|
size="small"
|
|
aria-label="Seiten-Übersicht schließen"
|
|
onClick={() => setOpen(false)}
|
|
sx={{ p: 0.25 }}
|
|
>
|
|
<CloseRoundedIcon sx={{ fontSize: 16 }} />
|
|
</IconButton>
|
|
</Box>
|
|
<Box sx={{ p: 1, display: 'flex', justifyContent: 'center' }}>
|
|
<Box
|
|
role="slider"
|
|
aria-label="Seitenposition"
|
|
aria-valuemin={0}
|
|
aria-valuemax={100}
|
|
tabIndex={0}
|
|
onPointerDown={handleThumbPointerDown}
|
|
onPointerMove={handleThumbPointerMove}
|
|
onPointerUp={endDrag}
|
|
onPointerCancel={endDrag}
|
|
onKeyDown={(event) => {
|
|
const { docH, winH } = metricsRef.current;
|
|
const y = window.scrollY || 0;
|
|
if (event.key === 'ArrowDown') {
|
|
event.preventDefault();
|
|
window.scrollTo({ top: y + winH * 0.8 });
|
|
} else if (event.key === 'ArrowUp') {
|
|
event.preventDefault();
|
|
window.scrollTo({ top: y - winH * 0.8 });
|
|
} else if (event.key === 'Home') {
|
|
event.preventDefault();
|
|
window.scrollTo({ top: 0 });
|
|
} else if (event.key === 'End') {
|
|
event.preventDefault();
|
|
window.scrollTo({ top: docH });
|
|
}
|
|
}}
|
|
sx={(theme) => ({
|
|
position: 'relative',
|
|
width: thumb.width,
|
|
height: thumb.height,
|
|
overflow: 'hidden',
|
|
borderRadius: 1,
|
|
border: '1px solid',
|
|
borderColor: theme.palette.divider,
|
|
bgcolor: theme.palette.action.hover,
|
|
cursor: 'pointer',
|
|
outline: 'none',
|
|
touchAction: 'none',
|
|
'&:focus-visible': {
|
|
borderColor: theme.palette.primary.main,
|
|
boxShadow: `0 0 0 2px ${theme.palette.primary.main}55`,
|
|
},
|
|
})}
|
|
>
|
|
<Box
|
|
ref={thumbBodyRef}
|
|
sx={{
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
width: thumb.width,
|
|
height: thumb.height,
|
|
overflow: 'hidden',
|
|
pointerEvents: 'none',
|
|
}}
|
|
/>
|
|
<Box
|
|
ref={viewportRef}
|
|
sx={(theme) => ({
|
|
position: 'absolute',
|
|
left: 0,
|
|
right: 0,
|
|
top: 0,
|
|
height: 24,
|
|
border: '2px solid',
|
|
borderColor: theme.palette.primary.main,
|
|
bgcolor: `${theme.palette.primary.main}22`,
|
|
borderRadius: 0.5,
|
|
pointerEvents: 'none',
|
|
boxSizing: 'border-box',
|
|
})}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
</Portal>
|
|
);
|
|
}
|