From 9bc878eb5153dd9612d01495cb47fdfeab0a456c Mon Sep 17 00:00:00 2001 From: seb Date: Sat, 10 Oct 2026 04:53:17 +0200 Subject: [PATCH] u --- src/components/AppShell.jsx | 2 + src/components/PageMinimap.jsx | 389 +++++++++++++++++++++++++++++++++ src/routes/artikel.jsx | 3 + 3 files changed, 394 insertions(+) create mode 100644 src/components/PageMinimap.jsx diff --git a/src/components/AppShell.jsx b/src/components/AppShell.jsx index 1545d58..da778a5 100644 --- a/src/components/AppShell.jsx +++ b/src/components/AppShell.jsx @@ -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() { + ); } diff --git a/src/components/PageMinimap.jsx b/src/components/PageMinimap.jsx new file mode 100644 index 0000000..ec181f1 --- /dev/null +++ b/src/components/PageMinimap.jsx @@ -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 `
`) + * 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 ( + + 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', + })} + /> + + + + + ); +} diff --git a/src/routes/artikel.jsx b/src/routes/artikel.jsx index 1d61fd7..c820eda 100644 --- a/src/routes/artikel.jsx +++ b/src/routes/artikel.jsx @@ -607,6 +607,9 @@ class ArtikelSearchPage extends Component { display: 'flex', flexDirection: 'column', overflow: 'hidden', + // Match PanelTile (PANEL_TILE_BORDER_RADIUS: borderRadius 4 → + // theme.shape.borderRadius × 4 = 16px) so both columns line up. + borderRadius: 4, }} >