@marimo-team/islands 0.23.3-dev9 → 0.23.3

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.
Files changed (42) hide show
  1. package/dist/{chat-ui-BLFhPclV.js → chat-ui-DEd_Ndal.js} +82 -82
  2. package/dist/{html-to-image-XYwXqg2E.js → html-to-image-DBosi5GK.js} +2240 -2214
  3. package/dist/main.js +2627 -2746
  4. package/dist/{process-output-BDVjDpbu.js → process-output-k-4WHpxz.js} +1 -1
  5. package/dist/{reveal-component-CrnLosc4.js → reveal-component-CFuofbBD.js} +827 -561
  6. package/dist/{slide-Dl7Rf496.js → slide-form-DgMI37ES.js} +1729 -894
  7. package/dist/style.css +1 -1
  8. package/package.json +1 -1
  9. package/src/components/editor/file-tree/renderers.tsx +1 -1
  10. package/src/components/editor/output/JsonOutput.tsx +187 -4
  11. package/src/components/editor/output/__tests__/JsonOutput-mimetype.test.tsx +80 -0
  12. package/src/components/editor/output/__tests__/json-output.test.ts +185 -2
  13. package/src/components/editor/renderers/slides-layout/__tests__/compute-slide-cells.test.ts +150 -0
  14. package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +298 -0
  15. package/src/components/editor/renderers/slides-layout/compute-slide-cells.ts +50 -0
  16. package/src/components/editor/renderers/slides-layout/plugin.tsx +54 -9
  17. package/src/components/editor/renderers/slides-layout/slides-layout.tsx +30 -12
  18. package/src/components/editor/renderers/slides-layout/types.ts +31 -3
  19. package/src/components/editor/renderers/types.ts +2 -0
  20. package/src/components/slides/__tests__/compose-slides.test.ts +433 -0
  21. package/src/components/slides/compose-slides.ts +337 -0
  22. package/src/components/slides/minimap.tsx +133 -12
  23. package/src/components/slides/reveal-component.tsx +337 -74
  24. package/src/components/slides/reveal-slides.css +33 -1
  25. package/src/components/slides/slide-form.tsx +347 -0
  26. package/src/components/ui/radio-group.tsx +5 -3
  27. package/src/core/cells/types.ts +2 -0
  28. package/src/core/islands/__tests__/bridge.test.ts +116 -5
  29. package/src/core/islands/bridge.ts +5 -1
  30. package/src/core/layout/layout.ts +6 -2
  31. package/src/core/static/__tests__/export-context.test.ts +122 -0
  32. package/src/core/static/__tests__/static-state.test.ts +80 -0
  33. package/src/core/static/export-context.ts +84 -0
  34. package/src/core/static/static-state.ts +44 -6
  35. package/src/plugins/core/RenderHTML.tsx +23 -2
  36. package/src/plugins/core/__test__/RenderHTML.test.ts +86 -1
  37. package/src/plugins/core/__test__/trusted-url.test.ts +130 -18
  38. package/src/plugins/core/sanitize.ts +11 -5
  39. package/src/plugins/core/trusted-url.ts +32 -10
  40. package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +29 -1
  41. package/src/plugins/impl/mpl-interactive/__tests__/MplInteractivePlugin.test.tsx +34 -0
  42. package/src/plugins/impl/panel/__tests__/PanelPlugin.test.ts +35 -2
@@ -1,107 +1,370 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { useEffect } from "react";
4
- import { ExpandIcon } from "lucide-react";
5
- import { Deck, Slide } from "@revealjs/react";
3
+ import {
4
+ useEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ Fragment as ReactFragment,
9
+ } from "react";
10
+ import useEvent from "react-use-event-hook";
11
+ import { ExpandIcon, EyeOffIcon } from "lucide-react";
12
+ import { Deck, Fragment, Slide, Stack } from "@revealjs/react";
6
13
  import { Slide as CellOutputSlide } from "@/components/slides/slide";
7
14
  import { Button } from "@/components/ui/button";
8
15
  import { Tooltip } from "@/components/ui/tooltip";
