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 `
`) * 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 ( 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 }, })} > ); } return ( 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, })} > setOpen(false)} sx={{ p: 0.25 }} > { 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`, }, })} > ({ 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', })} /> ); }