import { memo, useEffect, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type Ref, type RefCallback, } from "react"; import { Download } from "lucide-react"; import { collectBookmarkIndex, createAnchorPageMap, createPageObjectEntityId, getProjectIdentity, resolveAnchorPageIndex, type DeploymentInfo, type HtmlPageBlock, type ReaderDocument, } from "../document-model"; import type { InspectorState } from "../workbench/inspector"; import { useReaderRuntime } from "./useReaderRuntime"; import { BOOKMARKS_SECTION_CLASS, CurrentPagePanel, DocumentNavigation, } from "./ReaderNavigationPanel"; import { useFigureDirectory, useTableDirectory } from "../navigation"; import { PUBLIC_HTML_PAGE_CLASS, PUBLIC_HTML_PAGE_HTML_CLASS, PUBLIC_IDENTITY_CLASS, PUBLIC_IDENTITY_TITLE_CLASS, PUBLIC_READER_PAGES_CLASS, PUBLIC_READER_STAGE_CLASS, PUBLIC_TITLE_MAIN_CLASS, PUBLIC_TITLE_SUB_CLASS, } from "./publicViewerClasses"; import type { DisplayPage } from "./readerTypes"; import { usePageViewportScale } from "./usePageViewportScale"; import { PageZoomDock, SearchControl, SearchPanel } from "../workbench/actions"; import { PublicAttribution } from "./PublicAttribution"; import { SHELL_COMPACT_MAX_WIDTH, SHELL_COMPACT_MEDIA_QUERY, SHELL_DRAWER_BREAKPOINT, WorkbenchShell, } from "../workbench/shell"; import { useHotkey } from "../hotkeys"; import { cn } from "../core/cn"; import { isLocalWorkspaceHost, workspaceLayoutStyle } from "../shared"; import { TOOLBAR_ACTION_CLASS, TOOLBAR_ACTION_LABEL_CLASS, TOOLBAR_GROUP_CLASS, } from "../workbench/toolbarClasses"; export const PUBLIC_DRAWER_BREAKPOINT = SHELL_DRAWER_BREAKPOINT; const PUBLIC_READER_PAGE_SCALE_STORAGE_KEY = "openpress:reader:page-scale-mode"; export type ViewMode = "paged"; export type PageInspector = Pick; export function PublicViewer({ document, pages, style, deploymentInfo = { online: false }, }: { document: ReaderDocument; pages: Array; style: CSSProperties; deploymentInfo?: DeploymentInfo; }) { const sourceContainerRef = useRef(null); const displayPages = pages; const { viewMode } = useViewMode(); const bookmarks = useMemo(() => collectBookmarkIndex(displayPages), [displayPages]); const figures = useFigureDirectory(document); const tables = useTableDirectory(document); const hasDirectory = bookmarks.length > 0 || figures.length > 0 || tables.length > 0; const anchorPageMap = useMemo(() => createAnchorPageMap(displayPages), [displayPages]); const reader = useReaderRuntime({ pageCount: displayPages.length, leftPanelBreakpoint: PUBLIC_DRAWER_BREAKPOINT, rightPanelBreakpoint: PUBLIC_DRAWER_BREAKPOINT, initialPanelState: { leftPanelOpen: hasDirectory && !isPublicDrawerViewport(), rightPanelOpen: false, }, }); const [searchOpen, setSearchOpen] = useState(false); const setSearchPanelOpen = (nextOpen: boolean) => { if ( nextOpen && typeof window !== "undefined" && window.innerWidth <= SHELL_COMPACT_MAX_WIDTH && reader.leftPanelOpen ) { reader.toggleLeftPanel(); } setSearchOpen(nextOpen); }; const pageViewport = usePageViewportScale({ stageRef: reader.stageRef, pageContainerRef: sourceContainerRef, pageCount: displayPages.length, scaleModeStorageKey: PUBLIC_READER_PAGE_SCALE_STORAGE_KEY, }); const currentPage = displayPages[reader.currentPageIndex]; const publicPdfHref = typeof window !== "undefined" && !isLocalWorkspaceHost(window.location.hostname) ? deploymentInfo.pdf : undefined; const projectIdentity = getProjectIdentity(document.meta); const pressType = document.meta.type === "slides" ? "slides" : "pages"; const selectPublicPage = (pageIndex: number, options?: { behavior?: ScrollBehavior }) => { reader.setPage(pageIndex, options); if (window.innerWidth < PUBLIC_DRAWER_BREAKPOINT && reader.leftPanelOpen) reader.toggleLeftPanel(); }; useHotkey("workspace.toggle-bookmarks", reader.toggleLeftPanel); const selectPublicAnchor = (anchorId: string, pageIndex?: number) => { const targetPageIndex = resolveAnchorPageIndex(anchorPageMap, displayPages.length, anchorId, pageIndex); if (targetPageIndex === null) return false; selectPublicPage(targetPageIndex, { behavior: "smooth" }); return true; }; return ( setSearchPanelOpen(!searchOpen)} withRightPanel showRightPanelToggle={false} fixedPanels={searchOpen} publicViewer > {publicPdfHref ? ( ) : null}
{projectIdentity.name} {projectIdentity.subtitle ? {projectIdentity.subtitle} : null} {projectIdentity.label ? {projectIdentity.label} : null}
{hasDirectory ? (
) : null}
setSearchPanelOpen(false)} />
); } function isPublicDrawerViewport() { if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false; return window.matchMedia(SHELL_COMPACT_MEDIA_QUERY).matches; } export function useViewMode(): { viewMode: ViewMode } { return { viewMode: "paged" }; } export function PrintDocument({ document, pages, style, }: { document: ReaderDocument; pages: Array; style: CSSProperties; }) { const sourceContainerRef = useRef(null); const displayPages = pages; const registerPage = () => () => undefined; // Mirror the per-document page geometry vars onto :root so the @page // rule in print-route.css can resolve them. CSS custom properties set // on
never reach @page in any browser; without this, headless // Chrome falls back to the workspace theme default (210mm × 297mm A4) // and slide/social/landscape presses print onto the wrong paper. useEffect(() => { if (typeof document === "undefined" || typeof window === "undefined") return undefined; const root = window.document.documentElement; const overrides: Array<[string, string]> = []; for (const [key, value] of Object.entries(workspaceLayoutStyle(style))) { if (typeof key === "string" && key.startsWith("--") && typeof value === "string") { overrides.push([key, value]); } } const previous = overrides.map(([key]) => [key, root.style.getPropertyValue(key)] as const); overrides.forEach(([key, value]) => root.style.setProperty(key, value)); return () => { previous.forEach(([key, value]) => { if (value) root.style.setProperty(key, value); else root.style.removeProperty(key); }); }; }, [style]); return (
); } export function PublicPage({ pages, style, currentPageIndex, sourceContainerRef, registerPage, exposeSourceData = false, printLayout = false, inspector, onInternalAnchorNavigate, className, }: { pages: DisplayPage[]; style?: CSSProperties; currentPageIndex: number; sourceContainerRef: Ref; registerPage: (pageIndex: number) => RefCallback; exposeSourceData?: boolean; printLayout?: boolean; inspector?: PageInspector; onInternalAnchorNavigate?: (anchorId: string, pageIndex?: number) => boolean; className?: string; }) { const handlePageClick = (event: ReactMouseEvent) => { if (inspector?.enabled && inspector.handleClick(event)) return; if (!onInternalAnchorNavigate || event.defaultPrevented || event.button !== 0) return; if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return; if (!(event.target instanceof Element)) return; const link = event.target.closest('a[href^="#"]'); if (!link) return; const href = link.getAttribute("href") ?? ""; const anchorId = link.dataset.openpressAnchor || safeDecodeAnchor(href.slice(1)); if (!anchorId) return; const pageIndex = Number.parseInt(link.dataset.openpressTargetPageIndex ?? "", 10); const handled = onInternalAnchorNavigate(anchorId, Number.isFinite(pageIndex) ? pageIndex : undefined); if (handled) event.preventDefault(); }; return (
{pages.map((page) => (
))}
); } // Memoized by html string: React skips re-rendering (and therefore skips the // innerHTML assignment) when the page content hasn't changed. This preserves // any running CSS animations / transitions that live inside the rendered HTML. export const PageHtmlContent = memo(function PageHtmlContent({ html, className, style, }: { html: string; className: string; style?: CSSProperties; }) { return
; }); function safeDecodeAnchor(value: string) { if (!value) return ""; try { return decodeURIComponent(value); } catch { return value; } }