9
- import type { CellData, CellRuntimeState } from "@/core/cells/types";
10
- import type { RevealApi } from "reveal.js";
16
+ import type { RuntimeCell } from "@/core/cells/types";
17
+ import type { RevealApi, RevealConfig } from "reveal.js";
18
+ import { useEventListener } from "@/hooks/useEventListener";
11
19
  import { Events } from "@/utils/events";
12
20
  import { Logger } from "@/utils/Logger";
13
-
14
21
  import "./slides.css";
15
22
  import "./reveal-slides.css";
23
+ import type { SlidesLayout } from "../editor/renderers/slides-layout/types";
24
+ import {
25
+ buildSlideIndices,
26
+ composeSlides,
27
+ computeDeckNavigation,
28
+ resolveActiveCellIndex,
29
+ resolveDeckNavigationTarget,
30
+ type ComposedSubslide,
31
+ } from "./compose-slides";
32
+ import {
33
+ DEFAULT_DECK_TRANSITION,
34
+ DEFAULT_SLIDE_TYPE,
35
+ SlideSidebar,
36
+ } from "./slide-form";
37
+ import type { AppMode } from "@/core/mode";
38
+
39
+ const ASPECT_RATIO = 16 / 9;
40
+
41
+ /**
42
+ * reveal.js caches the last visited vertical index on each stack and can
43
+ * resume there on later horizontal navigation. After minimap-driven jumps we
44
+ * want stacks to re-enter from the top instead of reusing stale stack state.
45
+ */
46
+ function clearPreviousVerticalIndices(deck: RevealApi) {
47
+ const slidesEl = deck.getSlidesElement();
48
+ if (!slidesEl) {
49
+ return;
50
+ }
51
+
52
+ for (const stack of slidesEl.querySelectorAll(
53
+ "section.stack[data-previous-indexv]",
54
+ )) {
55
+ stack.removeAttribute("data-previous-indexv");
56
+ }
57
+ }
58
+
59
+ const FORWARD_NAV_KEYS = new Set([
60
+ " ",
61
+ "Spacebar",
62
+ "ArrowRight",
63
+ "ArrowDown",
64
+ "PageDown",
65
+ ]);
66
+ const BACK_NAV_KEYS = new Set(["ArrowLeft", "ArrowUp", "PageUp"]);
67
+
68
+ function classifyNavKey(event: KeyboardEvent): 1 | -1 | 0 {
69
+ if (FORWARD_NAV_KEYS.has(event.key)) {
70
+ return 1;
71
+ }
72
+ if (BACK_NAV_KEYS.has(event.key)) {
73
+ return -1;
74
+ }
75
+ return 0;
76
+ }
77
+
78
+ function useSlideDimensions(ref: React.RefObject<HTMLDivElement | null>) {
79
+ const [dims, setDims] = useState({ width: 960, height: 540 });
80
+
81
+ useEffect(() => {
82
+ const el = ref.current;
83
+ if (!el) {
84
+ return;
85
+ }
86
+
87
+ const observer = new ResizeObserver((entries) => {
88
+ const { width, height } = entries[0].contentRect;
89
+ if (width <= 0 || height <= 0) {
90
+ return;
91
+ }
92
+ const fitWidth = Math.min(width, height * ASPECT_RATIO);
93
+ const fitHeight = fitWidth / ASPECT_RATIO;
94
+ setDims({
95
+ width: Math.round(fitWidth),
96
+ height: Math.round(fitHeight),
97
+ });
98
+ });
99
+
100
+ observer.observe(el);
101
+ return () => observer.disconnect();
102
+ }, [ref]);
103
+
104
+ return dims;
105
+ }
106
+
107
+ /**
108
+ * Trigger a resize event on the window
109
+ * Vega elements need to be re-measured when the container width changes.
110
+ */
111
+ function triggerResize(deck: RevealApi | null) {
112
+ if (deck?.getCurrentSlide()?.querySelector(".vega-embed, marimo-vega")) {
113
+ requestAnimationFrame(() => {
114
+ window.dispatchEvent(new Event("resize"));
115
+ });
116
+ }
117
+ }
118
+
119
+ const SubslideView = ({
120
+ subslide,
121
+ }: {
122
+ subslide: ComposedSubslide<RuntimeCell>;
123
+ }) => (
124
+ <Slide>
125
+ <div className="h-full w-full overflow-auto flex">
126
+ <div className="mo-slide-content" style={{ margin: "auto 20px" }}>
127
+ {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
+ ));
136
+ if (block.isFragment) {
137
+ return (
138
+ <Fragment key={i} as="div">
139
+ {rendered}
140
+ </Fragment>
141
+ );
142
+ }
143
+ return <ReactFragment key={i}>{rendered}</ReactFragment>;
144
+ })}
145
+ </div>
146
+ </div>
147
+ </Slide>
148
+ );
16
149
 
