diff --git a/apps/web/src/reader/ReaderShell.tsx b/apps/web/src/reader/ReaderShell.tsx index a764188..375daf1 100644 --- a/apps/web/src/reader/ReaderShell.tsx +++ b/apps/web/src/reader/ReaderShell.tsx @@ -2,7 +2,15 @@ import { ArrowLeft, ArrowRight } from "lucide-react"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { ErrorRibbon } from "../components/ui"; import { navigate } from "../router"; -import { enterElementFullscreen, exitElementFullscreen, isElementFullscreen, readerArrowAction, shouldHandleReaderArrowKey, supportsElementFullscreen } from "./readerFullscreen"; +import { + enterElementFullscreen, + exitElementFullscreen, + isElementFullscreen, + isReaderHeaderActive, + readerArrowAction, + shouldHandleReaderArrowKey, + supportsElementFullscreen +} from "./readerFullscreen"; import { readerZoomLabel, READER_ZOOM_DEFAULT, READER_ZOOM_MAX, READER_ZOOM_MIN } from "./readerLayout"; export type ReaderControls = { @@ -32,10 +40,40 @@ type ReaderShellProps = { export function ReaderShell({ title, backHref, error, onRetry, controls, zoomControls, children }: ReaderShellProps) { const readerRef = useRef(null); + const headerRef = useRef(null); + const headerHideTimerRef = useRef(undefined); const [nativeFullscreen, setNativeFullscreen] = useState(false); const [fallbackFullscreen, setFallbackFullscreen] = useState(false); + const [headerVisible, setHeaderVisible] = useState(true); const fullscreenActive = nativeFullscreen || fallbackFullscreen; + const clearHeaderHideTimer = useCallback(() => { + if (headerHideTimerRef.current === undefined) return; + window.clearTimeout(headerHideTimerRef.current); + headerHideTimerRef.current = undefined; + }, []); + + const scheduleHeaderHide = useCallback(() => { + clearHeaderHideTimer(); + if (!fullscreenActive) { + setHeaderVisible(true); + return; + } + headerHideTimerRef.current = window.setTimeout(() => { + if (isReaderHeaderActive(headerRef.current, document)) { + scheduleHeaderHide(); + return; + } + setHeaderVisible(false); + }, 2000); + }, [clearHeaderHideTimer, fullscreenActive]); + + const revealHeader = useCallback(() => { + if (!fullscreenActive) return; + setHeaderVisible(true); + scheduleHeaderHide(); + }, [fullscreenActive, scheduleHeaderHide]); + const toggleFullscreen = useCallback(async () => { const readerElement = readerRef.current; if (!readerElement) return; @@ -73,6 +111,18 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon return () => document.removeEventListener("fullscreenchange", syncFullscreen); }, []); + useEffect(() => { + if (fullscreenActive) { + setHeaderVisible(true); + scheduleHeaderHide(); + return; + } + clearHeaderHideTimer(); + setHeaderVisible(true); + }, [clearHeaderHideTimer, fullscreenActive, scheduleHeaderHide]); + + useEffect(() => clearHeaderHideTimer, [clearHeaderHideTimer]); + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape" && fallbackFullscreen) { @@ -80,6 +130,7 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon return; } if (!shouldHandleReaderArrowKey(event, readerRef.current)) return; + revealHeader(); const action = readerArrowAction(event); if (action === "previous" && controls.canPrevious) { event.preventDefault(); @@ -92,11 +143,37 @@ export function ReaderShell({ title, backHref, error, onRetry, controls, zoomCon }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [controls, fallbackFullscreen]); + }, [controls, fallbackFullscreen, revealHeader]); return ( -
-
+
+
{ + clearHeaderHideTimer(); + setHeaderVisible(true); + }} + onMouseEnter={() => { + clearHeaderHideTimer(); + setHeaderVisible(true); + }} + onMouseLeave={scheduleHeaderHide} + onPointerDown={() => { + clearHeaderHideTimer(); + setHeaderVisible(true); + }} + >