{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"slide-viewer","type":"registry:ui","title":"SlideViewer","description":"Read-only full-screen viewer with keyboard nav.","package":"fancy-slides","dependencies":[],"registryDependencies":["types","theme","slide","registry"],"files":[{"path":"components\/fancy\/slide-viewer\/SlideViewer.tsx","content":"import { useCallback, useEffect, useRef, useState, type ReactNode } from \"react\";\r\nimport type { Deck, SlideElement, SlideTransition } from \"..\/..\/types\";\r\nimport { resolveTheme } from \"..\/..\/theme\/theme-utils\";\r\nimport { Slide } from \"..\/Slide\";\r\nimport { useSlideKeyboard } from \"..\/..\/hooks\/use-slide-keyboard\";\r\nimport { totalBuildSteps } from \"..\/..\/utils\/builds\";\r\nimport { cn } from \"..\/..\/utils\/cn\";\r\nimport { defaultElementRegistry } from \"..\/..\/registry\";\r\n\r\nexport interface SlideViewerProps {\r\n    \/** Deck to play. *\/\r\n    deck: Deck;\r\n    \/** Controlled current slide index. Use with `onIndexChange`. *\/\r\n    index?: number;\r\n    \/** Default current slide index (uncontrolled). *\/\r\n    defaultIndex?: number;\r\n    \/** Called when the viewer advances. *\/\r\n    onIndexChange?: (index: number) => void;\r\n    \/** Called when the viewer exits (Esc). *\/\r\n    onExit?: () => void;\r\n    \/** Auto-advance interval in ms \u2014 kiosk mode. Omit to disable. *\/\r\n    autoAdvanceMs?: number;\r\n    \/** Hide the bottom progress bar + slide counter. *\/\r\n    hideChrome?: boolean;\r\n    \/**\r\n     * Custom renderer for element types Slide doesn't render natively\r\n     * (chart\/code\/table\/embed). Defaults to the built-in\r\n     * `defaultElementRegistry` so decks play fully out of the box; pass your\r\n     * own to override.\r\n     *\/\r\n    renderElement?: (element: SlideElement, slideWidthPx: number) => ReactNode | undefined;\r\n    \/** Extra classes on the viewer wrapper. *\/\r\n    className?: string;\r\n}\r\n\r\n\/**\r\n * Read-only deck viewer. Renders one slide at a time at the maximum size\r\n * that fits the container while preserving the theme's aspect ratio.\r\n * Keyboard nav is built in; expand a fullscreen-ready container around\r\n * `<SlideViewer>` to get the F11-style experience.\r\n *\/\r\nexport function SlideViewer({\r\n    deck,\r\n    index: controlledIndex,\r\n    defaultIndex,\r\n    onIndexChange,\r\n    onExit,\r\n    autoAdvanceMs,\r\n    hideChrome = false,\r\n    renderElement = defaultElementRegistry,\r\n    className,\r\n}: SlideViewerProps) {\r\n    const isControlled = controlledIndex !== undefined;\r\n    const [internalIndex, setInternalIndex] = useState(defaultIndex ?? 0);\r\n    const index = isControlled ? controlledIndex! : internalIndex;\r\n\r\n    const goTo = useCallback(\r\n        (i: number) => {\r\n            const clamped = Math.max(0, Math.min(deck.slides.length - 1, i));\r\n            if (!isControlled) setInternalIndex(clamped);\r\n            onIndexChange?.(clamped);\r\n        },\r\n        [deck.slides.length, isControlled, onIndexChange],\r\n    );\r\n\r\n    const [blanked, setBlanked] = useState(false);\r\n    const containerRef = useRef<HTMLDivElement>(null);\r\n\r\n    \/\/ Track nav direction so `slide` transitions default sensibly: advancing\r\n    \/\/ forward enters from the right, going back enters from the left.\r\n    const prevIndexRef = useRef(index);\r\n    const forward = index >= prevIndexRef.current;\r\n\r\n    \/\/ \u2500\u2500\u2500 Intra-slide build (entrance-animation) step-through \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n    \/\/ 0 = nothing built yet on the current slide. Each forward advance fires\r\n    \/\/ the next step until `totalBuildSteps`, after which we move to the next\r\n    \/\/ slide. Going backward lands on the previous slide fully built.\r\n    const slide = deck.slides[index];\r\n    const totalSteps = totalBuildSteps(slide);\r\n    const [buildStep, setBuildStep] = useState(0);\r\n\r\n    \/\/ Reset the build step on slide change. Only a forward advance INTO the next\r\n    \/\/ slide replays builds from 0; every other landing (retreat, Home\/End,\r\n    \/\/ digit jump, external control) shows the slide fully built. `nextFreshRef`\r\n    \/\/ flags the one forward-advance case.\r\n    const nextFreshRef = useRef(false);\r\n    useEffect(() => {\r\n        if (index === prevIndexRef.current) return;\r\n        prevIndexRef.current = index;\r\n        const fresh = nextFreshRef.current;\r\n        nextFreshRef.current = false;\r\n        setBuildStep(fresh ? 0 : totalBuildSteps(deck.slides[index]));\r\n    }, [index, deck.slides]);\r\n\r\n    const advance = useCallback(() => {\r\n        if (buildStep < totalSteps) {\r\n            setBuildStep((s) => s + 1);\r\n        } else if (index < deck.slides.length - 1) {\r\n            nextFreshRef.current = true; \/\/ next slide starts with nothing built\r\n            goTo(index + 1);\r\n        }\r\n    }, [buildStep, totalSteps, index, deck.slides.length, goTo]);\r\n\r\n    const retreat = useCallback(() => {\r\n        \/\/ v1: reversing individual builds is out of scope \u2014 step back a whole\r\n        \/\/ slide, showing it fully built (handled by the reset effect).\r\n        if (index > 0) goTo(index - 1);\r\n    }, [index, goTo]);\r\n\r\n    useSlideKeyboard({\r\n        total: deck.slides.length,\r\n        index,\r\n        goTo,\r\n        onAdvance: advance,\r\n        onRetreat: retreat,\r\n        onExit,\r\n        onBlank: () => setBlanked((b) => !b),\r\n        onFullscreen: () => {\r\n            const el = containerRef.current;\r\n            if (!el) return;\r\n            if (document.fullscreenElement) document.exitFullscreen();\r\n            else el.requestFullscreen?.();\r\n        },\r\n    });\r\n\r\n    \/\/ Auto-advance loop for kiosk mode \u2014 steps through builds then slides.\r\n    useEffect(() => {\r\n        if (!autoAdvanceMs || deck.slides.length <= 1) return;\r\n        const t = setTimeout(() => {\r\n            if (buildStep < totalSteps) {\r\n                setBuildStep((s) => s + 1);\r\n            } else {\r\n                nextFreshRef.current = true;\r\n                goTo(index + 1 < deck.slides.length ? index + 1 : 0);\r\n            }\r\n        }, autoAdvanceMs);\r\n        return () => clearTimeout(t);\r\n    }, [autoAdvanceMs, index, deck.slides.length, goTo, buildStep, totalSteps]);\r\n    const theme = resolveTheme(deck.theme);\r\n    const aspectRatio = theme.aspectRatio ?? 16 \/ 9;\r\n\r\n    \/\/ Resolve the incoming slide's entrance transition (slide \u2192 theme default \u2192 none).\r\n    const transition: SlideTransition | undefined = slide?.transition ?? theme.defaultTransition;\r\n    const enterStyle = transitionEnterStyle(transition, forward);\r\n\r\n    return (\r\n        <div\r\n            ref={containerRef}\r\n            className={cn(\"fs-viewer\", className)}\r\n            style={{\r\n                position: \"relative\",\r\n                width: \"100%\",\r\n                height: \"100%\",\r\n                background: blanked ? \"#000000\" : theme.colors?.background ?? \"#000000\",\r\n                display: \"flex\",\r\n                alignItems: \"center\",\r\n                justifyContent: \"center\",\r\n                overflow: \"hidden\",\r\n            }}\r\n            tabIndex={0}\r\n            data-fancy-slides-viewer={deck.id}\r\n            data-fancy-slides-build-step={buildStep}\r\n            onClick={() => {\r\n                if (blanked) return;\r\n                advance();\r\n            }}\r\n        >\r\n            {\/* Keyframes for slide entrance transitions. Pure CSS \u2014 no runtime deps.\r\n                Gated behind prefers-reduced-motion so animations vanish entirely\r\n                for users who ask for less motion. *\/}\r\n            <style>{TRANSITION_KEYFRAMES}<\/style>\r\n\r\n            {!blanked && slide && (\r\n                <div\r\n                    style={{\r\n                        \/\/ Box that fits the slide while preserving aspect ratio.\r\n                        width: \"min(100%, calc(100vh * var(--fs-ratio)))\",\r\n                        aspectRatio: String(aspectRatio),\r\n                        \/\/ CSS var lets us inline-style the aspect ratio so it works in any container.\r\n                        [\"--fs-ratio\" as keyof React.CSSProperties as string]: aspectRatio.toString(),\r\n                        boxShadow: \"0 8px 30px rgba(0,0,0,0.35)\",\r\n                    } as React.CSSProperties}\r\n                >\r\n                    {\/* Keyed by index so React remounts on every slide change and the\r\n                        enter animation replays from its first frame. *\/}\r\n                    <div key={index} className=\"fs-slide-enter\" style={enterStyle}>\r\n                        <Slide slide={slide} theme={theme} buildStep={buildStep} renderElement={renderElement} \/>\r\n                    <\/div>\r\n                <\/div>\r\n            )}\r\n\r\n            {!hideChrome && !blanked && (\r\n                <div\r\n                    style={{\r\n                        position: \"absolute\",\r\n                        bottom: 16,\r\n                        right: 16,\r\n                        padding: \"4px 10px\",\r\n                        borderRadius: 999,\r\n                        background: \"rgba(15, 23, 42, 0.6)\",\r\n                        color: \"#f8fafc\",\r\n                        fontSize: 12,\r\n                        fontFamily: theme.fonts?.mono,\r\n                        backdropFilter: \"blur(6px)\",\r\n                    }}\r\n                    aria-label=\"Slide counter\"\r\n                >\r\n                    {index + 1} \/ {deck.slides.length}\r\n                <\/div>\r\n            )}\r\n        <\/div>\r\n    );\r\n}\r\n\r\n\/\/ \u2500\u2500\u2500 Transitions \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n\r\nconst DEFAULT_DURATION = 400;\r\nconst EASE = \"cubic-bezier(0.16, 1, 0.3, 1)\"; \/\/ ease-out\r\n\r\n\/**\r\n * Build the inline style that drives a slide's entrance animation. The actual\r\n * keyframes live in {@link TRANSITION_KEYFRAMES}; here we just pick the right\r\n * `animation-name` + duration. `prefers-reduced-motion: reduce` is handled in\r\n * CSS (the keyframe-bearing rules are wrapped in a media query), so a reduced-\r\n * motion user simply sees the final frame with no movement.\r\n *\/\r\nfunction transitionEnterStyle(transition: SlideTransition | undefined, forward: boolean): React.CSSProperties {\r\n    const kind = transition?.kind ?? \"none\";\r\n    if (kind === \"none\") return { width: \"100%\", height: \"100%\" };\r\n\r\n    const duration = transition?.duration ?? DEFAULT_DURATION;\r\n    let name: string;\r\n    switch (kind) {\r\n        case \"fade\":\r\n            name = \"fs-fade-in\";\r\n            break;\r\n        case \"zoom\":\r\n            name = \"fs-zoom-in\";\r\n            break;\r\n        case \"slide\": {\r\n            const dir = transition?.direction ?? (forward ? \"right\" : \"left\");\r\n            name = `fs-slide-in-${dir}`;\r\n            break;\r\n        }\r\n        default:\r\n            return { width: \"100%\", height: \"100%\" };\r\n    }\r\n\r\n    return {\r\n        width: \"100%\",\r\n        height: \"100%\",\r\n        animationName: name,\r\n        animationDuration: `${duration}ms`,\r\n        animationTimingFunction: EASE,\r\n        animationFillMode: \"both\",\r\n    };\r\n}\r\n\r\n\/**\r\n * Keyframes for every transition kind. Wrapped in\r\n * `@media (prefers-reduced-motion: no-preference)` so that reduced-motion users\r\n * get no animation at all \u2014 `animation-fill-mode: both` would otherwise pin the\r\n * element at its `from` frame, so we disable the animation entirely instead.\r\n *\/\r\nconst TRANSITION_KEYFRAMES = `\r\n@media (prefers-reduced-motion: reduce) {\r\n    .fs-slide-enter { animation: none !important; }\r\n}\r\n@media (prefers-reduced-motion: no-preference) {\r\n    @keyframes fs-fade-in {\r\n        from { opacity: 0; }\r\n        to { opacity: 1; }\r\n    }\r\n    @keyframes fs-zoom-in {\r\n        from { opacity: 0; transform: scale(0.92); }\r\n        to { opacity: 1; transform: scale(1); }\r\n    }\r\n    @keyframes fs-slide-in-right {\r\n        from { opacity: 0; transform: translateX(8%); }\r\n        to { opacity: 1; transform: translateX(0); }\r\n    }\r\n    @keyframes fs-slide-in-left {\r\n        from { opacity: 0; transform: translateX(-8%); }\r\n        to { opacity: 1; transform: translateX(0); }\r\n    }\r\n    @keyframes fs-slide-in-up {\r\n        from { opacity: 0; transform: translateY(8%); }\r\n        to { opacity: 1; transform: translateY(0); }\r\n    }\r\n    @keyframes fs-slide-in-down {\r\n        from { opacity: 0; transform: translateY(-8%); }\r\n        to { opacity: 1; transform: translateY(0); }\r\n    }\r\n}\r\n`;\r\n","type":"registry:ui","target":"components\/fancy\/slide-viewer\/SlideViewer.tsx"},{"path":"components\/fancy\/slide-viewer\/index.ts","content":"export { SlideViewer } from \".\/SlideViewer\";\r\nexport type { SlideViewerProps } from \".\/SlideViewer\";\r\n","type":"registry:ui","target":"components\/fancy\/slide-viewer\/index.ts"}]}