import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent } from "react"; import { Download, Maximize2, X } from "lucide-react"; import { Button } from "@/openpress/ui/button"; import { useHotkey } from "../hotkeys"; import { createPageObjectEntityId } from "../document-model"; import type { DeploymentInfo, HtmlPageBlock, ReaderDocument } from "../document-model"; import { pageIndexFromHash, replacePageRoute } from "./readerPageRoute"; import { clampReaderPageIndex, formatReaderPageNumber, normalizeReaderPageCount } from "./readerStateModel"; import { usePageViewportScale } from "./usePageViewportScale"; import { PUBLIC_HTML_PAGE_CLASS, PUBLIC_HTML_PAGE_HTML_CLASS } from "./publicViewerClasses"; import { workspaceLayoutStyle } from "../shared"; type PresentationUiMode = "chrome" | "immersive"; const PRESENTER_ROOT_CLASS = [ "op-workspace-chrome openpress-reader-app openpress-slide-presenter", "fixed inset-0 !grid !h-dvh !min-h-dvh !w-full !overflow-hidden overscroll-none", "![background-image:radial-gradient(circle_at_50%_0,var(--op-workspace-border-muted),transparent_42%)] !bg-[#070707]", "!text-[rgb(245_245_242_/_0.92)]", ].join(" "); const PRESENTER_STAGE_CLASS = "relative min-h-0 min-w-0 overflow-hidden cursor-pointer"; const PRESENTER_STAGE_IMMERSIVE_CLASS = "relative min-h-0 min-w-0 overflow-hidden cursor-none"; const READER_STAGE_CLASS = [ "reader-stage relative flex !h-full !min-h-0 !w-full items-center justify-center !overflow-hidden !bg-transparent outline-none", "[grid-area:main] [container-type:inline-size] focus:outline-none overscroll-contain !snap-none", "[scrollbar-width:none] [&::-webkit-scrollbar]:hidden", ].join(" "); const READER_PAGES_CLASS = [ "reader-pages openpress-public-page", "!grid !h-full !min-h-full !w-full content-center !items-center !justify-items-center !p-0", "[--openpress-page-gap:0]", ].join(" "); const PAGE_FRAME_CLASS = `${PUBLIC_HTML_PAGE_CLASS} !min-h-0 !scroll-mt-0 !snap-center`; const PAGE_HTML_CLASS = [ PUBLIC_HTML_PAGE_HTML_CLASS, "shadow-[0_28px_80px_rgb(0_0_0_/_0.34),0_0_0_1px_rgb(255_255_255_/_0.1)]", ].join(" "); const HUD_BASE_CLASS = [ "fixed bottom-[18px] right-[18px] z-40 flex items-center gap-2", "rounded-[var(--op-workspace-radius-pill)] border border-[var(--op-workspace-border)]", "bg-[color-mix(in_srgb,var(--op-workspace-surface)_74%,transparent)] p-1.5 shadow-[var(--op-workspace-shadow-floating)]", "backdrop-blur-[18px] transition-[opacity,transform] duration-150", ].join(" "); const HUD_TITLE_CLASS = [ "max-w-60 overflow-hidden text-ellipsis whitespace-nowrap border-r border-white/10 px-3", "text-xs font-medium leading-[30px] text-[rgb(245_245_242_/_0.56)]", "[font-family:var(--op-workspace-font-ui)]", ].join(" "); const HUD_PROGRESS_CLASS = [ "min-w-16 px-[10px] text-center text-xs font-semibold leading-[30px]", "tracking-[0.08em] text-[rgb(245_245_242_/_0.72)]", "[font-family:var(--op-workspace-font-mono)]", ].join(" "); const HUD_BUTTON_CLASS = [ "inline-flex h-[30px] w-[30px] cursor-pointer items-center justify-center rounded-full border-0", "bg-transparent p-0 text-[rgb(245_245_242_/_0.68)] no-underline", "hover:bg-white/10 hover:text-[rgb(245_245_242_/_0.96)] focus-visible:bg-white/10 focus-visible:text-[rgb(245_245_242_/_0.96)]", "[&_svg]:h-[15px] [&_svg]:w-[15px]", ].join(" "); export function SlidePresentationPage({ document, pages, style, onExitPresentation, deploymentInfo, }: { document: ReaderDocument; pages: HtmlPageBlock[]; style: CSSProperties; onExitPresentation?: (pageIndex: number) => void; deploymentInfo?: DeploymentInfo; }) { const sourceContainerRef = useRef(null); const stageRef = useRef(null); const currentPageIndexRef = useRef(0); const normalizedPageCount = normalizeReaderPageCount(pages.length); const [currentPageIndex, setCurrentPageIndex] = useState(() => { if (typeof window === "undefined") return 0; return pageIndexFromHash(window.location.hash, normalizedPageCount) ?? 0; }); const [uiMode, setUiMode] = useState(() => { if (shouldStartImmersive()) return "immersive"; // Fullscreen may already be active before in-place navigation. if (typeof globalThis.document !== "undefined" && globalThis.document.fullscreenElement) return "immersive"; return "chrome"; }); const pageViewport = usePageViewportScale({ stageRef, pageContainerRef: sourceContainerRef, pageCount: pages.length, layoutMode: "single", initialScaleMode: "fit-page", maxFitScale: Infinity, }); const currentPage = pages[clampReaderPageIndex(currentPageIndex, normalizedPageCount)]; const currentPageLabel = formatReaderPageNumber(currentPageIndex + 1); const totalPageLabel = formatReaderPageNumber(normalizedPageCount); const setPage = useCallback((pageIndex: number) => { const target = clampReaderPageIndex(pageIndex, normalizedPageCount); setCurrentPageIndex(target); replacePageRoute(target); }, [normalizedPageCount]); currentPageIndexRef.current = currentPageIndex; useEffect(() => { setCurrentPageIndex((idx) => clampReaderPageIndex(idx, normalizedPageCount)); }, [normalizedPageCount]); useEffect(() => { const syncPageFromHash = () => { const fromHash = pageIndexFromHash(window.location.hash, normalizedPageCount); if (fromHash !== null) setCurrentPageIndex(fromHash); }; syncPageFromHash(); window.addEventListener("hashchange", syncPageFromHash); return () => window.removeEventListener("hashchange", syncPageFromHash); }, [normalizedPageCount]); const canHandlePresentationHotkey = (event: KeyboardEvent) => !isEditableTarget(event.target); useHotkey("presentation.exit", (event) => { if (!canHandlePresentationHotkey(event)) return false; const activeDocument = globalThis.document; if (!activeDocument.fullscreenElement || !activeDocument.exitFullscreen) return false; void activeDocument.exitFullscreen(); }); useHotkey("presentation.next", (event) => { if (!canHandlePresentationHotkey(event)) return false; setPage(currentPageIndexRef.current + 1); }); useHotkey("presentation.previous", (event) => { if (!canHandlePresentationHotkey(event)) return false; setPage(currentPageIndexRef.current - 1); }); useHotkey("presentation.first", (event) => { if (!canHandlePresentationHotkey(event)) return false; setPage(0); }); useHotkey("presentation.last", (event) => { if (!canHandlePresentationHotkey(event)) return false; setPage(normalizedPageCount - 1); }); useEffect(() => { const handleFullscreenChange = () => { setUiMode(globalThis.document.fullscreenElement ? "immersive" : "chrome"); }; globalThis.document.addEventListener("fullscreenchange", handleFullscreenChange); return () => globalThis.document.removeEventListener("fullscreenchange", handleFullscreenChange); }, []); useEffect(() => { if (!shouldStartImmersive()) return; enterImmersive({ keepOnFailure: true }); }, []); const handleStageClick = (event: ReactMouseEvent) => { if (event.defaultPrevented) return; if (!(event.target instanceof Element)) return; if (event.target.closest("a, button, input, textarea, select, [contenteditable]")) return; setPage(currentPageIndex + 1); }; const handleFullscreen = () => { enterImmersive({ keepOnFailure: false }); }; const enterImmersive = ({ keepOnFailure }: { keepOnFailure: boolean }) => { const stage = stageRef.current; if (!stage?.requestFullscreen) { setUiMode("immersive"); return; } setUiMode("immersive"); void stage.requestFullscreen().catch(() => { if (!keepOnFailure) setUiMode("chrome"); }); }; const pageHtml = useMemo(() => currentPage?.html ?? "", [currentPage?.html]); return (
{currentPage ? (
) : null}
{deploymentInfo && document.meta.title ? ( {document.meta.title} ) : null} {currentPageLabel} / {totalPageLabel} {deploymentInfo?.pdf ? ( ) : null} {onExitPresentation ? ( ) : null}
); } function isEditableTarget(target: EventTarget | null) { if (!(target instanceof HTMLElement)) return false; return Boolean(target.closest("input, textarea, select, button, [contenteditable]")); } function shouldStartImmersive() { if (typeof window === "undefined") return false; return new URLSearchParams(window.location.search).get("fullscreen") === "1"; }