This commit is contained in:
seb
2026-10-10 04:53:17 +02:00
parent f778b2d104
commit 9bc878eb51
3 changed files with 394 additions and 0 deletions

View File

@@ -20,6 +20,7 @@ import {
NAV_SURFACE,
} from '../colors';
import { ChatFab, ChatDrawer } from './chat';
import PageMinimap from './PageMinimap';
const SIDEBAR_WIDTH = 144;
@@ -322,6 +323,7 @@ export default function AppShell() {
</Box>
<ChatFab />
<ChatDrawer />
<PageMinimap />
</Box>
);
}

View File

@@ -0,0 +1,389 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { matchPath, useLocation } from 'react-router-dom';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import MapRoundedIcon from '@mui/icons-material/MapRounded';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import { ARTICLE_TAB, readPreferredArticleTab } from './article/articleRoute';
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 });
// SEO tab only: /artikel/:sku/seo, or /artikel/:sku when the preferred
// default tab resolves to seo.
const { pathname } = useLocation();
const articleMatch = matchPath('/artikel/:sku/:tab?', pathname);
const isSeoPage = Boolean(
articleMatch &&
(articleMatch.params?.tab === ARTICLE_TAB.seo ||
(!articleMatch.params?.tab &&
readPreferredArticleTab() === ARTICLE_TAB.seo)),
);
/** 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]);
// Reset scroll visibility on navigation; the new page starts hidden
// until the user scrolls again.
useEffect(() => {
setScrollActive(false);
}, [pathname]);
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 || !isSeoPage) return null;
// Collapsed: a single centered pill to reopen the overview.
if (!open) {
return (
<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>
);
}
return (
<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>
);
}