17
150
  const RevealSlidesComponent = ({
18
151
  cellsWithOutput,
152
+ layout,
153
+ setLayout,
19
154
  activeIndex,
20
155
  onSlideChange,
21
156
  deckRef,
157
+ mode,
158
+ configWidth = 300, // px
22
159
  }: {
23
- cellsWithOutput: (CellRuntimeState & CellData)[];
160
+ cellsWithOutput: RuntimeCell[];
161
+ layout: SlidesLayout;
162
+ setLayout: (layout: SlidesLayout) => void;
24
163
  activeIndex?: number;
25
164
  onSlideChange?: (index: number) => void;
26
165
  deckRef: React.RefObject<RevealApi | null>;
166
+ mode: AppMode;
167
+ configWidth?: number;
27
168
  }) => {
169
+ const containerRef = useRef<HTMLDivElement>(null);
170
+ const { width, height } = useSlideDimensions(containerRef);
171
+ const activeCell =
172
+ activeIndex != null ? cellsWithOutput[activeIndex] : undefined;
173
+ // Fall back to the first cell while the deck settles on an initial slide.
174
+ // Still `undefined` when the deck is empty (handled below).
175
+ const activeConfigCell = activeCell ?? cellsWithOutput.at(0);
176
+
177
+ const composition = useMemo(
178
+ () =>
179
+ composeSlides({
180
+ cells: cellsWithOutput,
181
+ getType: (cell) =>
182
+ layout.cells.get(cell.id)?.type ?? DEFAULT_SLIDE_TYPE,
183
+ }),
184
+ [cellsWithOutput, layout.cells],
185
+ );
186
+
187
+ // Skip cells aren't part of the composed deck. When one is selected in the
188
+ // minimap we render a preview over the deck and park reveal on a neighboring
189
+ // real slide; keyboard nav while parked is handled below.
190
+ const skippedPreviewCell =
191
+ activeCell && layout.cells.get(activeCell.id)?.type === "skip"
192
+ ? activeCell
193
+ : null;
194
+
195
+ const { cellToTarget, targetToCellIndex } = useMemo(
196
+ () =>
197
+ buildSlideIndices({
198
+ composition,
199
+ cells: cellsWithOutput,
200
+ getId: (c) => c.id,
201
+ }),
202
+ [composition, cellsWithOutput],
203
+ );
204
+
205
+ const deckTransition = layout.deck?.transition ?? DEFAULT_DECK_TRANSITION;
206
+ const revealConfig: RevealConfig = useMemo(
207
+ () => ({
208
+ embedded: true,
209
+ width,
210
+ height,
211
+ center: false,
212
+ minScale: 0.2,
213
+ maxScale: 2,
214
+ transition: deckTransition,
215
+ keyboardCondition: (event: KeyboardEvent) => !Events.fromInput(event),
216
+ }),
217
+ [width, height, deckTransition],
218
+ );
219
+
28
220
  useEffect(() => {
29
221
  const deck = deckRef.current;
30
- if (deck == null || activeIndex == null) {
222
+ if (deck == null) {
223
+ return;
224
+ }
225
+ const target = resolveDeckNavigationTarget({
226
+ activeIndex,
227
+ cells: cellsWithOutput,
228
+ cellToTarget,
229
+ getId: (cell) => cell.id,
230
+ });
231
+ const next = target && computeDeckNavigation(deck.getIndices(), target);
232
+ if (!next) {
233
+ return;
234
+ }
235
+ deck.slide(next.h, next.v, next.f);
236
+ clearPreviousVerticalIndices(deck);
237
+ }, [activeIndex, cellToTarget, cellsWithOutput, deckRef]);
238
+
239
+ // Forward the deck's current cell to the parent, except while a skipped
240
+ // preview is parked: every reveal.js event during that window is an echo
241
+ // of the programmatic park (possibly with transient indices), so ignoring
242
+ // them keeps `activeCellId` pinned on the skipped cell.
243
+ const reportCurrentCell = useEvent(() => {
244
+ if (skippedPreviewCell != null) {
245
+ return;
246
+ }
247
+ const deck = deckRef.current;
248
+ if (!deck) {
249
+ return;
250
+ }
251
+ const flatIndex = resolveActiveCellIndex(
252
+ targetToCellIndex,
253
+ deck.getIndices(),
254
+ );
255
+ if (flatIndex != null) {
256
+ onSlideChange?.(flatIndex);
257
+ }
258
+ });
259
+
260
+ // While parked on a skipped preview, step through minimap order instead of
261
+ // letting reveal.js advance from the parked slide the user can't see.
262
+ const handleParkedNavKey = useEvent((event: KeyboardEvent) => {
263
+ if (!skippedPreviewCell || activeIndex == null) {
264
+ return;
265
+ }
266
+ if (Events.fromInput(event)) {
267
+ return;
268
+ }
269
+ const direction = classifyNavKey(event);
270
+ if (direction === 0) {
31
271
  return;
32
272
  }
33
- const { h } = deck.getIndices();
34
- if (h !== activeIndex) {
35
- deck.slide(activeIndex);
273
+ event.preventDefault();
274
+ event.stopPropagation();
275
+ const nextIndex = activeIndex + direction;
276
+ if (nextIndex < 0 || nextIndex >= cellsWithOutput.length) {
277
+ return;
36
278
  }
37
- }, [activeIndex, deckRef]);
279
+ onSlideChange?.(nextIndex);
280
+ });
281
+
282
+ useEventListener(document, "keydown", handleParkedNavKey, { capture: true });
38
283
 
39
284
  return (
40
- <div className="group relative h-full w-full flex-1">
41
- <Deck
42
- deckRef={deckRef}
43
- className="relative w-full h-full border rounded bg-background mo-slides-theme prose-slides"
44
- style={{ height: "100%" }}
45
- config={{
46
- embedded: true, // Avoid styles leaking out
47
- overview: false,
48
- width: "100%",
49
- height: "100%", // Both style and config height are needed to ensure the deck is full height
50
- center: false, // We are handling this manually
51
- minScale: 1,
52
- maxScale: 1,
53
- // Only enable keyboard controls when not focused on an input
54
- keyboardCondition: (event: KeyboardEvent) => {
55
- return !Events.fromInput(event);
56
- },
57
- }}
58
- onSlideChange={() => {
59
- const deck = deckRef.current;
60
- if (deck) {
61
- onSlideChange?.(deck.getIndices().h);
62
- // Trigger resize so vega-embed re-measures container width
63
- if (
64
- deck.getCurrentSlide()?.querySelector(".vega-embed, marimo-vega")
65
- ) {
66
- requestAnimationFrame(() => {
67
- window.dispatchEvent(new Event("resize"));
68
- });
69
- }
70
- }
71
- }}
285
+ <div className="flex-1 min-w-0 flex flex-row gap-3">
286
+ <div
287
+ ref={containerRef}
288
+ className="flex-1 min-w-0 flex items-center justify-center overflow-hidden"
72
289
  >
73
- {cellsWithOutput.map((cell) => (
74
- <Slide key={cell.id}>
75
- <div className="h-full w-full overflow-auto flex">
76
- <div className="mo-slide-content" style={{ margin: "auto 0" }}>
77
- <CellOutputSlide
78
- cellId={cell.id}
79
- status={cell.status}
80
- output={cell.output}
81
- />
290
+ <div className="group relative" style={{ width, height }}>
291
+ <Deck
292
+ deckRef={deckRef}
293
+ className="aspect-video w-full overflow-hidden border rounded bg-background mo-slides-theme prose-slides"
294
+ config={revealConfig}
295
+ onSlideChange={() => {
296
+ reportCurrentCell();
297
+ const deck = deckRef.current;
298
+ triggerResize(deck);
299
+ }}
300
+ onFragmentShown={reportCurrentCell}
301
+ onFragmentHidden={reportCurrentCell}
302
+ >
303
+ {composition.stacks.map((stack, i) => {
304
+ if (stack.subslides.length === 1) {
305
+ return <SubslideView key={i} subslide={stack.subslides[0]} />;
306
+ }
307
+ return (
308
+ <Stack key={i}>
309
+ {stack.subslides.map((sub, j) => (
310
+ <SubslideView key={j} subslide={sub} />
311
+ ))}
312
+ </Stack>
313
+ );
314
+ })}
315
+ </Deck>
316
+ {skippedPreviewCell && (
317
+ <div
318
+ className="absolute inset-0 z-10 border rounded bg-background flex flex-col overflow-hidden"
319
+ aria-label="Skipped in presentation"
320
+ >
321
+ <div className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-muted-foreground border-b bg-muted/40">
322
+ <EyeOffIcon className="h-3.5 w-3.5" />
323
+ <span>Skipped in presentation</span>
324
+ </div>
325
+ <div className="flex-1 overflow-auto flex">
326
+ <div
327
+ className="mo-slide-content"
328
+ style={{ margin: "auto 20px" }}
329
+ >
330
+ <CellOutputSlide
331
+ cellId={skippedPreviewCell.id}
332
+ status={skippedPreviewCell.status}
333
+ output={skippedPreviewCell.output}
334
+ />
335
+ </div>
82
336
  </div>
83
337
  </div>
84
- </Slide>
85
- ))}
86
- </Deck>
87
- <Tooltip content="Fullscreen (F)">
88
- <Button
89
- data-testid="marimo-plugin-slides-fullscreen"
90
- variant="ghost"
91
- size="icon"
92
- className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-70 text-muted-foreground transition-opacity h-7 w-7"
93
- onClick={() => {
94
- deckRef.current
95
- ?.getViewportElement()
96
- ?.requestFullscreen()
97
- .catch((error) => {
98
- Logger.error("Failed to request fullscreen", error);
99
- });
100
- }}
101
- >
102
- <ExpandIcon className="h-4 w-4" />
103
- </Button>
104
- </Tooltip>
338
+ )}
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>
357
+ </div>
358
+ </div>
359
+
360
+ {mode !== "read" && (
361
+ <SlideSidebar
362
+ configWidth={configWidth}
363
+ layout={layout}
364
+ setLayout={setLayout}
365
+ activeConfigCell={activeConfigCell}
366
+ />
367
+ )}
105
368
  </div>
106
369
  );
107
370
  };
