@marimo-team/islands 0.23.5-dev1 → 0.23.5-dev11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import {
4
+ startTransition,
4
5
  useEffect,
5
6
  useMemo,
6
7
  useRef,
@@ -8,7 +9,7 @@ import {
8
9
  Fragment as ReactFragment,
9
10
  } from "react";
10
11
  import useEvent from "react-use-event-hook";
11
- import { ExpandIcon, EyeOffIcon } from "lucide-react";
12
+ import { CodeIcon, ExpandIcon, EyeOffIcon } from "lucide-react";
12
13
  import { Deck, Fragment, Slide, Stack } from "@revealjs/react";
13
14
  import { Slide as CellOutputSlide } from "@/components/slides/slide";
14
15
  import { Button } from "@/components/ui/button";
@@ -34,6 +35,13 @@ import {
34
35
  DEFAULT_SLIDE_TYPE,
35
36
  SlideSidebar,
36
37
  } from "./slide-form";
38
+ import {
39
+ SlideCellReadOnlyView,
40
+ SlideCellView,
41
+ } from "@/components/slides/slide-cell-view";
42
+ import { cn } from "@/utils/cn";
43
+ import { isIslands } from "@/core/islands/utils";
44
+ import { useNotebookCodeAvailable } from "@/core/meta/code-visibility";
37
45
  import type { AppMode } from "@/core/mode";
38
46
 
39
47
  const ASPECT_RATIO = 16 / 9;
@@ -118,21 +126,41 @@ function triggerResize(deck: RevealApi | null) {
118
126
 
119
127
  const SubslideView = ({
120
128
  subslide,
129
+ showCode,
130
+ isEditable,
121
131
  }: {
122
132
  subslide: ComposedSubslide<RuntimeCell>;
133
+ showCode: boolean;
134
+ isEditable: boolean;
123
135
  }) => (
124
136
  <Slide>
125
137
  <div className="h-full w-full overflow-auto flex">
126
- <div className="mo-slide-content" style={{ margin: "auto 20px" }}>
138
+ <div
139
+ className={
140
+ showCode ? "mo-slide-content flex flex-col gap-3" : "mo-slide-content"
141
+ }
142
+ style={{
143
+ margin: "auto 20px",
144
+ }}
145
+ >
127
146
  {subslide.blocks.map((block, i) => {
128
- const rendered = block.cells.map((cell) => (
129
- <CellOutputSlide
130
- key={cell.id}
131
- cellId={cell.id}
132
- status={cell.status}
133
- output={cell.output}
134
- />
135
- ));
147
+ const rendered = block.cells.map((cell) => {
148
+ if (!showCode) {
149
+ return (
150
+ <CellOutputSlide
151
+ key={cell.id}
152
+ cellId={cell.id}
153
+ status={cell.status}
154
+ output={cell.output}
155
+ />
156
+ );
157
+ }
158
+ return isEditable ? (
159
+ <SlideCellView key={cell.id} cell={cell} />
160
+ ) : (
161
+ <SlideCellReadOnlyView key={cell.id} cell={cell} />
162
+ );
163
+ });
136
164
  if (block.isFragment) {
137
165
  return (
138
166
  <Fragment key={i} as="div">
@@ -147,27 +175,37 @@ const SubslideView = ({
147
175
  </Slide>
148
176
  );
149
177
 
178
+ // There is an upstream react bug in dev mode (https://github.com/facebook/react/issues/34840)
179
+ // Uncaught SecurityError: Failed to read a named property '$$typeof' from 'Window'
180
+ // Happens with cells containing iframes / external content
150
181
  const RevealSlidesComponent = ({
151
182
  cellsWithOutput,
152
183
  layout,
153
184
  setLayout,
154
185
  activeIndex,
155
186
  onSlideChange,
156
- deckRef,
157
187
  mode,
158
188
  configWidth = 300, // px
189
+ isEditable = false,
159
190
  }: {
160
191
  cellsWithOutput: RuntimeCell[];
161
192
  layout: SlidesLayout;
162
193
  setLayout: (layout: SlidesLayout) => void;
163
194
  activeIndex?: number;
164
195
  onSlideChange?: (index: number) => void;
165
- deckRef: React.RefObject<RevealApi | null>;
166
196
  mode: AppMode;
167
197
  configWidth?: number;
198
+ isEditable?: boolean;
168
199
  }) => {
169
200
  const containerRef = useRef<HTMLDivElement>(null);
201
+ const deckRef = useRef<RevealApi | null>(null);
170
202
  const { width, height } = useSlideDimensions(containerRef);
203
+
204
+ const [showCode, setShowCode] = useState(false);
205
+ const codeAvailable = useNotebookCodeAvailable(cellsWithOutput);
206
+ const codeToggleEnabled = !isIslands() && codeAvailable;
207
+ const codeShown = codeToggleEnabled && showCode;
208
+
171
209
  const activeCell =
172
210
  activeIndex != null ? cellsWithOutput[activeIndex] : undefined;
173
211
  // Fall back to the first cell while the deck settles on an initial slide.
@@ -217,11 +255,7 @@ const RevealSlidesComponent = ({
217
255
  [width, height, deckTransition],
218
256
  );
219
257
 
220
- useEffect(() => {
221
- const deck = deckRef.current;
222
- if (deck == null) {
223
- return;
224
- }
258
+ const navigateDeckToActiveCell = useEvent((deck: RevealApi) => {
225
259
  const target = resolveDeckNavigationTarget({
226
260
  activeIndex,
227
261
  cells: cellsWithOutput,
@@ -234,7 +268,43 @@ const RevealSlidesComponent = ({
234
268
  }
235
269
  deck.slide(next.h, next.v, next.f);
236
270
  clearPreviousVerticalIndices(deck);
237
- }, [activeIndex, cellToTarget, cellsWithOutput, deckRef]);
271
+ });
272
+
273
+ useEffect(() => {
274
+ const deck = deckRef.current;
275
+ if (deck == null) {
276
+ return;
277
+ }
278
+ navigateDeckToActiveCell(deck);
279
+ }, [activeIndex, cellToTarget, cellsWithOutput, navigateDeckToActiveCell]);
280
+
281
+ // Toggling code (re)mounts a CodeMirror editor on the active slide. Defer
282
+ // the state update so the button/keypress paints first and the heavier mount
283
+ // can be interrupted by higher-priority work.
284
+ const toggleShowCode = useEvent(() => {
285
+ startTransition(() => setShowCode((value) => !value));
286
+ });
287
+
288
+ const handleDeckReady = useEvent((deck: RevealApi) => {
289
+ navigateDeckToActiveCell(deck);
290
+ if (codeToggleEnabled) {
291
+ deck.addKeyBinding(
292
+ { keyCode: 67, key: "C", description: "Toggle code editor" },
293
+ toggleShowCode,
294
+ );
295
+ }
296
+ });
297
+
298
+ const activeSubslide = useMemo(() => {
299
+ if (!activeCell) {
300
+ return null;
301
+ }
302
+ const target = cellToTarget.get(activeCell.id);
303
+ if (!target) {
304
+ return null;
305
+ }
306
+ return { h: target.h, v: target.v };
307
+ }, [activeCell, cellToTarget]);
238
308
 
239
309
  // Forward the deck's current cell to the parent, except while a skipped
240
310
  // preview is parked: every reveal.js event during that window is an echo
@@ -266,6 +336,7 @@ const RevealSlidesComponent = ({
266
336
  if (Events.fromInput(event)) {
267
337
  return;
268
338
  }
339
+
269
340
  const direction = classifyNavKey(event);
270
341
  if (direction === 0) {
271
342
  return;
@@ -279,6 +350,11 @@ const RevealSlidesComponent = ({
279
350
  onSlideChange?.(nextIndex);
280
351
  });
281
352
 
353
+ const handleSlideChange = useEvent(() => {
354
+ reportCurrentCell();
355
+ triggerResize(deckRef.current);
356
+ });
357
+
282
358
  useEventListener(document, "keydown", handleParkedNavKey, { capture: true });
283
359
 
284
360
  return (
@@ -292,23 +368,38 @@ const RevealSlidesComponent = ({
292
368
  deckRef={deckRef}
293
369
  className="aspect-video w-full overflow-hidden border rounded bg-background mo-slides-theme prose-slides"
294
370
  config={revealConfig}
295
- onSlideChange={() => {
296
- reportCurrentCell();
297
- const deck = deckRef.current;
298
- triggerResize(deck);
299
- }}
371
+ onReady={handleDeckReady}
372
+ onSlideChange={handleSlideChange}
300
373
  onFragmentShown={reportCurrentCell}
301
374
  onFragmentHidden={reportCurrentCell}
302
375
  >
303
- {composition.stacks.map((stack, i) => {
376
+ {composition.stacks.map((stack, h) => {
304
377
  if (stack.subslides.length === 1) {
305
- return <SubslideView key={i} subslide={stack.subslides[0]} />;
378
+ const isActive =
379
+ activeSubslide?.h === h && activeSubslide?.v === 0;
380
+ return (
381
+ <SubslideView
382
+ key={h}
383
+ subslide={stack.subslides[0]}
384
+ showCode={codeShown && isActive}
385
+ isEditable={isEditable}
386
+ />
387
+ );
306
388
  }
307
389
  return (
308
- <Stack key={i}>
309
- {stack.subslides.map((sub, j) => (
310
- <SubslideView key={j} subslide={sub} />
311
- ))}
390
+ <Stack key={h}>
391
+ {stack.subslides.map((sub, v) => {
392
+ const isActive =
393
+ activeSubslide?.h === h && activeSubslide?.v === v;
394
+ return (
395
+ <SubslideView
396
+ key={v}
397
+ subslide={sub}
398
+ showCode={codeShown && isActive}
399
+ isEditable={isEditable}
400
+ />
401
+ );
402
+ })}
312
403
  </Stack>
313
404
  );
314
405
  })}
@@ -336,24 +427,45 @@ const RevealSlidesComponent = ({
336
427
  </div>
337
428
  </div>
338
429
  )}
339
- <Tooltip content="Fullscreen (F)">
340
- <Button
341
- data-testid="marimo-plugin-slides-fullscreen"
342
- variant="ghost"
343
- size="icon"
344
- className="absolute top-2 right-2 z-20 opacity-0 group-hover:opacity-70 text-muted-foreground transition-opacity h-7 w-7"
345
- onClick={() => {
346
- deckRef.current
347
- ?.getViewportElement()
348
- ?.requestFullscreen()
349
- .catch((error) => {
350
- Logger.error("Failed to request fullscreen", error);
351
- });
352
- }}
353
- >
354
- <ExpandIcon className="h-4 w-4" />
355
- </Button>
356
- </Tooltip>
430
+ <div className="absolute top-2 right-2 z-20 opacity-0 group-hover:opacity-70 text-muted-foreground transition-opacity">
431
+ {codeToggleEnabled && (
432
+ <Tooltip content={codeShown ? "Hide code (C)" : "Show code (C)"}>
433
+ <Button
434
+ data-testid="marimo-plugin-slides-toggle-code"
435
+ variant="ghost"
436
+ size="icon"
437
+ className={cn(
438
+ "text-muted-foreground h-7 w-7",
439
+ codeShown && "text-foreground bg-muted",
440
+ )}
441
+ aria-pressed={codeShown}
442
+ aria-label={codeShown ? "Hide code" : "Show code"}
443
+ onClick={toggleShowCode}
444
+ >
445
+ <CodeIcon className="h-4 w-4" />
446
+ </Button>
447
+ </Tooltip>
448
+ )}
449
+ <Tooltip content="Fullscreen (F)">
450
+ <Button
451
+ data-testid="marimo-plugin-slides-fullscreen"
452
+ variant="ghost"
453
+ size="icon"
454
+ className="text-muted-foreground h-7 w-7"
455
+ aria-label="Enter fullscreen"
456
+ onClick={() => {
457
+ deckRef.current
458
+ ?.getViewportElement()
459
+ ?.requestFullscreen()
460
+ .catch((error) => {
461
+ Logger.error("Failed to request fullscreen", error);
462
+ });
463
+ }}
464
+ >
465
+ <ExpandIcon className="h-4 w-4" />
466
+ </Button>
467
+ </Tooltip>
468
+ </div>
357
469
  </div>
358
470
  </div>
359
471
 
@@ -72,3 +72,11 @@
72
72
  .reveal .mo-slide-content .output {
73
73
  margin: 0;
74
74
  }
75
+
76
+ .reveal .marimo-cell .cm-editor {
77
+ border-radius: 8px;
78
+ padding-right: 10px;
79
+ }
80
+ .reveal .marimo-cell .cm-panels {
81
+ margin-right: 0;
82
+ }
@@ -0,0 +1,182 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { useMemo, useRef, useState } from "react";
4
+ import type { EditorView } from "@codemirror/view";
5
+ import { useAtomValue } from "jotai";
6
+ import { CellEditor } from "@/components/editor/cell/code/cell-editor";
7
+ import { CellStatusComponent } from "@/components/editor/cell/CellStatus";
8
+ import { RunButton } from "@/components/editor/cell/RunButton";
9
+ import { StopButton } from "@/components/editor/cell/StopButton";
10
+ import { useRunCell } from "@/components/editor/cell/useRunCells";
11
+ import { Slide as CellOutputSlide } from "@/components/slides/slide";
12
+ import { useUserConfig } from "@/core/config/config";
13
+ import {
14
+ cellNeedsRun,
15
+ cellStatusClasses,
16
+ isUninstantiated,
17
+ } from "@/core/cells/utils";
18
+ import type { CellData, CellRuntimeState } from "@/core/cells/types";
19
+ import type { LanguageAdapterType } from "@/core/codemirror/language/types";
20
+ import { connectionAtom } from "@/core/network/connection";
21
+ import { useTheme } from "@/theme/useTheme";
22
+ import { cn } from "@/utils/cn";
23
+ import { ReadonlyCode } from "../editor/code/readonly-python-code";
24
+ import { languageAdapterFromCode } from "@/core/codemirror/language/extension";
25
+
26
+ type RuntimeCell = CellRuntimeState & CellData;
27
+
28
+ /**
29
+ * Renders a single cell in the slides view as an editable CodeMirror editor
30
+ * stacked with its output, mirroring the notebook layout. Editing and
31
+ * Ctrl/Cmd+Enter run the cell against the live kernel so presenters can iterate
32
+ * without leaving the slide deck.
33
+ */
34
+ export const SlideCellView = ({ cell }: { cell: RuntimeCell }) => {
35
+ const [userConfig] = useUserConfig();
36
+ const { theme } = useTheme();
37
+ const runCell = useRunCell(cell.id);
38
+ const connection = useAtomValue(connectionAtom);
39
+ const editorViewRef = useRef<EditorView | null>(null);
40
+ const editorViewParentRef = useRef<HTMLDivElement | null>(null);
41
+ const [languageAdapter, setLanguageAdapter] = useState<
42
+ LanguageAdapterType | undefined
43
+ >();
44
+
45
+ const cellOutputPosition = userConfig.display.cell_output;
46
+ const hasOutput = cell.output != null;
47
+
48
+ const uninstantiated = isUninstantiated({
49
+ executionTime: cell.runElapsedTimeMs ?? cell.lastExecutionTime,
50
+ status: cell.status,
51
+ errored: cell.errored,
52
+ interrupted: cell.interrupted,
53
+ stopped: cell.stopped,
54
+ });
55
+
56
+ const needsRun = cellNeedsRun({
57
+ edited: cell.edited,
58
+ interrupted: cell.interrupted,
59
+ staleInputs: cell.staleInputs,
60
+ disabled: cell.config.disabled,
61
+ status: cell.status,
62
+ });
63
+
64
+ const editorWrapperClassName = cn(
65
+ "marimo-cell",
66
+ "hover-actions-parent",
67
+ "interactive",
68
+ cellStatusClasses({
69
+ needsRun,
70
+ errored: cell.errored,
71
+ stopped: cell.stopped,
72
+ disabled: cell.config.disabled,
73
+ status: cell.status,
74
+ }),
75
+ );
76
+
77
+ const output = (
78
+ <CellOutputSlide
79
+ cellId={cell.id}
80
+ status={cell.status}
81
+ output={cell.output}
82
+ />
83
+ );
84
+
85
+ const toolbar = (
86
+ <div className="absolute top-1 right-2 z-10 flex items-center gap-1.5">
87
+ <CellStatusComponent
88
+ editing={true}
89
+ status={cell.status}
90
+ disabled={cell.config.disabled ?? false}
91
+ staleInputs={cell.staleInputs}
92
+ edited={cell.edited}
93
+ interrupted={cell.interrupted}
94
+ elapsedTime={cell.runElapsedTimeMs ?? cell.lastExecutionTime}
95
+ runStartTimestamp={cell.runStartTimestamp}
96
+ lastRunStartTimestamp={cell.lastRunStartTimestamp}
97
+ uninstantiated={uninstantiated}
98
+ />
99
+ <div className="flex items-center shadow-none gap-1">
100
+ <RunButton
101
+ edited={cell.edited}
102
+ status={cell.status}
103
+ needsRun={needsRun}
104
+ connectionState={connection.state}
105
+ config={cell.config}
106
+ onClick={runCell}
107
+ />
108
+ <StopButton status={cell.status} connectionState={connection.state} />
109
+ </div>
110
+ </div>
111
+ );
112
+
113
+ const editor = (
114
+ <div className={editorWrapperClassName}>
115
+ <CellEditor
116
+ theme={theme}
117
+ showPlaceholder={false}
118
+ id={cell.id}
119
+ code={cell.code}
120
+ config={cell.config}
121
+ status={cell.status}
122
+ serializedEditorState={cell.serializedEditorState}
123
+ runCell={runCell}
124
+ setEditorView={(ev) => {
125
+ editorViewRef.current = ev;
126
+ }}
127
+ userConfig={userConfig}
128
+ editorViewRef={editorViewRef}
129
+ editorViewParentRef={editorViewParentRef}
130
+ hasOutput={hasOutput}
131
+ // hide_code is intentionally overridden in the slide view; the editor
132
+ // is unmounted entirely when the user toggles code off.
133
+ showHiddenCode={() => undefined}
134
+ languageAdapter={languageAdapter}
135
+ setLanguageAdapter={setLanguageAdapter}
136
+ showLanguageToggles={false}
137
+ outputArea={cellOutputPosition}
138
+ />
139
+ {toolbar}
140
+ </div>
141
+ );
142
+
143
+ return (
144
+ <>
145
+ {cellOutputPosition === "above" && output}
146
+ {editor}
147
+ {cellOutputPosition === "below" && output}
148
+ </>
149
+ );
150
+ };
151
+
152
+ export const SlideCellReadOnlyView = ({ cell }: { cell: RuntimeCell }) => {
153
+ const [userConfig] = useUserConfig();
154
+ const cellOutputPosition = userConfig.display.cell_output;
155
+
156
+ const language = useMemo(() => {
157
+ const adapter = languageAdapterFromCode(cell.code.trim());
158
+ return adapter.type === "sql" ? "sql" : "python";
159
+ }, [cell.code]);
160
+
161
+ const output = (
162
+ <CellOutputSlide
163
+ cellId={cell.id}
164
+ status={cell.status}
165
+ output={cell.output}
166
+ />
167
+ );
168
+
169
+ const editor = (
170
+ <div className="marimo-cell">
171
+ <ReadonlyCode code={cell.code} language={language} showHideCode={false} />
172
+ </div>
173
+ );
174
+
175
+ return (
176
+ <>
177
+ {cellOutputPosition === "above" && output}
178
+ {editor}
179
+ {cellOutputPosition === "below" && output}
180
+ </>
181
+ );
182
+ };
@@ -288,6 +288,8 @@ export const SlideSidebar = ({
288
288
  width: isConfigOpen ? configWidth : COLLAPSED_CONFIG_WIDTH,
289
289
  }}
290
290
  aria-label="Slide configuration"
291
+ // Prevent keys from bubbling up to reveal.js's document-level keydown listener and moving the deck.
292
+ onKeyDown={(e) => e.stopPropagation()}
291
293
  >
292
294
  <header
293
295
  className={cn(
@@ -140,3 +140,48 @@ export function isUninstantiated({
140
140
  !(errored || interrupted || stopped)
141
141
  );
142
142
  }
143
+
144
+ /**
145
+ * Whether a cell needs to be run given its edited / interrupted / stale
146
+ * inputs, while accounting for ancestor-disabled cells (which should not be
147
+ * flagged as needing a run until re-enabled).
148
+ */
149
+ export function cellNeedsRun({
150
+ edited,
151
+ interrupted,
152
+ staleInputs,
153
+ disabled,
154
+ status,
155
+ }: {
156
+ edited: boolean;
157
+ interrupted: boolean;
158
+ staleInputs: boolean;
159
+ disabled: boolean | undefined;
160
+ status: RuntimeState;
161
+ }): boolean {
162
+ const disabledOrAncestorDisabled =
163
+ disabled || status === "disabled-transitively";
164
+ return edited || interrupted || (staleInputs && !disabledOrAncestorDisabled);
165
+ }
166
+
167
+ export function cellStatusClasses({
168
+ needsRun,
169
+ errored,
170
+ stopped,
171
+ disabled,
172
+ status,
173
+ }: {
174
+ needsRun: boolean;
175
+ errored: boolean;
176
+ stopped: boolean;
177
+ disabled: boolean | undefined;
178
+ status: RuntimeState;
179
+ }) {
180
+ return {
181
+ "needs-run": needsRun,
182
+ "has-error": errored,
183
+ stopped,
184
+ disabled: disabled ?? false,
185
+ stale: status === "disabled-transitively",
186
+ };
187
+ }
@@ -0,0 +1,30 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import type { RuntimeCell } from "@/core/cells/types";
4
+ import { Logger } from "@/utils/Logger";
5
+
6
+ /**
7
+ * Build-time stub for `@/components/slides/slide-cell-view`, wired up via
8
+ * `resolve.alias` in `frontend/islands/vite.config.mts`. Islands embeds gate
9
+ * off the slides "show code" toggle entirely (see `useNotebookCodeAvailable`
10
+ * + `isIslands()`), so neither view is reachable at runtime there.
11
+ *
12
+ * Replacing the module at build time keeps the entire CodeMirror /
13
+ * Codeium / `@bufbuild/protobuf` import subtree out of the islands bundle,
14
+ * which both shrinks the bundle and lets `islands/validate.sh` pass — the
15
+ * upstream protobuf code contains a `process.env.BUF_BIGINT_DISABLE`
16
+ * runtime check that the validator otherwise flags.
17
+ */
18
+ export const SlideCellView = (_props: { cell: RuntimeCell }) => {
19
+ Logger.warn(
20
+ "SlideCellView islands stub rendered; this should never happen in a read-only embed.",
21
+ );
22
+ return null;
23
+ };
24
+
25
+ export const SlideCellReadOnlyView = (_props: { cell: RuntimeCell }) => {
26
+ Logger.warn(
27
+ "SlideCellReadOnlyView islands stub rendered; this should never happen in a read-only embed.",
28
+ );
29
+ return null;
30
+ };