/* Copyright 2026 Marimo. All rights reserved. */ import { type CSSProperties, 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 { cellDomProps } from "@/components/editor/common"; import { Slide as CellOutputSlide } from "@/components/slides/slide"; import { SlideScrollContainer } from "@/components/slides/slide-scroll-hint"; import { Button } from "@/components/ui/button"; import { useFullScreenElement } from "@/components/ui/fullscreen"; import { Tooltip } from "@/components/ui/tooltip"; import { outputIsStale } from "@/core/cells/cell"; 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 { DeckVerticalAlign, SlideConfig, SlidesLayout, SlideType, } from "../editor/renderers/slides-layout/types"; import { buildSlideIndices, composeSlides, computeDeckNavigation, resolveActiveCellIndex, resolveDeckNavigationTarget, type ComposedSubslide, } from "./compose-slides"; import { DEFAULT_DECK_VERTICAL_ALIGN, 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 { initialRunCompletedAtom } from "@/core/kernel/state"; import { useNotebookCodeAvailable } from "@/core/meta/code-visibility"; import { type AppMode, kioskModeAtom } from "@/core/mode"; import { runtimeAdapterAtom } from "@/core/runtime/adapter"; import { useAtomValue } from "jotai"; import RevealNotes from "reveal.js/plugin/notes"; import { createSpeakerViewUrl, isSpeakerViewReceiver, supportsSpeakerView, } from "./speaker-view"; const ASPECT_RATIO = 16 / 9; const CODE_TOGGLE_KEY_CODE = 67; // Reveal retains custom key bindings after initialization. Replace the `C` // binding when code availability or receiver mode changes so it cannot keep a // stale handler. export function syncCodeToggleKeyBinding({ deck, enabled, onToggle, }: { deck: Pick; enabled: boolean; onToggle: () => void; }): void { deck.removeKeyBinding(CODE_TOGGLE_KEY_CODE); if (enabled) { deck.addKeyBinding( { keyCode: CODE_TOGGLE_KEY_CODE, key: "C", description: "Toggle code editor", }, onToggle, ); } } /** * 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; } // 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 ( ); }; /** * Resolve whether a slide cell shows its source instead of its output. * * Code is shown when either the cell's persisted `showCode` config is set or * the keyboard toggle `C` override is active for it (logical OR). */ export function shouldShowCode(options: { cells: ReadonlyMap; cellId: CellId | undefined; showCodeOverrides: ReadonlySet; codeAvailable: boolean; }): boolean { const { cells, cellId, showCodeOverrides, codeAvailable } = options; if (cellId == null || !codeAvailable) { return false; } const configured = cells.get(cellId)?.showCode ?? false; return configured || showCodeOverrides.has(cellId); } /** * The slide type a cell takes *in the composed deck*. Cells without output and * the cell currently held in the parked edit overlay are dropped (`"skip"`) so * they aren't mounted a second time in the deck — the overlay renders them * instead. Everything else uses its configured type, defaulting to a slide. */ export function deckSlideType(options: { cell: RuntimeCell; noOutputIds: ReadonlySet; heldEditCellId: CellId | null; slideConfigs: ReadonlyMap; }): SlideType { const { cell, noOutputIds, heldEditCellId, slideConfigs } = options; if (noOutputIds.has(cell.id) || cell.id === heldEditCellId) { return "skip"; } return slideConfigs.get(cell.id)?.type ?? DEFAULT_SLIDE_TYPE; } /** * Tracks the cell pinned in the parked overlay (rendered over the deck for * skipped / output-less cells, and during in-progress edits). * * A brand-new (or output-less) cell is edited in the parked overlay, which * lives outside reveal's slide DOM. The moment it first produces output it * would normally jump to its composed slide — a different React subtree that * reveal also re-syncs/transitions — tearing down the editor and dropping * focus mid-edit (e.g. typing in a new markdown cell). To avoid that we *hold* * the cell in the overlay even after it gains output, and only let it settle * into the deck once the user navigates to a different cell. * * The hold is keyed off the active cell rather than DOM focus on purpose: the * slide editor doesn't participate in the global cell-focus state, and the * active cell only changes when the user moves in the minimap — exactly when * we want to release the hold. * * Returns: * - `parkedPreviewCell`: the cell to render in the overlay * - `isHeldEdit`: whether the cell is held in the overlay * - `isNoOutputPreview`: whether the cell is output-less * - `heldEditCellId`: the id of the cell that is held in the overlay */ export function useParkedPreview(options: { activeCell: RuntimeCell | undefined; slideConfigs: ReadonlyMap; noOutputIds: ReadonlySet; }): { parkedPreviewCell: RuntimeCell | null; isHeldEdit: boolean; isNoOutputPreview: boolean; heldEditCellId: CellId | null; heldShowsCode: boolean; toggleHeldShowsCode: () => void; } { const { activeCell, slideConfigs, noOutputIds } = options; const activeCellId = activeCell?.id ?? null; const isNoOutputPreview = activeCell != null && noOutputIds.has(activeCell.id); const isSkippedPreview = activeCell != null && slideConfigs.get(activeCell.id)?.type === "skip"; // Genuinely parked: skipped in the deck, or no output to compose yet. const baseParked = isSkippedPreview || isNoOutputPreview; // The cell pinned in the overlay, tracked alongside the active cell it was // armed against so we can release it exactly when the active cell changes. const [held, setHeld] = useState<{ activeCellId: CellId | null; cellId: CellId | null; }>({ activeCellId, cellId: null }); let heldCellId = held.cellId; if (held.activeCellId !== activeCellId) { // Active cell changed: drop any prior hold, arming a fresh one only while // the new cell has no output yet (skipped cells park via `baseParked`). heldCellId = isNoOutputPreview ? activeCellId : null; setHeld({ activeCellId, cellId: heldCellId }); } else if (isNoOutputPreview && heldCellId !== activeCellId) { // Same active cell, still output-less: (re)arm the hold. heldCellId = activeCellId; setHeld({ activeCellId, cellId: heldCellId }); } const isHeldEdit = !baseParked && activeCellId != null && heldCellId === activeCellId; // Keep the held cell out of the composed deck so its editor isn't mounted a // second time (the overlay already renders it); it rejoins once released. const heldEditCellId = isHeldEdit ? heldCellId : null; // Code visibility for the held overlay. Defaults to showing the editor so it // survives the no-output -> output transition mid-edit; the `C` toggle can // hide it on demand. const [heldShow, setHeldShow] = useState<{ cellId: CellId | null; show: boolean; }>({ cellId: heldEditCellId, show: true }); let heldShowsCode = heldShow.show; if (heldShow.cellId !== heldEditCellId) { heldShowsCode = true; setHeldShow({ cellId: heldEditCellId, show: true }); } const toggleHeldShowsCode = useEvent(() => setHeldShow((prev) => ({ ...prev, show: !prev.show })), ); return { parkedPreviewCell: baseParked || isHeldEdit ? (activeCell ?? null) : null, isHeldEdit, isNoOutputPreview, heldEditCellId, heldShowsCode, toggleHeldShowsCode, }; } /** * Margin style that positions a slide's content vertically within the * full-height slide. The content is a flex item, so the vertical margins decide * where the free space lands: `auto` on both sides centers it, while pinning one * side to `0` pushes content to the top or bottom. The horizontal `20px` keeps * content off the slide edges regardless of alignment. */ function resolveSlideContentStyle( verticalAlign: DeckVerticalAlign | undefined, ): CSSProperties { switch (verticalAlign ?? DEFAULT_DECK_VERTICAL_ALIGN) { case "top": return { margin: "0 20px auto" }; case "bottom": return { margin: "auto 20px 0" }; default: return { margin: "auto 20px" }; } } /** * Renders a cell's output wrapped in a `data-cell-id`/`data-cell-name` div * (see `cellDomProps`), so it can be targeted by custom CSS in the slides * view just like it can in the vertical layout. The wrapper is required * because `CellOutputSlide` sets its own `id` from `CellOutputId` (a * different scheme than `HTMLCellId`) and its inner `OutputArea` uses * `display: contents`, which can't be targeted for background/border styling. */ export const CellOutputBlock = ({ cell }: { cell: RuntimeCell }) => (
); const SubslideView = ({ subslide, resolveShowCode, isEditable, slideConfigs, contentStyle, }: { subslide: ComposedSubslide; resolveShowCode: (cellId: CellId) => boolean; isEditable: boolean; slideConfigs: ReadonlyMap; contentStyle: CSSProperties; }) => { const { slideLevel, cumulativeByBlock } = buildSubslideNotes( subslide, slideConfigs, ); const anyCodeShown = subslide.blocks.some((block) => block.cells.some((cell) => resolveShowCode(cell.id)), ); return (
{subslide.blocks.map((block, i) => { const rendered = block.cells.map((cell) => { if (!resolveShowCode(cell.id)) { 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 && }
); }; /** * Whether the parked overlay renders the cell's *source* instead of its output. */ export function parkedRendersSource(options: { isNoOutputPreview: boolean; isEditable: boolean; showCode: boolean; }): boolean { const { isNoOutputPreview, isEditable, showCode } = options; return isNoOutputPreview ? isEditable || showCode : showCode; } const ParkedPreviewContent = ({ cell, isNoOutputPreview, isEditable, showCode, }: { cell: RuntimeCell; isNoOutputPreview: boolean; isEditable: boolean; showCode: boolean; }) => { if (parkedRendersSource({ isNoOutputPreview, isEditable, showCode })) { // Editable cells get the live editor; otherwise a read-only source view. return isEditable ? ( ) : ( ); } return ; }; // 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 = ({ slideCells, layout, setLayout, noOutputIds, activeIndex, onSlideChange, mode, configWidth, // px isEditable = false, }: { slideCells: RuntimeCell[]; layout: SlidesLayout; setLayout: (layout: SlidesLayout) => void; noOutputIds: ReadonlySet; 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); const isFullscreen = useFullScreenElement() != null; // A receiver frame cannot spawn another speaker view. WebAssembly exports // also omit the plugin because each receiver would start another runtime. const kioskMode = useAtomValue(kioskModeAtom); const runtimeKind = useAtomValue(runtimeAdapterAtom).kind; const initialRunCompleted = useAtomValue(initialRunCompletedAtom); const canResolveInitialHash = runtimeKind !== "wasm" || initialRunCompleted; const deckPlugins = useMemo( () => (kioskMode || !supportsSpeakerView(runtimeKind) ? [] : [RevealNotes]), [kioskMode, runtimeKind], ); // Store the state of the code toggle for each cell // This acts like a 'peek' at the code. const [showCodeOverrides, setShowCodeOverrides] = useState< ReadonlySet >(() => new Set()); const codeAvailable = useNotebookCodeAvailable(slideCells); const codeRenderingEnabled = !isIslands() && codeAvailable; const isSpeakerReceiver = isSpeakerViewReceiver( kioskMode, window.location.search, ); const codeToggleEnabled = !isSpeakerReceiver && codeRenderingEnabled; const activeCell = activeIndex != null ? slideCells[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 ?? slideCells.at(0); const { parkedPreviewCell, isHeldEdit, isNoOutputPreview, heldEditCellId, heldShowsCode, toggleHeldShowsCode, } = useParkedPreview({ activeCell, slideConfigs: layout.cells, noOutputIds, }); const resolveShowCode = (cellId: CellId | undefined): boolean => shouldShowCode({ cells: layout.cells, cellId, showCodeOverrides, codeAvailable: codeRenderingEnabled, }); // `C` and the toolbar button target the active slide's cell (the revealed // fragment when stepping through a stack, otherwise the lead cell). const cellIdToShowCode = activeCell?.id ?? activeConfigCell?.id; const cellShowsCode = isHeldEdit ? heldShowsCode : resolveShowCode(cellIdToShowCode); // A slide persisted with `showCode: true` always renders code const codeAlwaysShown = codeRenderingEnabled && cellIdToShowCode != null && (layout.cells.get(cellIdToShowCode)?.showCode ?? false); const composition = useMemo( () => composeSlides({ cells: slideCells, getType: (cell) => deckSlideType({ cell, noOutputIds, heldEditCellId, slideConfigs: layout.cells, }), }), [slideCells, noOutputIds, layout.cells, heldEditCellId], ); const { cellToTarget, targetToCellIndex } = useMemo( () => buildSlideIndices({ composition, cells: slideCells, getId: (c) => c.id, }), [composition, slideCells], ); const deckTransition = layout.deck?.transition ?? DEFAULT_DECK_TRANSITION; const slideContentStyle = resolveSlideContentStyle( layout.deck?.verticalAlign, ); // 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(() => { return createSpeakerViewUrl(window.location.href); }, []); 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, // reveal.js auto-switches to its scroll view for mobile. // We disable this mode because it rewrites the slide DOM that // `@revealjs/react` owns, which crashes on `reveal.sync()` re-renders. scrollActivationWidth: 0, }), [width, height, deckTransition, kioskUrl], ); const navigateDeckToActiveCell = useEvent((deck: RevealApi) => { if (activeIndex == null && window.location.hash.startsWith("#/")) { if (!canResolveInitialHash) { return; } window.dispatchEvent(new HashChangeEvent("hashchange")); clearPreviousVerticalIndices(deck); return; } const target = resolveDeckNavigationTarget({ activeIndex, cells: slideCells, 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, canResolveInitialHash, cellToTarget, slideCells, 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(() => { if (cellIdToShowCode == null || codeAlwaysShown) { return; } if (isHeldEdit) { toggleHeldShowsCode(); return; } startTransition(() => setShowCodeOverrides((prev) => { const next = new Set(prev); if (next.has(cellIdToShowCode)) { next.delete(cellIdToShowCode); } else { next.add(cellIdToShowCode); } return next; }), ); }); useEffect(() => { const deck = deckRef.current; if (deck) { syncCodeToggleKeyBinding({ deck, enabled: codeToggleEnabled, onToggle: toggleShowCode, }); } return () => { deckRef.current?.removeKeyBinding(CODE_TOGGLE_KEY_CODE); }; }, [codeToggleEnabled, toggleShowCode]); // Ignore programmatic preview navigation so the minimap selection remains // pinned to the previewed cell. const reportDeckCell = useEvent((deck: RevealApi) => { if (parkedPreviewCell != null) { return; } const flatIndex = resolveActiveCellIndex( targetToCellIndex, deck.getIndices(), ); if (flatIndex != null) { onSlideChange?.(flatIndex); } }); const reportCurrentCell = useEvent(() => { const deck = deckRef.current; if (deck) { reportDeckCell(deck); } }); const handleDeckReady = useEvent((deck: RevealApi) => { navigateDeckToActiveCell(deck); if (canResolveInitialHash) { reportDeckCell(deck); } syncCodeToggleKeyBinding({ deck, enabled: codeToggleEnabled, onToggle: 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 }); } }); // While parked on a 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 (!parkedPreviewCell || 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 >= slideCells.length) { return; } onSlideChange?.(nextIndex); }); const handleSlideChange = useEvent(() => { reportCurrentCell(); }); useEventListener(document, "keydown", handleParkedNavKey, { capture: true }); // `isHeldEdit` means the cell already produces output and is only kept in the // overlay so the editor survives the edit, so the parked banners don't apply. const parkedPreviewLabel = isHeldEdit ? null : isNoOutputPreview ? "Hidden as there is no output" : "Skipped in presentation"; const parkedShowCode = isHeldEdit ? heldShowsCode : resolveShowCode(parkedPreviewCell?.id); const parkedShowsSource = parkedRendersSource({ isNoOutputPreview, isEditable, showCode: parkedShowCode, }); const slideArea = (
{composition.stacks.map((stack, h) => { if (stack.subslides.length === 1) { return ( ); } return ( {stack.subslides.map((sub, v) => { return ( ); })} ); })} {parkedPreviewCell && (
{parkedPreviewLabel && (
{parkedPreviewLabel}
)}
)}
{codeToggleEnabled && ( )}
); if (mode === "read") { return (
{slideArea}
); } return (
{slideArea}
); }; export default RevealSlidesComponent;