import { useT } from "@agent-native/core/client/i18n"; import { IconChevronLeft, IconChevronRight, IconMaximize, IconX, } from "@tabler/icons-react"; import { useState, useEffect, useCallback, useMemo, useRef } from "react"; import { useNavigate } from "react-router"; import SlideRenderer from "@/components/deck/SlideRenderer"; import type { Slide, SlideAnimation, AnimationType, } from "@/context/DeckContext"; import type { AspectRatio } from "@/lib/aspect-ratios"; import { findLegacyAnimationContainer, resolveSlideAnimationElement, } from "@/lib/slide-animation-elements"; interface PresentationViewProps { slides: Slide[]; deckId: string; startIndex?: number; aspectRatio?: AspectRatio; } // ─── Element animation helpers ──────────────────────────────────────────────── /** * Get the effective animation steps for a slide. * Uses slide.animations if defined, falls back to splitByParagraph auto-detection. */ function getAnimationSteps(slide: Slide): SlideAnimation[] | null { if (slide.animations && slide.animations.length > 0) return slide.animations; // Legacy splitByParagraph: auto-detect and create steps if (slide.splitByParagraph) { const doc = new DOMParser().parseFromString(slide.content, "text/html"); const root = doc.querySelector(".fmd-slide"); if (!root) return null; const container = findLegacyAnimationContainer(root); if (!container) return null; return Array.from(container.children).map((_, i) => ({ id: `auto-${i}`, elementIndex: i, type: "slide-up" as AnimationType, })); } return null; } /** CSS animation string for a given element animation type (for the newly-revealed item). */ function getElemAnimCss(type: AnimationType): string { switch (type) { case "appear": return "animation: elem-appear 100ms ease both;"; case "fade": return "animation: elem-appear 400ms ease both;"; case "slide-up": return "animation: elem-slide-up 300ms cubic-bezier(0.25,0.46,0.45,0.94) both;"; case "zoom": return "animation: elem-zoom 300ms cubic-bezier(0.25,0.46,0.45,0.94) both;"; } } /** * Return a modified HTML string where content-container children have * data-pstep attributes and an injected `; return styleTag + doc.body.innerHTML; } // ─── Animation class helpers ────────────────────────────────────────────────── function isInstant(t: Slide["transition"]): boolean { return !t || t === "instant" || t === "none"; } function getEnterClass( transition: Slide["transition"], direction: "next" | "prev", ): string { switch (transition) { case "fade": return "slide-anim-fade-enter"; case "slide": return direction === "next" ? "slide-anim-slide-enter-right" : "slide-anim-slide-enter-left"; case "zoom": return "slide-anim-zoom-enter"; default: return ""; } } function getExitClass( transition: Slide["transition"], direction: "next" | "prev", ): string { switch (transition) { case "fade": return "slide-anim-fade-exit"; case "slide": return direction === "next" ? "slide-anim-slide-exit-left" : "slide-anim-slide-exit-right"; case "zoom": return "slide-anim-zoom-exit"; default: return ""; } } // ─── Component ──────────────────────────────────────────────────────────────── export default function PresentationView({ slides, deckId, startIndex = 0, aspectRatio, }: PresentationViewProps) { const t = useT(); const safeSlides = useMemo( () => (Array.isArray(slides) ? slides : []) .filter(Boolean) .map((slide, index) => ({ ...slide, id: slide.id || `slide-${index}`, content: typeof slide.content === "string" ? slide.content : "", notes: slide.notes || "", layout: slide.layout || "blank", })), [slides], ); const clampIndex = useCallback( (index: number) => { if (safeSlides.length === 0) return 0; const safeIndex = Number.isFinite(index) ? index : 0; return Math.max(0, Math.min(safeIndex, safeSlides.length - 1)); }, [safeSlides.length], ); const [currentIndex, setCurrentIndex] = useState(() => clampIndex(startIndex), ); const [prevIndex, setPrevIndex] = useState(null); const [direction, setDirection] = useState<"next" | "prev">("next"); const [animating, setAnimating] = useState(false); const [currentStep, setCurrentStep] = useState(0); const [showControls, setShowControls] = useState(false); const [cursorVisible, setCursorVisible] = useState(true); const [needsFullscreenGesture, setNeedsFullscreenGesture] = useState(false); const enteredFullscreenRef = useRef(false); const navigate = useNavigate(); const isShared = deckId.startsWith("__shared__/"); useEffect(() => { setCurrentIndex((prev) => clampIndex(prev)); setPrevIndex((prev) => prev !== null && prev >= safeSlides.length ? null : prev, ); }, [clampIndex, safeSlides.length]); useEffect(() => { setCurrentIndex(clampIndex(startIndex)); }, [clampIndex, startIndex]); const currentSlide = safeSlides[currentIndex]; const animSteps = currentSlide ? getAnimationSteps(currentSlide) : null; const maxSteps = animSteps ? animSteps.length : 0; const startTransition = useCallback( (newIndex: number, dir: "next" | "prev") => { const incoming = safeSlides[newIndex]; const t = incoming?.transition; // Going backward → fully revealed; forward → start at 0 const incomingSteps = incoming ? getAnimationSteps(incoming) : null; const initialStep = dir === "prev" ? (incomingSteps ? incomingSteps.length : 0) : 0; if (isInstant(t)) { setCurrentIndex(newIndex); setCurrentStep(initialStep); return; } setPrevIndex(currentIndex); setDirection(dir); setAnimating(true); setCurrentIndex(newIndex); setCurrentStep(initialStep); setTimeout(() => { setPrevIndex(null); setAnimating(false); }, 400); }, [currentIndex, safeSlides], ); const goNext = useCallback(() => { if (animating) return; // Reveal next paragraph step if enabled if (maxSteps > 0 && currentStep < maxSteps /* i18n-ignore */) { setCurrentStep((prev) => prev + 1); return; } if (currentIndex >= safeSlides.length - 1) return; startTransition(currentIndex + 1, "next"); }, [ animating, maxSteps, currentStep, currentIndex, safeSlides.length, startTransition, ]); const goPrev = useCallback(() => { if (animating) return; if (currentIndex <= 0) return; startTransition(currentIndex - 1, "prev"); }, [animating, currentIndex, startTransition]); const exit = useCallback(() => { if (document.fullscreenElement) { document.exitFullscreen().catch(() => {}); } if (isShared) { const token = deckId.replace("__shared__/", ""); navigate(`/share/${token}`); } else { navigate(`/deck/${deckId}`); } }, [navigate, deckId, isShared]); useEffect(() => { const handleKey = (e: KeyboardEvent) => { switch (e.key) { case "ArrowRight": case "ArrowDown": case " ": e.preventDefault(); goNext(); break; case "ArrowLeft": case "ArrowUp": e.preventDefault(); goPrev(); break; case "f": case "F": e.preventDefault(); if (!document.fullscreenElement) { document.documentElement.requestFullscreen().catch(() => {}); } else { document.exitFullscreen().catch(() => {}); } break; case "Escape": exit(); break; } }; window.addEventListener("keydown", handleKey); return () => window.removeEventListener("keydown", handleKey); }, [goNext, goPrev, exit]); // Try to enter fullscreen. Browsers require a user gesture; the click that // navigated to /present often counts, but Safari/Firefox sometimes block // it. If blocked, we surface a "Click to enter fullscreen" overlay. const enterFullscreen = useCallback(() => { const el = document.documentElement; if (!el.requestFullscreen || document.fullscreenElement) { setNeedsFullscreenGesture(false); return; } el.requestFullscreen() .then(() => { enteredFullscreenRef.current = true; setNeedsFullscreenGesture(false); }) .catch(() => setNeedsFullscreenGesture(true)); }, []); // Request fullscreen on mount; track exit-by-Escape to navigate back useEffect(() => { enterFullscreen(); const handleFullscreenChange = () => { // If the user pressed Escape (browser auto-exits fullscreen), leave // present mode. We only navigate-back when WE successfully entered // fullscreen first — otherwise the gesture-fallback overlay handles it. if (enteredFullscreenRef.current && !document.fullscreenElement) { enteredFullscreenRef.current = false; if (isShared) { const token = deckId.replace("__shared__/", ""); navigate(`/share/${token}`); } else { navigate(`/deck/${deckId}`); } } }; document.addEventListener("fullscreenchange", handleFullscreenChange); return () => { document.removeEventListener("fullscreenchange", handleFullscreenChange); if (document.fullscreenElement) { document.exitFullscreen().catch(() => {}); } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Lock the body during present mode: hide scrollbars, mark the body so // external automation/test tooling can detect present mode is active. useEffect(() => { const html = document.documentElement; const body = document.body; const prevHtmlOverflow = html.style.overflow; const prevBodyOverflow = body.style.overflow; html.style.overflow = "hidden"; body.style.overflow = "hidden"; body.setAttribute("data-presentation-mode", "active"); return () => { html.style.overflow = prevHtmlOverflow; body.style.overflow = prevBodyOverflow; body.removeAttribute("data-presentation-mode"); }; }, []); // Auto-hide controls AND cursor after inactivity useEffect(() => { let timeout: ReturnType; const handleMove = () => { setShowControls(true); setCursorVisible(true); clearTimeout(timeout); timeout = setTimeout(() => { setShowControls(false); setCursorVisible(false); }, 2500); }; window.addEventListener("mousemove", handleMove); window.addEventListener("touchstart", handleMove); return () => { window.removeEventListener("mousemove", handleMove); window.removeEventListener("touchstart", handleMove); clearTimeout(timeout); }; }, []); const displaySlide = useMemo(() => { if (!currentSlide || !animSteps || animSteps.length === 0) return currentSlide; return { ...currentSlide, content: annotateStepsForPresentation( currentSlide.content, animSteps, currentStep, ), }; }, [currentSlide, animSteps, currentStep]); if (!currentSlide) { return (
); } const enterClass = animating ? getEnterClass(currentSlide.transition, direction) : ""; const exitClass = animating && prevIndex !== null ? getExitClass(currentSlide.transition, direction) : ""; return (
{ // If fullscreen was blocked by the browser (no user gesture), // any click in the presentation is itself a gesture — retry. if (needsFullscreenGesture) { enterFullscreen(); return; } goNext(); }} > {/* Exiting slide — rendered only during transition */} {animating && prevIndex !== null && safeSlides[prevIndex] && (
)} {/* Entering / current slide */}
{/* Controls overlay */}
e.stopPropagation()} >
{currentIndex + 1} / {safeSlides.length}
{/* Progress bar */}
{/* Fullscreen-gesture fallback — shown when the browser blocked our auto requestFullscreen() because there was no user gesture. */} {needsFullscreenGesture && ( )}
); }