{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"presenter-view","type":"registry:ui","title":"PresenterView","description":"Speaker-only side view \u2014 current slide + next slide + notes + clock + timer.","package":"fancy-slides","dependencies":[],"registryDependencies":["types","theme","slide"],"files":[{"path":"components\/fancy\/presenter-view\/PresenterView.tsx","content":"import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from \"react\";\r\nimport type { Deck, SlideElement } 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\n\r\nexport interface PresenterViewProps {\r\n    \/** Deck being presented. *\/\r\n    deck: Deck;\r\n    \/** Controlled current slide index. *\/\r\n    index?: number;\r\n    \/** Default current slide index (uncontrolled). *\/\r\n    defaultIndex?: number;\r\n    \/** Called when the presenter advances. *\/\r\n    onIndexChange?: (index: number) => void;\r\n    \/** Called when the presenter exits (Esc). *\/\r\n    onExit?: () => void;\r\n    \/** Reset the elapsed timer to this `Date.now()` value. Defaults to mount time. *\/\r\n    startedAt?: number;\r\n    \/** Optional custom renderer for non-built-in element types. *\/\r\n    renderElement?: (element: SlideElement, slideWidthPx: number) => ReactNode | undefined;\r\n    className?: string;\r\n}\r\n\r\n\/**\r\n * Speaker-only side view designed to live on a second monitor while the\r\n * audience sees a `<SlideViewer>`. Layout:\r\n *\r\n *   \u250c\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\u252c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510\r\n *   \u2502                            \u2502  next slide        \u2502\r\n *   \u2502                            \u251c\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\r\n *   \u2502  current slide             \u2502                    \u2502\r\n *   \u2502  (largest viewport)        \u2502  speaker notes     \u2502\r\n *   \u2502                            \u2502                    \u2502\r\n *   \u251c\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\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524\r\n *   \u2502  3 \/ 12   elapsed 04:21   clock 14:35   prev \/  \u2502\r\n *   \u2502                                          next   \u2502\r\n *   \u2514\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\u2518\r\n *\r\n * Keyboard: same set as SlideViewer \u2014 \u2190\/\u2192\/Space\/Esc\/Home\/End\/B\/F\/1-9.\r\n * Notes pane shows the current slide's `notes` field (rendered as\r\n * preformatted text for now; markdown rendering arrives with the\r\n * ContentRenderer integration in 0.2).\r\n *\/\r\nexport function PresenterView({\r\n    deck,\r\n    index: controlledIndex,\r\n    defaultIndex,\r\n    onIndexChange,\r\n    onExit,\r\n    startedAt,\r\n    renderElement,\r\n    className,\r\n}: PresenterViewProps) {\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    \/\/ Mirror SlideViewer's build step-through so the speaker drives builds too.\r\n    const slide = deck.slides[index];\r\n    const totalSteps = totalBuildSteps(slide);\r\n    const [buildStep, setBuildStep] = useState(0);\r\n    const prevIndexRef = useRef(index);\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;\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        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    });\r\n\r\n    const theme = resolveTheme(deck.theme);\r\n    const nextSlide = deck.slides[index + 1];\r\n\r\n    \/\/ Tick once a second for the clock + elapsed timer.\r\n    const [now, setNow] = useState(() => Date.now());\r\n    useEffect(() => {\r\n        const id = setInterval(() => setNow(Date.now()), 1000);\r\n        return () => clearInterval(id);\r\n    }, []);\r\n    const startedAtRef = useMemo(() => startedAt ?? now, [startedAt]); \/\/ initial mount captures now\r\n    \/\/ eslint-disable-next-line react-hooks\/exhaustive-deps -- intentional: capture mount time once\r\n\r\n    return (\r\n        <div\r\n            className={cn(\"fs-presenter\", className)}\r\n            style={{\r\n                display: \"grid\",\r\n                gridTemplateRows: \"1fr auto\",\r\n                gridTemplateColumns: \"minmax(0, 2fr) minmax(0, 1fr)\",\r\n                width: \"100%\",\r\n                height: \"100%\",\r\n                background: \"#0b1220\",\r\n                color: \"#f8fafc\",\r\n                fontFamily: theme.fonts?.body,\r\n            }}\r\n            data-fancy-slides-presenter={deck.id}\r\n        >\r\n            {\/* Current slide \u2014 left, full height of the upper row *\/}\r\n            <div\r\n                style={{\r\n                    gridRow: 1,\r\n                    gridColumn: 1,\r\n                    padding: 24,\r\n                    display: \"flex\",\r\n                    alignItems: \"center\",\r\n                    justifyContent: \"center\",\r\n                    minHeight: 0,\r\n                }}\r\n            >\r\n                <div\r\n                    style={{\r\n                        width: \"100%\",\r\n                        aspectRatio: String(theme.aspectRatio ?? 16 \/ 9),\r\n                        maxHeight: \"100%\",\r\n                        boxShadow: \"0 12px 40px rgba(0,0,0,0.5)\",\r\n                        borderRadius: 8,\r\n                        overflow: \"hidden\",\r\n                    }}\r\n                >\r\n                    {slide ? <Slide slide={slide} theme={theme} buildStep={buildStep} renderElement={renderElement} \/> : null}\r\n                <\/div>\r\n            <\/div>\r\n\r\n            {\/* Right column \u2014 next slide on top, notes below *\/}\r\n            <div\r\n                style={{\r\n                    gridRow: 1,\r\n                    gridColumn: 2,\r\n                    display: \"grid\",\r\n                    gridTemplateRows: \"auto 1fr\",\r\n                    gap: 12,\r\n                    padding: 24,\r\n                    paddingLeft: 0,\r\n                    minHeight: 0,\r\n                }}\r\n            >\r\n                <div>\r\n                    <SectionLabel>Up next<\/SectionLabel>\r\n                    {nextSlide ? (\r\n                        <div\r\n                            style={{\r\n                                marginTop: 8,\r\n                                width: \"100%\",\r\n                                aspectRatio: String(theme.aspectRatio ?? 16 \/ 9),\r\n                                boxShadow: \"0 4px 16px rgba(0,0,0,0.4)\",\r\n                                borderRadius: 6,\r\n                                overflow: \"hidden\",\r\n                                opacity: 0.85,\r\n                            }}\r\n                        >\r\n                            <Slide slide={nextSlide} theme={theme} renderElement={renderElement} \/>\r\n                        <\/div>\r\n                    ) : (\r\n                        <div\r\n                            style={{\r\n                                marginTop: 8,\r\n                                display: \"grid\",\r\n                                placeItems: \"center\",\r\n                                aspectRatio: String(theme.aspectRatio ?? 16 \/ 9),\r\n                                borderRadius: 6,\r\n                                border: \"1px dashed rgba(255,255,255,0.2)\",\r\n                                color: \"rgba(255,255,255,0.4)\",\r\n                                fontSize: 13,\r\n                            }}\r\n                        >\r\n                            End of deck\r\n                        <\/div>\r\n                    )}\r\n                <\/div>\r\n                <div style={{ display: \"flex\", flexDirection: \"column\", minHeight: 0 }}>\r\n                    <SectionLabel>Speaker notes<\/SectionLabel>\r\n                    <pre\r\n                        style={{\r\n                            marginTop: 8,\r\n                            flex: 1,\r\n                            overflow: \"auto\",\r\n                            background: \"rgba(255,255,255,0.04)\",\r\n                            border: \"1px solid rgba(255,255,255,0.08)\",\r\n                            borderRadius: 6,\r\n                            padding: 12,\r\n                            fontFamily: theme.fonts?.body,\r\n                            fontSize: 15,\r\n                            lineHeight: 1.5,\r\n                            whiteSpace: \"pre-wrap\",\r\n                            wordBreak: \"break-word\",\r\n                            color: \"rgba(248,250,252,0.92)\",\r\n                            margin: 0,\r\n                        }}\r\n                    >\r\n                        {slide?.notes?.trim() || (\r\n                            <span style={{ color: \"rgba(255,255,255,0.35)\", fontStyle: \"italic\" }}>\r\n                                No notes for this slide.\r\n                            <\/span>\r\n                        )}\r\n                    <\/pre>\r\n                <\/div>\r\n            <\/div>\r\n\r\n            {\/* Status bar \u2014 bottom, spans both columns *\/}\r\n            <div\r\n                style={{\r\n                    gridRow: 2,\r\n                    gridColumn: \"1 \/ span 2\",\r\n                    display: \"flex\",\r\n                    alignItems: \"center\",\r\n                    gap: 24,\r\n                    padding: \"12px 24px\",\r\n                    borderTop: \"1px solid rgba(255,255,255,0.1)\",\r\n                    fontSize: 13,\r\n                    color: \"rgba(248,250,252,0.7)\",\r\n                    fontFamily: theme.fonts?.mono,\r\n                }}\r\n            >\r\n                <StatusChip label=\"Slide\">\r\n                    {index + 1} \/ {deck.slides.length}\r\n                <\/StatusChip>\r\n                <StatusChip label=\"Elapsed\">{formatElapsed(now - startedAtRef)}<\/StatusChip>\r\n                <StatusChip label=\"Clock\">{formatClock(now)}<\/StatusChip>\r\n                <div style={{ marginLeft: \"auto\", display: \"flex\", gap: 8 }}>\r\n                    <NavButton onClick={retreat} disabled={index === 0}>\r\n                        \u2190 Prev\r\n                    <\/NavButton>\r\n                    <NavButton onClick={advance} disabled={index >= deck.slides.length - 1 && buildStep >= totalSteps}>\r\n                        Next \u2192\r\n                    <\/NavButton>\r\n                <\/div>\r\n            <\/div>\r\n        <\/div>\r\n    );\r\n}\r\n\r\nfunction SectionLabel({ children }: { children: ReactNode }) {\r\n    return (\r\n        <div\r\n            style={{\r\n                fontSize: 10,\r\n                textTransform: \"uppercase\",\r\n                letterSpacing: \"0.08em\",\r\n                color: \"rgba(248,250,252,0.5)\",\r\n                fontWeight: 600,\r\n            }}\r\n        >\r\n            {children}\r\n        <\/div>\r\n    );\r\n}\r\n\r\nfunction StatusChip({ label, children }: { label: string; children: ReactNode }) {\r\n    return (\r\n        <div style={{ display: \"inline-flex\", alignItems: \"baseline\", gap: 6 }}>\r\n            <span\r\n                style={{\r\n                    fontSize: 10,\r\n                    textTransform: \"uppercase\",\r\n                    letterSpacing: \"0.08em\",\r\n                    color: \"rgba(248,250,252,0.4)\",\r\n                }}\r\n            >\r\n                {label}\r\n            <\/span>\r\n            <span style={{ color: \"rgba(248,250,252,0.92)\", fontWeight: 600 }}>{children}<\/span>\r\n        <\/div>\r\n    );\r\n}\r\n\r\nfunction NavButton({ onClick, disabled, children }: { onClick: () => void; disabled?: boolean; children: ReactNode }) {\r\n    return (\r\n        <button\r\n            onClick={onClick}\r\n            disabled={disabled}\r\n            style={{\r\n                padding: \"4px 10px\",\r\n                borderRadius: 6,\r\n                background: \"rgba(255,255,255,0.08)\",\r\n                border: \"1px solid rgba(255,255,255,0.15)\",\r\n                color: \"rgba(248,250,252,0.92)\",\r\n                cursor: disabled ? \"not-allowed\" : \"pointer\",\r\n                opacity: disabled ? 0.4 : 1,\r\n                fontSize: 13,\r\n                fontFamily: \"inherit\",\r\n            }}\r\n        >\r\n            {children}\r\n        <\/button>\r\n    );\r\n}\r\n\r\nfunction formatClock(ms: number): string {\r\n    const d = new Date(ms);\r\n    return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;\r\n}\r\n\r\nfunction formatElapsed(ms: number): string {\r\n    const totalSec = Math.max(0, Math.floor(ms \/ 1000));\r\n    const h = Math.floor(totalSec \/ 3600);\r\n    const m = Math.floor((totalSec % 3600) \/ 60);\r\n    const s = totalSec % 60;\r\n    if (h > 0) return `${h}:${pad(m)}:${pad(s)}`;\r\n    return `${pad(m)}:${pad(s)}`;\r\n}\r\n\r\nfunction pad(n: number): string {\r\n    return n < 10 ? `0${n}` : String(n);\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/presenter-view\/PresenterView.tsx"},{"path":"components\/fancy\/presenter-view\/index.ts","content":"export { PresenterView } from \".\/PresenterView\";\r\nexport type { PresenterViewProps } from \".\/PresenterView\";\r\n","type":"registry:ui","target":"components\/fancy\/presenter-view\/index.ts"}]}