diff --git a/docs/guides/apps.md b/docs/guides/apps.md index 14551a44ed2..1f2747343b9 100644 --- a/docs/guides/apps.md +++ b/docs/guides/apps.md @@ -87,7 +87,8 @@ If you prefer a slideshow-like experience, you can use the slides layout. Enable - A slide minimap on the left where you can drag and drop slides to rearrange them. - A config sidebar on the right where you can configure the type of each slide. -- Edit code and run cells by clicking the Code toggle or using the keyboard shortcut `C`. +- Edit code and run cells by clicking the Code toggle or pressing `C`. +- Add speaker notes at the bottom of each slide and launch speaker view by pressing `S`. - Powered by [reveal.js](https://revealjs.com/), so you can use most of its features like keyboard shortcuts, navigation, etc. #### Notes diff --git a/frontend/e2e-tests/slides.spec.ts b/frontend/e2e-tests/slides.spec.ts index 9497ccc9b8e..59ab91dbd41 100644 --- a/frontend/e2e-tests/slides.spec.ts +++ b/frontend/e2e-tests/slides.spec.ts @@ -30,8 +30,9 @@ test("slides", async ({ page }) => { await takeScreenshot(page, __filename); - // Reveal.js marks the active slide
with .present - const slides = slidesContainer.locator(".slides > section"); + // Reveal.js marks the active slide
with .present. We match by + // `data-index-h` (which reveal sets on each slide section) + const slides = slidesContainer.locator("section[data-index-h]"); await expect(slides.first()).toHaveClass(/present/); // Focus the deck so keyboard navigation works (embedded mode) diff --git a/frontend/src/components/editor/renderers/slides-layout/__tests__/compute-slide-cells.test.ts b/frontend/src/components/editor/renderers/slides-layout/__tests__/compute-slide-cells.test.ts index 38097f45e88..497c4f91851 100644 --- a/frontend/src/components/editor/renderers/slides-layout/__tests__/compute-slide-cells.test.ts +++ b/frontend/src/components/editor/renderers/slides-layout/__tests__/compute-slide-cells.test.ts @@ -4,6 +4,7 @@ import { describe, it, expect } from "vitest"; import { computeSlideCellsInfo } from "../compute-slide-cells"; import type { SlideConfig, SlidesLayout } from "../types"; import type { CellId } from "@/core/cells/ids"; +import { cellId } from "@/__tests__/branded"; interface TestCell { id: CellId; @@ -15,10 +16,10 @@ const DEFAULT_OUTPUT: TestCell["output"] = { data: "ok" }; const cell = ( id: string, output: TestCell["output"] = DEFAULT_OUTPUT, -): TestCell => ({ id: id as CellId, output }); +): TestCell => ({ id: cellId(id), output }); const layoutOf = (entries: Array<[string, SlideConfig]>): SlidesLayout => ({ - cells: new Map(entries.map(([id, cfg]) => [id as CellId, cfg])), + cells: new Map(entries.map(([id, cfg]) => [cellId(id), cfg])), deck: {}, }); @@ -121,7 +122,7 @@ describe("computeSlideCellsInfo", () => { // Skipped cells are still "visible" deck cells — they just aren't rendered // in reveal. The minimap relies on the full list plus skippedIds. expect(result.cellsWithOutput.map((c) => c.id)).toEqual(["a", "b", "c"]); - expect(result.slideTypes.get("b" as CellId)).toBe("skip"); + expect(result.slideTypes.get(cellId("b"))).toBe("skip"); }); it("ignores layout entries for cells that have no output", () => { @@ -137,7 +138,7 @@ describe("computeSlideCellsInfo", () => { ); expect(result.cellsWithOutput.map((c) => c.id)).toEqual(["a"]); expect(result.skippedIds.size).toBe(0); - expect(result.slideTypes.has("b" as CellId)).toBe(false); + expect(result.slideTypes.has(cellId("b"))).toBe(false); }); it("preserves the input order of cells in cellsWithOutput", () => { diff --git a/frontend/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts b/frontend/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts index 74b63cb49b4..dd8a36d4a87 100644 --- a/frontend/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +++ b/frontend/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts @@ -4,10 +4,11 @@ import { describe, it, expect } from "vitest"; import { SlidesLayoutPlugin } from "../plugin"; import type { CellData } from "@/core/cells/types"; import type { CellId } from "@/core/cells/ids"; +import { cellId } from "@/__tests__/branded"; function makeCell(id: string, code = "print('hi')"): CellData { return { - id: id as CellId, + id: cellId(id), name: id, code, edited: false, @@ -198,7 +199,10 @@ const BACKWARDS_COMPAT_SNAPSHOTS: BackwardsCompatCase[] = [ }, { // Defensive: if a future version adds a new SlideConfig field and a user - // downgrades, we must not crash on unknown keys. + // downgrades, we must not crash on unknown keys — AND we must not silently + // drop them either. `notes` / `background` aren't in the current schema; + // they must still be present after validate + (de)serialize so a downgrade + // followed by a save doesn't erase the newer marimo's data. label: "forward-compat: unknown SlideConfig fields present", input: { cells: [{ type: "slide", notes: "x", background: "#000" }], @@ -206,7 +210,42 @@ const BACKWARDS_COMPAT_SNAPSHOTS: BackwardsCompatCase[] = [ expected: { deck: {}, cellIds: ["a"], - cellEntries: [["a", { type: "slide" }]], + cellEntries: [["a", { type: "slide", notes: "x", background: "#000" }]], + }, + }, + { + // Same forward-compat guarantee for unknown deck-level fields (e.g. future + // Reveal options we haven't modeled yet). + label: "forward-compat: unknown deck fields present", + input: { + cells: [{}], + deck: { transition: "fade", controls: false, autoSlide: 5000 }, + }, + expected: { + deck: { transition: "fade", controls: false, autoSlide: 5000 }, + cellIds: ["a"], + }, + }, + { + // `speakerNotes` was added to SlideConfig. The validator must + // know about it (so it isn't silently stripped), the deserializer must + // carry it through, and serialize → deserialize must round-trip it. + label: "speakerNotes round-trips through validate + (de)serialize", + input: { + cells: [ + { type: "slide", speakerNotes: "intro" }, + { type: "fragment", speakerNotes: "" }, + { type: "fragment", speakerNotes: "multi\n\nline\n\nnotes" }, + ], + }, + expected: { + deck: {}, + cellIds: ["a", "b", "c"], + cellEntries: [ + ["a", { type: "slide", speakerNotes: "intro" }], + ["b", { type: "fragment", speakerNotes: "" }], + ["c", { type: "fragment", speakerNotes: "multi\n\nline\n\nnotes" }], + ], }, }, ]; @@ -223,20 +262,21 @@ describe("SlidesLayoutPlugin backwards compatibility", () => { parsed.success, `validator rejected: ${JSON.stringify(input)}`, ).toBe(true); + if (!parsed.success) { + return; + } - // 2. Deserialize must succeed and reflect the user-set fields. - const layout = SlidesLayoutPlugin.deserializeLayout( - // Use the raw input (not the validator output) because that is what - // `deserializeLayout` actually receives in production today. - // oxlint-disable-next-line typescript/no-explicit-any - input as any, - cells, - ); + // 2. Deserializing the *validator output* (not the raw input) must + // preserve every field listed in `expected.cellEntries`. This is what + // catches a schema regression: if the validator silently strips a + // known field, the deserialized config won't carry it and the + // assertion below fails. + const layout = SlidesLayoutPlugin.deserializeLayout(parsed.data, cells); if (expected.deck !== undefined) { expect(layout.deck).toEqual(expected.deck); } - for (const [cellId, expectedConfig] of expected.cellEntries ?? []) { - expect(layout.cells.get(cellId as CellId)).toMatchObject( + for (const [cellEntryId, expectedConfig] of expected.cellEntries ?? []) { + expect(layout.cells.get(cellId(cellEntryId))).toMatchObject( expectedConfig as object, ); } @@ -251,8 +291,8 @@ describe("SlidesLayoutPlugin backwards compatibility", () => { reserialized, cells, ); - for (const [cellId, expectedConfig] of expected.cellEntries ?? []) { - expect(redeserialized.cells.get(cellId as CellId)).toMatchObject( + for (const [cellEntryId, expectedConfig] of expected.cellEntries ?? []) { + expect(redeserialized.cells.get(cellId(cellEntryId))).toMatchObject( expectedConfig as object, ); } diff --git a/frontend/src/components/editor/renderers/slides-layout/plugin.tsx b/frontend/src/components/editor/renderers/slides-layout/plugin.tsx index 0435c2ac006..2f9c7b81d0e 100644 --- a/frontend/src/components/editor/renderers/slides-layout/plugin.tsx +++ b/frontend/src/components/editor/renderers/slides-layout/plugin.tsx @@ -1,15 +1,15 @@ /* Copyright 2026 Marimo. All rights reserved. */ -import { z } from "zod"; +import type { CellId } from "@/core/cells/ids"; +import { Logger } from "@/utils/Logger"; import type { ICellRendererPlugin } from "../types"; import { SlidesLayoutRenderer } from "./slides-layout"; -import type { - SerializedSlidesLayout, - SlideConfig, - SlidesLayout, +import { + type SerializedSlidesLayout, + type SlideConfig, + type SlidesLayout, + SlidesLayoutSchema, } from "./types"; -import { Logger } from "@/utils/Logger"; -import type { CellId } from "@/core/cells/ids"; /** * Plugin definition for the slides layout. @@ -20,24 +20,7 @@ export const SlidesLayoutPlugin: ICellRendererPlugin< > = { type: "slides", name: "Slides", - - // All fields are optional so layouts saved by older marimo versions will work - validator: z.object({ - cells: z - .array( - z.object({ - type: z.enum(["slide", "sub-slide", "fragment", "skip"]).optional(), - }), - ) - .optional(), - deck: z - .object({ - transition: z - .enum(["none", "fade", "slide", "convex", "concave", "zoom"]) - .optional(), - }) - .optional(), - }), + validator: SlidesLayoutSchema, deserializeLayout: (serialized, cells): SlidesLayout => { const serializedCells = serialized.cells ?? []; diff --git a/frontend/src/components/editor/renderers/slides-layout/slides-layout.tsx b/frontend/src/components/editor/renderers/slides-layout/slides-layout.tsx index 6f152c1df64..9b4c1edad5d 100644 --- a/frontend/src/components/editor/renderers/slides-layout/slides-layout.tsx +++ b/frontend/src/components/editor/renderers/slides-layout/slides-layout.tsx @@ -3,6 +3,7 @@ import React, { useMemo, useState } from "react"; import { useAtomValue } from "jotai"; import { numColumnsAtom } from "@/core/cells/cells"; import type { CellId } from "@/core/cells/ids"; +import { kioskModeAtom } from "@/core/mode"; import type { ICellRendererProps } from "../types"; import type { SlidesLayout } from "./types"; import { computeSlideCellsInfo } from "./compute-slide-cells"; @@ -21,7 +22,10 @@ export const SlidesLayoutRenderer: React.FC = ({ cells, mode, }) => { - const isReading = mode === "read"; + // Kiosk clients (e.g. reveal.js's speaker-view iframes) are read-only and + // shouldn't show authoring chrome, so we collapse to the read-mode layout. + const kioskMode = useAtomValue(kioskModeAtom); + const isReading = mode === "read" || kioskMode; const numColumns = useAtomValue(numColumnsAtom); const isMultiColumn = numColumns > 1; const [activeCellId, setActiveCellId] = useState(null); @@ -52,14 +56,23 @@ export const SlidesLayoutRenderer: React.FC = ({ activeIndex={resolvedIndex} onSlideChange={handleSlideChange} configWidth={300} - mode={mode} - isEditable={mode !== "read"} + mode={isReading ? "read" : mode} + isEditable={!isReading} /> ); if (isReading) { - // Cap the deck height and derive width from height via aspect-video so it stays 16:9 without - // ballooning to the full viewport on wide screens. + // In kiosk mode (e.g. reveal.js's speaker-view iframes), anchor to the + // iframe viewport with `dvh`/`dvw` so the deck resizes with the popup + // window. The non-kiosk read mode keeps its 16:9 cap so the deck doesn't + // balloon to the full viewport on wide screens. + if (kioskMode) { + return ( +
+ {slides} +
+ ); + } return (
diff --git a/frontend/src/components/editor/renderers/slides-layout/types.ts b/frontend/src/components/editor/renderers/slides-layout/types.ts index a87e7ded5aa..eee62542d49 100644 --- a/frontend/src/components/editor/renderers/slides-layout/types.ts +++ b/frontend/src/components/editor/renderers/slides-layout/types.ts @@ -1,41 +1,50 @@ /* Copyright 2026 Marimo. All rights reserved. */ -/* oxlint-disable typescript/no-empty-object-type */ +import { z } from "zod"; import type { CellId } from "@/core/cells/ids"; +const SlideTypeSchema = z.enum(["slide", "sub-slide", "fragment", "skip"]); +export type SlideType = z.infer; + +const SlideConfigSchema = z.looseObject({ + type: SlideTypeSchema.optional(), + speakerNotes: z.string().optional(), +}); +export type SlideConfig = z.infer; + +const DeckTransitionSchema = z.enum([ + "none", + "fade", + "slide", + "convex", + "concave", + "zoom", +]); +export type DeckTransition = z.infer; + +const DeckConfigSchema = z.looseObject({ + transition: DeckTransitionSchema.optional(), +}); +export type DeckConfig = z.infer; + /** - * The serialized form of a slides layout. - * This must be backwards-compatible as it is stored on the user's disk. + * Schema for the serialized form of a slides layout. + * + * This must be backwards-compatible as it is stored on the user's disk — + * fields are optional so files saved before they existed (e.g. the bare `{}` + * emitted by earlier marimo versions) still deserialize cleanly. Unknown + * keys are preserved (via `looseObject`) for the same reason. */ -// oxlint-disable-next-line typescript/consistent-type-definitions -export type SerializedSlidesLayout = { - // Both fields are optional so files saved before these existed (e.g. the - // bare `{}` emitted by earlier marimo versions) still deserialize cleanly. - deck?: DeckConfig; - cells?: SlideConfig[]; -}; +export const SlidesLayoutSchema = z.looseObject({ + cells: z.array(SlideConfigSchema).optional(), + deck: DeckConfigSchema.optional(), +}); +export type SerializedSlidesLayout = z.infer; -export interface SlidesLayout extends Omit< - SerializedSlidesLayout, - "cells" | "deck" -> { - // We map the cells to their IDs so that we can track them as they move around. +/** + * Runtime form of a slides layout. + */ +export interface SlidesLayout { cells: Map; deck: DeckConfig; } - -export type SlideType = "slide" | "sub-slide" | "fragment" | "skip"; -export interface SlideConfig { - type?: SlideType; -} - -export type DeckTransition = - | "none" - | "fade" - | "slide" - | "convex" - | "concave" - | "zoom"; -export interface DeckConfig { - transition?: DeckTransition; -} diff --git a/frontend/src/components/slides/__tests__/slide-notes.test.ts b/frontend/src/components/slides/__tests__/slide-notes.test.ts new file mode 100644 index 00000000000..5cc2692b61a --- /dev/null +++ b/frontend/src/components/slides/__tests__/slide-notes.test.ts @@ -0,0 +1,131 @@ +/* Copyright 2026 Marimo. All rights reserved. */ + +import { describe, expect, it } from "vitest"; +import type { + SlideConfig, + SlideType, +} from "@/components/editor/renderers/slides-layout/types"; +import type { CellId } from "@/core/cells/ids"; +import { cellId } from "@/__tests__/branded"; +import { composeSlides } from "../compose-slides"; +import { buildSubslideNotes, collectBlockNotes } from "../slide-notes"; + +interface Cell { + id: CellId; + type?: SlideType; +} + +const cell = (id: string, type?: SlideType): Cell => ({ + id: cellId(id), + type, +}); + +const configs = ( + notes: Record, +): ReadonlyMap => + new Map( + Object.entries(notes).map(([id, speakerNotes]) => [ + cellId(id), + { speakerNotes } satisfies SlideConfig, + ]), + ); + +const firstSubslide = (cells: Cell[]) => + composeSlides({ cells, getType: (c) => c.type ?? "slide" }).stacks[0] + .subslides[0]; + +describe("collectBlockNotes", () => { + it("concatenates non-empty notes with paragraph spacing", () => { + const result = collectBlockNotes( + [cell("a"), cell("b"), cell("c")], + configs({ a: "first", b: "", c: "third" }), + ); + expect(result).toBe("first\n\nthird"); + }); + + it("returns an empty string when no cell has notes", () => { + expect(collectBlockNotes([cell("a")], configs({}))).toBe(""); + }); + + it("ignores whitespace-only notes", () => { + expect( + collectBlockNotes([cell("a"), cell("b")], configs({ a: " ", b: "x" })), + ).toBe("x"); + }); +}); + +describe("buildSubslideNotes", () => { + it("returns empty notes when no cell has any", () => { + const subslide = firstSubslide([cell("a"), cell("b", "fragment")]); + expect(buildSubslideNotes(subslide, configs({}))).toEqual({ + slideLevel: "", + cumulativeByBlock: new Map(), + }); + }); + + it("returns only slide-level notes when there are no fragments", () => { + const subslide = firstSubslide([cell("a")]); + expect(buildSubslideNotes(subslide, configs({ a: "intro" }))).toEqual({ + slideLevel: "intro", + cumulativeByBlock: new Map(), + }); + }); + + it("accumulates fragments below the slide-level notes with a divider", () => { + const subslide = firstSubslide([ + cell("a"), + cell("b", "fragment"), + cell("c", "fragment"), + ]); + const { slideLevel, cumulativeByBlock } = buildSubslideNotes( + subslide, + configs({ a: "intro", b: "step one", c: "step two" }), + ); + expect(slideLevel).toBe("intro"); + expect(cumulativeByBlock.get(1)).toBe("intro\n\n---\n\nstep one"); + expect(cumulativeByBlock.get(2)).toBe( + "intro\n\n---\n\nstep one\n\n---\n\nstep two", + ); + }); + + it("accumulates fragments with no slide-level notes", () => { + const subslide = firstSubslide([ + cell("a"), + cell("b", "fragment"), + cell("c", "fragment"), + ]); + const { slideLevel, cumulativeByBlock } = buildSubslideNotes( + subslide, + configs({ b: "first reveal", c: "second reveal" }), + ); + expect(slideLevel).toBe(""); + expect(cumulativeByBlock.get(1)).toBe("first reveal"); + expect(cumulativeByBlock.get(2)).toBe( + "first reveal\n\n---\n\nsecond reveal", + ); + }); + + it("skips empty fragments without leaving dangling dividers", () => { + const subslide = firstSubslide([ + cell("a"), + cell("b", "fragment"), + cell("c", "fragment"), + ]); + const { cumulativeByBlock } = buildSubslideNotes( + subslide, + configs({ a: "intro", c: "third" }), + ); + expect(cumulativeByBlock.get(1)).toBe("intro"); + expect(cumulativeByBlock.get(2)).toBe("intro\n\n---\n\nthird"); + }); + + it("returns no cumulative entries when fragments and slide have no notes", () => { + const subslide = firstSubslide([ + cell("a"), + cell("b", "fragment"), + cell("c", "fragment"), + ]); + const { cumulativeByBlock } = buildSubslideNotes(subslide, configs({})); + expect(cumulativeByBlock.size).toBe(0); + }); +}); diff --git a/frontend/src/components/slides/reveal-component.tsx b/frontend/src/components/slides/reveal-component.tsx index 9d6c5a3db95..d3abba1a450 100644 --- a/frontend/src/components/slides/reveal-component.tsx +++ b/frontend/src/components/slides/reveal-component.tsx @@ -11,9 +11,11 @@ import { 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"; @@ -21,7 +23,10 @@ import { Events } from "@/utils/events"; import { Logger } from "@/utils/Logger"; import "./slides.css"; import "./reveal-slides.css"; -import type { SlidesLayout } from "../editor/renderers/slides-layout/types"; +import type { + SlideConfig, + SlidesLayout, +} from "../editor/renderers/slides-layout/types"; import { buildSlideIndices, composeSlides, @@ -39,10 +44,14 @@ 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 } from "@/core/mode"; +import { type AppMode, kioskModeAtom } from "@/core/mode"; +import { useAtomValue } from "jotai"; +import RevealNotes from "reveal.js/plugin/notes"; const ASPECT_RATIO = 16 / 9; @@ -124,56 +133,93 @@ function triggerResize(deck: RevealApi | null) { } } +// 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; -}) => ( - -
-
- {subslide.blocks.map((block, i) => { - const rendered = block.cells.map((cell) => { - if (!showCode) { + 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 isEditable ? ( - - ) : ( - - ); - }); - if (block.isFragment) { - return ( - - {rendered} - - ); - } - return {rendered}; - })} + 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' @@ -200,6 +246,13 @@ const RevealSlidesComponent = ({ 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); @@ -241,6 +294,16 @@ const RevealSlidesComponent = ({ ); 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, @@ -251,8 +314,9 @@ const RevealSlidesComponent = ({ maxScale: 2, transition: deckTransition, keyboardCondition: (event: KeyboardEvent) => !Events.fromInput(event), + url: kioskUrl, }), - [width, height, deckTransition], + [width, height, deckTransition, kioskUrl], ); const navigateDeckToActiveCell = useEvent((deck: RevealApi) => { @@ -357,126 +421,157 @@ const RevealSlidesComponent = ({ useEventListener(document, "keydown", handleParkedNavKey, { capture: true }); - return ( -
-
-
- - {composition.stacks.map((stack, h) => { - if (stack.subslides.length === 1) { - const isActive = - activeSubslide?.h === h && activeSubslide?.v === 0; - return ( - - ); - } + const slideArea = ( +
+
+ + {composition.stacks.map((stack, h) => { + if (stack.subslides.length === 1) { + const isActive = + activeSubslide?.h === h && activeSubslide?.v === 0; return ( - - {stack.subslides.map((sub, v) => { - const isActive = - activeSubslide?.h === h && activeSubslide?.v === v; - return ( - - ); - })} - + ); - })} - - {skippedPreviewCell && ( -
-
- - Skipped in presentation -
-
-
- -
+ } + return ( + + {stack.subslides.map((sub, v) => { + const isActive = + activeSubslide?.h === h && activeSubslide?.v === v; + return ( + + ); + })} + + ); + })} + + {skippedPreviewCell && ( +
+
+ + Skipped in presentation +
+
+
+
- )} -
- {codeToggleEnabled && ( - - - - )} - +
+ )} +
+ {codeToggleEnabled && ( + -
+ )} + + +
+
+ ); + + if (mode === "read") { + return ( +
{slideArea}
+ ); + } - {mode !== "read" && ( - + + + {slideArea} + + - )} + + + + +
); }; diff --git a/frontend/src/components/slides/slide-notes-editor.tsx b/frontend/src/components/slides/slide-notes-editor.tsx new file mode 100644 index 00000000000..42e8a872f43 --- /dev/null +++ b/frontend/src/components/slides/slide-notes-editor.tsx @@ -0,0 +1,127 @@ +/* Copyright 2026 Marimo. All rights reserved. */ + +import { StickyNoteIcon } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import useEvent from "react-use-event-hook"; +import type { CellId } from "@/core/cells/ids"; +import { useDebouncedCallback } from "@/hooks/useDebounce"; +import { cn } from "@/utils/cn"; +import { Events } from "@/utils/events"; +import type { SlidesLayout } from "../editor/renderers/slides-layout/types"; + +interface SlideNotesEditorProps { + layout: SlidesLayout; + setLayout: (layout: SlidesLayout) => void; + cellId: CellId | undefined; + className?: string; +} + +const PERSIST_DELAY_MS = 300; + +export const SlideNotesEditor = ({ + layout, + setLayout, + cellId, + className, +}: SlideNotesEditorProps) => { + const initialValue = cellId + ? (layout.cells.get(cellId)?.speakerNotes ?? "") + : ""; + + const [draft, setDraft] = useState(initialValue); + + // Tracks whether the user has typed something that hasn't been persisted + // yet. Used to decide if the textarea is safe to overwrite from props. + const hasPendingEditRef = useRef(false); + + // The debounced callback takes `(cellId, text)` so a `flush()` replays with + // the latest args — which means the in-flight text lands on the slide it + // was typed for, even if `cellId` has since changed. + const persistImmediate = useEvent((targetCellId: CellId, next: string) => { + hasPendingEditRef.current = false; + const existing = layout.cells.get(targetCellId); + if ((existing?.speakerNotes ?? "") === next) { + return; + } + const newCells = new Map(layout.cells); + newCells.set(targetCellId, { ...existing, speakerNotes: next }); + setLayout({ ...layout, cells: newCells }); + }); + + const persistDebounced = useDebouncedCallback( + persistImmediate, + PERSIST_DELAY_MS, + ); + + // Keep the textarea in sync with `layout`: + // - On slide switch, flush any in-flight edit to the *previous* slide before + // adopting the new slide's notes. + // - On same-slide updates (e.g. future undo/redo or external setLayout + // writers), adopt the new value only when the user isn't mid-edit so + // pending keystrokes aren't clobbered. + const prevCellIdRef = useRef(cellId); + useEffect(() => { + if (prevCellIdRef.current !== cellId) { + persistDebounced.flush(); + hasPendingEditRef.current = false; + setDraft(initialValue); + prevCellIdRef.current = cellId; + return; + } + if (!hasPendingEditRef.current && initialValue !== draft) { + setDraft(initialValue); + } + }, [cellId, initialValue, draft, persistDebounced]); + + // Flush on unmount so closing the panel / navigating away doesn't lose text. + useEffect(() => { + return () => { + persistDebounced.flush(); + }; + }, [persistDebounced]); + + const handleChange = (next: string) => { + setDraft(next); + if (cellId) { + hasPendingEditRef.current = true; + persistDebounced(cellId, next); + } + }; + + return ( +
e.stopPropagation()} + > +
+ + Speaker notes +
+
+ {cellId ? ( +