@@ -37,6 +37,38 @@
37
37
  text-align: unset;
38
38
  }
39
39
 
40
- .reveal .slides > section {
40
+ .reveal .slides > section,
41
+ .reveal .slides > section > section {
41
42
  height: 100%;
42
43
  }
44
+
45
+ /* Reveal.js animates slides by rendering past/future neighbors in the layout
46
+ and translating them offscreen. Tailwind v4's preflight forces `[hidden]`
47
+ elements to `display: none !important`, which collapses those neighbors
48
+ and kills the transition. Reasserting `display: block` inside the same
49
+ `@layer base` lets selector specificity beat preflight's `[hidden]` rule. */
50
+ @layer base {
51
+ .reveal .slides > section.past,
52
+ .reveal .slides > section.future,
53
+ .reveal .slides > section > section.past,
54
+ .reveal .slides > section > section.future {
55
+ display: block !important;
56
+ }
57
+ }
58
+
59
+ /* Without this, the slides will animate as if from the edge of the screen. We hide this unless fullscreen */
60
+ .reveal-viewport:not(:fullscreen) {
61
+ overflow: hidden;
62
+ }
63
+
64
+ /* Reveal slides can contain multiple blocks on a single subslide (the root
65
+ cell plus one or more fragments), so we stack vertically and stretch each
66
+ block to full width so its content stays left-aligned like a single cell
67
+ would. The base `.mo-slide-content` stays a horizontal shrink-wrap flex for
68
+ swiper/minimap, which only ever render one child. */
69
+ .reveal .mo-slide-content {
70
+ flex-direction: column;
71
+ }
72
+ .reveal .mo-slide-content .output {
73
+ margin: 0;
74
+ }