/* Copyright 2026 Marimo. All rights reserved. */ import { startTransition, useEffect, useMemo, useRef, useState, Fragment as ReactFragment, } from "react"; import useEvent from "react-use-event-hook"; import { CodeIcon, ExpandIcon, EyeOffIcon } from "lucide-react"; import { Deck, Fragment, Slide, Stack } from "@revealjs/react"; import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; import { Slide as CellOutputSlide } from "@/components/slides/slide"; import { Button } from "@/components/ui/button"; import { Tooltip } from "@/components/ui/tooltip"; import type { CellId } from "@/core/cells/ids"; import type { RuntimeCell } from "@/core/cells/types"; import type { RevealApi, RevealConfig } from "reveal.js"; import { useEventListener } from "@/hooks/useEventListener"; import { Events } from "@/utils/events"; import { Logger } from "@/utils/Logger"; import "./slides.css"; import "./reveal-slides.css"; import type { SlideConfig, SlidesLayout, } from "../editor/renderers/slides-layout/types"; import { buildSlideIndices, composeSlides, computeDeckNavigation, resolveActiveCellIndex, resolveDeckNavigationTarget, type ComposedSubslide, } from "./compose-slides"; import { DEFAULT_DECK_TRANSITION, DEFAULT_SLIDE_TYPE, SlideSidebar, } from "./slide-form"; import { SlideCellReadOnlyView, SlideCellView, } from "@/components/slides/slide-cell-view"; import { SlideNotesEditor } from "./slide-notes-editor"; import { buildSubslideNotes, NOTES_DIVIDER } from "./slide-notes"; import { cn } from "@/utils/cn"; import { isIslands } from "@/core/islands/utils"; import { useNotebookCodeAvailable } from "@/core/meta/code-visibility"; import { type AppMode, kioskModeAtom } from "@/core/mode"; import { useAtomValue } from "jotai"; import RevealNotes from "reveal.js/plugin/notes"; const ASPECT_RATIO = 16 / 9; /** * reveal.js caches the last visited vertical index on each stack and can * resume there on later horizontal navigation. After minimap-driven jumps we * want stacks to re-enter from the top instead of reusing stale stack state. */ function clearPreviousVerticalIndices(deck: RevealApi) { const slidesEl = deck.getSlidesElement(); if (!slidesEl) { return; } for (const stack of slidesEl.querySelectorAll( "section.stack[data-previous-indexv]", )) { stack.removeAttribute("data-previous-indexv"); } } const FORWARD_NAV_KEYS = new Set([ " ", "Spacebar", "ArrowRight", "ArrowDown", "PageDown", ]); const BACK_NAV_KEYS = new Set(["ArrowLeft", "ArrowUp", "PageUp"]); function classifyNavKey(event: KeyboardEvent): 1 | -1 | 0 { if (FORWARD_NAV_KEYS.has(event.key)) { return 1; } if (BACK_NAV_KEYS.has(event.key)) { return -1; } return 0; } function useSlideDimensions(ref: React.RefObject) { const [dims, setDims] = useState({ width: 960, height: 540 }); useEffect(() => { const el = ref.current; if (!el) { return; } const observer = new ResizeObserver((entries) => { const { width, height } = entries[0].contentRect; if (width <= 0 || height <= 0) { return; } const fitWidth = Math.min(width, height * ASPECT_RATIO); const fitHeight = fitWidth / ASPECT_RATIO; setDims({ width: Math.round(fitWidth), height: Math.round(fitHeight), }); }); observer.observe(el); return () => observer.disconnect(); }, [ref]); return dims; } /** * Trigger a resize event on the window * Vega elements need to be re-measured when the container width changes. */ function triggerResize(deck: RevealApi | null) { if (deck?.getCurrentSlide()?.querySelector(".vega-embed, marimo-vega")) { requestAnimationFrame(() => { window.dispatchEvent(new Event("resize")); }); } } // The speaker view renders this via innerHTML with `white-space: normal`, so // we materialize `\n` as `
` and a lone `---` line as `
`. const NotesAside = ({ text }: { text: string }) => { const lines = text.split("\n"); return ( ); }; const SubslideView = ({ subslide, showCode, isEditable, slideConfigs, }: { subslide: ComposedSubslide; showCode: boolean; isEditable: boolean; slideConfigs: ReadonlyMap; }) => { const { slideLevel, cumulativeByBlock } = buildSubslideNotes( subslide, slideConfigs, ); return (
{subslide.blocks.map((block, i) => { const rendered = block.cells.map((cell) => { if (!showCode) { return ( ); } return isEditable ? ( ) : ( ); }); if (block.isFragment) { const cumulative = cumulativeByBlock.get(i); return ( {rendered} {cumulative && } ); } return {rendered}; })}
{/* Outside any `.fragment`: shown only before any fragment is revealed. */} {slideLevel && }
); }; // There is an upstream react bug in dev mode (https://github.com/facebook/react/issues/34840) // Uncaught SecurityError: Failed to read a named property '$$typeof' from 'Window' // Happens with cells containing iframes / external content const RevealSlidesComponent = ({ cellsWithOutput, layout, setLayout, activeIndex, onSlideChange, mode, configWidth = 300, // px isEditable = false, }: { cellsWithOutput: RuntimeCell[]; layout: SlidesLayout; setLayout: (layout: SlidesLayout) => void; activeIndex?: number; onSlideChange?: (index: number) => void; mode: AppMode; configWidth?: number; isEditable?: boolean; }) => { const containerRef = useRef(null); const deckRef = useRef(null); const { width, height } = useSlideDimensions(containerRef); // Skip the Notes plugin inside reveal's own speaker-view iframes so pressing // `S` there doesn't try to spawn another popup. const kioskMode = useAtomValue(kioskModeAtom); const deckPlugins = useMemo( () => (kioskMode ? [] : [RevealNotes]), [kioskMode], ); const [showCode, setShowCode] = useState(false); const codeAvailable = useNotebookCodeAvailable(cellsWithOutput); const codeToggleEnabled = !isIslands() && codeAvailable; const codeShown = codeToggleEnabled && showCode; const activeCell = activeIndex != null ? cellsWithOutput[activeIndex] : undefined; // Fall back to the first cell while the deck settles on an initial slide. // Still `undefined` when the deck is empty (handled below). const activeConfigCell = activeCell ?? cellsWithOutput.at(0); const composition = useMemo( () => composeSlides({ cells: cellsWithOutput, getType: (cell) => layout.cells.get(cell.id)?.type ?? DEFAULT_SLIDE_TYPE, }), [cellsWithOutput, layout.cells], ); // Skip cells aren't part of the composed deck. When one is selected in the // minimap we render a preview over the deck and park reveal on a neighboring // real slide; keyboard nav while parked is handled below. const skippedPreviewCell = activeCell && layout.cells.get(activeCell.id)?.type === "skip" ? activeCell : null; const { cellToTarget, targetToCellIndex } = useMemo( () => buildSlideIndices({ composition, cells: cellsWithOutput, getId: (c) => c.id, }), [composition, cellsWithOutput], ); const deckTransition = layout.deck?.transition ?? DEFAULT_DECK_TRANSITION; // Reveal's Notes plugin iframes the deck for the current/upcoming-slide // previews. We load the same URL but as a read-only kiosk client with the // app chrome hidden, which `` interprets the same as // read mode (no minimap, sidebar, or notes editor). const kioskUrl = useMemo(() => { const url = new URL(window.location.href); url.searchParams.set("kiosk", "true"); url.searchParams.set("show-chrome", "false"); return url.toString(); }, []); const revealConfig: RevealConfig = useMemo( () => ({ embedded: true, width, height, center: false, minScale: 0.2, maxScale: 2, transition: deckTransition, keyboardCondition: (event: KeyboardEvent) => !Events.fromInput(event), url: kioskUrl, }), [width, height, deckTransition, kioskUrl], ); const navigateDeckToActiveCell = useEvent((deck: RevealApi) => { const target = resolveDeckNavigationTarget({ activeIndex, cells: cellsWithOutput, cellToTarget, getId: (cell) => cell.id, }); const next = target && computeDeckNavigation(deck.getIndices(), target); if (!next) { return; } deck.slide(next.h, next.v, next.f); clearPreviousVerticalIndices(deck); }); useEffect(() => { const deck = deckRef.current; if (deck == null) { return; } navigateDeckToActiveCell(deck); }, [activeIndex, cellToTarget, cellsWithOutput, navigateDeckToActiveCell]); // Toggling code (re)mounts a CodeMirror editor on the active slide. Defer // the state update so the button/keypress paints first and the heavier mount // can be interrupted by higher-priority work. const toggleShowCode = useEvent(() => { startTransition(() => setShowCode((value) => !value)); }); const handleDeckReady = useEvent((deck: RevealApi) => { navigateDeckToActiveCell(deck); if (codeToggleEnabled) { deck.addKeyBinding( { keyCode: 67, key: "C", description: "Toggle code editor" }, toggleShowCode, ); } // Reveal listens for `keydown` on `document` and bails when // `document.activeElement` is an input/contenteditable (e.g. the speaker // notes textarea below the deck). Park focus on the deck wrapper so arrow // keys reliably advance slides without the user having to click first. const revealEl = deck.getSlidesElement()?.closest(".reveal"); if (revealEl instanceof HTMLElement) { revealEl.tabIndex = -1; revealEl.focus({ preventScroll: true }); } }); const activeSubslide = useMemo(() => { if (!activeCell) { return null; } const target = cellToTarget.get(activeCell.id); if (!target) { return null; } return { h: target.h, v: target.v }; }, [activeCell, cellToTarget]); // Forward the deck's current cell to the parent, except while a skipped // preview is parked: every reveal.js event during that window is an echo // of the programmatic park (possibly with transient indices), so ignoring // them keeps `activeCellId` pinned on the skipped cell. const reportCurrentCell = useEvent(() => { if (skippedPreviewCell != null) { return; } const deck = deckRef.current; if (!deck) { return; } const flatIndex = resolveActiveCellIndex( targetToCellIndex, deck.getIndices(), ); if (flatIndex != null) { onSlideChange?.(flatIndex); } }); // While parked on a skipped preview, step through minimap order instead of // letting reveal.js advance from the parked slide the user can't see. const handleParkedNavKey = useEvent((event: KeyboardEvent) => { if (!skippedPreviewCell || activeIndex == null) { return; } if (Events.fromInput(event)) { return; } const direction = classifyNavKey(event); if (direction === 0) { return; } event.preventDefault(); event.stopPropagation(); const nextIndex = activeIndex + direction; if (nextIndex < 0 || nextIndex >= cellsWithOutput.length) { return; } onSlideChange?.(nextIndex); }); const handleSlideChange = useEvent(() => { reportCurrentCell(); triggerResize(deckRef.current); }); useEventListener(document, "keydown", handleParkedNavKey, { capture: true }); const slideArea = (
{composition.stacks.map((stack, h) => { if (stack.subslides.length === 1) { const isActive = activeSubslide?.h === h && activeSubslide?.v === 0; return ( ); } return ( {stack.subslides.map((sub, v) => { const isActive = activeSubslide?.h === h && activeSubslide?.v === v; return ( ); })} ); })} {skippedPreviewCell && (
Skipped in presentation
)}
{codeToggleEnabled && ( )}
); if (mode === "read") { return (
{slideArea}
); } return (
{slideArea}
); }; export default RevealSlidesComponent;