Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -21,7 +22,10 @@ export const SlidesLayoutRenderer: React.FC<Props> = ({
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<CellId | null>(null);
Expand Down Expand Up @@ -52,8 +56,8 @@ export const SlidesLayoutRenderer: React.FC<Props> = ({
activeIndex={resolvedIndex}
onSlideChange={handleSlideChange}
configWidth={300}
mode={mode}
isEditable={mode !== "read"}
mode={isReading ? "read" : mode}
isEditable={!isReading}
/>
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface SlidesLayout extends Omit<
export type SlideType = "slide" | "sub-slide" | "fragment" | "skip";
export interface SlideConfig {
type?: SlideType;
speakerNotes?: string;
Comment thread
Light2Dark marked this conversation as resolved.
Outdated
}
Comment thread
Light2Dark marked this conversation as resolved.
Outdated

export type DeckTransition =
Expand Down
130 changes: 130 additions & 0 deletions frontend/src/components/slides/__tests__/slide-notes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/* 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 { composeSlides } from "../compose-slides";
import { buildSubslideNotes, collectBlockNotes } from "../slide-notes";

interface Cell {
id: CellId;
type?: SlideType;
}

const cell = (id: string, type?: SlideType): Cell => ({
id: id as CellId,
Comment thread
Light2Dark marked this conversation as resolved.
Outdated
type,
});

const configs = (
notes: Record<string, string>,
): ReadonlyMap<CellId, SlideConfig> =>
new Map(
Object.entries(notes).map(([id, speakerNotes]) => [
id as CellId,
{ 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);
});
});
Loading
Loading