@marimo-team/frontend 0.23.1-dev7 → 0.23.1-dev9

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 (78) hide show
  1. package/dist/assets/{CellStatus-1vKptZLk.js → CellStatus-zTcdYfqx.js} +1 -1
  2. package/dist/assets/{JsonOutput-BsAmBgmA.js → JsonOutput-BY31ccA7.js} +1 -1
  3. package/dist/assets/{MarimoErrorOutput-BrEeS6om.js → MarimoErrorOutput--Yd2Aw0J.js} +1 -1
  4. package/dist/assets/{RenderHTML-DvfCr2Ox.js → RenderHTML-CbuarQqA.js} +1 -1
  5. package/dist/assets/{add-cell-with-ai-Dt7oHdbm.js → add-cell-with-ai-_Y6SqxBB.js} +1 -1
  6. package/dist/assets/{add-connection-dialog-CspEHMf4.js → add-connection-dialog-CjvNOKgb.js} +1 -1
  7. package/dist/assets/{agent-panel-BnU7gQWx.js → agent-panel-C24uwabG.js} +1 -1
  8. package/dist/assets/{ai-model-dropdown-Xik-y-L5.js → ai-model-dropdown-Dyxi3_nW.js} +1 -1
  9. package/dist/assets/{app-config-button-CHBHFhjc.js → app-config-button-BT2Do4RJ.js} +1 -1
  10. package/dist/assets/{cell-editor-D_3l2zTD.js → cell-editor-zW0u82sK.js} +1 -1
  11. package/dist/assets/{cell-link-DJVe6_Zu.js → cell-link-CRkrHl-y.js} +1 -1
  12. package/dist/assets/{cells-S7rO4svP.js → cells-BqYYXi6G.js} +69 -69
  13. package/dist/assets/{chat-display-CfEsEqVW.js → chat-display-DsHMZa9F.js} +1 -1
  14. package/dist/assets/{chat-panel-qK2fGD8c.js → chat-panel-o9D3upnX.js} +1 -1
  15. package/dist/assets/{chat-ui-D0Zk2tGi.js → chat-ui-BYS03y86.js} +1 -1
  16. package/dist/assets/{column-preview-BZ6dVJcf.js → column-preview-Dwv5a_zE.js} +1 -1
  17. package/dist/assets/{command-palette-1FgTXBti.js → command-palette-BYbKGSF3.js} +1 -1
  18. package/dist/assets/{common-BmgcLq5w.js → common-DeoGL9rK.js} +1 -1
  19. package/dist/assets/{components-B_dPGsbP.js → components-CDgxb-5o.js} +1 -1
  20. package/dist/assets/{components-D2yZZbqm.js → components-DKHyHZBv.js} +1 -1
  21. package/dist/assets/{datasource-t6MwjjVj.js → datasource-COFRe84u.js} +1 -1
  22. package/dist/assets/{dependency-graph-panel-BadtKupA.js → dependency-graph-panel-BXSe6z1R.js} +1 -1
  23. package/dist/assets/{documentation-panel-BsLlmX7w.js → documentation-panel-CA2pWMgB.js} +1 -1
  24. package/dist/assets/{download-Do1WPYs4.js → download-5XbM3TL_.js} +1 -1
  25. package/dist/assets/edit-page-CMUN3ESy.js +9 -0
  26. package/dist/assets/{error-panel-CBVjdcTs.js → error-panel-CbqfK1HJ.js} +1 -1
  27. package/dist/assets/{file-explorer-panel-DYR37L0M.js → file-explorer-panel-CbS8z-JR.js} +1 -1
  28. package/dist/assets/{file-icons-Ce885dch.js → file-icons-Bj5YoM7H.js} +1 -1
  29. package/dist/assets/{floating-outline-x0sdO8LG.js → floating-outline-XObNWtN8.js} +1 -1
  30. package/dist/assets/{focus-DTtb8f52.js → focus-DzMo6UAI.js} +1 -1
  31. package/dist/assets/{form-D_Nha4Lp.js → form-DLyXacSF.js} +1 -1
  32. package/dist/assets/{home-page-CoJ_ZMWR.js → home-page-BUdd5uTz.js} +1 -1
  33. package/dist/assets/{hooks-Cx6iKOXA.js → hooks-kZJc1iBf.js} +1 -1
  34. package/dist/assets/{html-to-image-B2vXpMPW.js → html-to-image-DGqJ93hW.js} +1 -1
  35. package/dist/assets/{index-B_D5e64b.js → index-Bm25ctN7.js} +22 -22
  36. package/dist/assets/index-CKRn_SiB.css +2 -0
  37. package/dist/assets/{kiosk-mode-FcVQMZAH.js → kiosk-mode-DYHoqMaZ.js} +1 -1
  38. package/dist/assets/layout-tmN-U1zs.js +9 -0
  39. package/dist/assets/{logs-panel-Dsopo0A4.js → logs-panel-CRW4c2IL.js} +1 -1
  40. package/dist/assets/{markdown-renderer-Ds5PRrQP.js → markdown-renderer-DNANigO8.js} +1 -1
  41. package/dist/assets/{name-cell-input-CYTm4rHn.js → name-cell-input-3iKP6YTw.js} +1 -1
  42. package/dist/assets/{outline-panel-C6Gebwlt.js → outline-panel-VIqWcHj6.js} +1 -1
  43. package/dist/assets/{packages-panel-Cx5Im5-h.js → packages-panel-D_z4ylBE.js} +1 -1
  44. package/dist/assets/panels-CLfdzLPR.js +1 -0
  45. package/dist/assets/{process-output-DxNLeVL1.js → process-output-Q6wVr7a-.js} +1 -1
  46. package/dist/assets/{readonly-python-code-CB7U_Wc5.js → readonly-python-code-CI_b818F.js} +1 -1
  47. package/dist/assets/{run-page-CGoGL9nm.js → run-page-DPuH6QY4.js} +1 -1
  48. package/dist/assets/{scratchpad-panel-DR4mmtqX.js → scratchpad-panel-BsMm0GQP.js} +1 -1
  49. package/dist/assets/{session-panel-DGqZrbYK.js → session-panel-CTDzGShO.js} +1 -1
  50. package/dist/assets/{slides-component-BIXn0Nqk.js → slides-component-ncUJNz7U.js} +1 -1
  51. package/dist/assets/{snippets-panel-CU_AkTo5.js → snippets-panel-CWof0wHk.js} +1 -1
  52. package/dist/assets/{state-Di6_R3-d.js → state-BvnlMKdT.js} +1 -1
  53. package/dist/assets/{state-iGDxMYGl.js → state-DPomuurt.js} +1 -1
  54. package/dist/assets/{textarea-cV4DzEoq.js → textarea-CS2o3y4W.js} +1 -1
  55. package/dist/assets/{tracing-6MHdsIto.js → tracing-CPDDwzIA.js} +1 -1
  56. package/dist/assets/{tracing-panel-Cwuf0kYN.js → tracing-panel-Ku1LapXJ.js} +2 -2
  57. package/dist/assets/{useAddCell-DDDgUZhC.js → useAddCell-B6yUY_RG.js} +1 -1
  58. package/dist/assets/{useCellActionButton-BceYv-6H.js → useCellActionButton-SxeK4dmW.js} +1 -1
  59. package/dist/assets/{useDeleteCell-CwBNr3-p.js → useDeleteCell-DHUjJQJx.js} +1 -1
  60. package/dist/assets/{useDependencyPanelTab-Bjv6Z79M.js → useDependencyPanelTab-CflgayoH.js} +1 -1
  61. package/dist/assets/{useNotebookActions-0DS32qpY.js → useNotebookActions-DHBEqrc_.js} +1 -1
  62. package/dist/assets/{useRunCells-Bf82xWy5.js → useRunCells-DFYAOTWd.js} +1 -1
  63. package/dist/assets/{useSplitCell-WZ71D3bV.js → useSplitCell-Bh-NZsBl.js} +1 -1
  64. package/dist/index.html +23 -23
  65. package/package.json +1 -1
  66. package/src/components/editor/renderers/slides-layout/slides-layout.tsx +50 -44
  67. package/src/components/slides/__tests__/minimap.test.ts +402 -0
  68. package/src/components/slides/minimap.tsx +534 -0
  69. package/src/components/slides/slide.tsx +29 -0
  70. package/src/components/slides/slides-component.tsx +16 -1
  71. package/src/core/cells/__tests__/cells.test.ts +105 -1
  72. package/src/core/cells/cells.ts +43 -0
  73. package/src/core/cells/document-changes.ts +2 -1
  74. package/src/plugins/impl/matplotlib/matplotlib-renderer.ts +38 -14
  75. package/dist/assets/edit-page-CSyxrzTp.js +0 -13
  76. package/dist/assets/index-qO0a4zuT.css +0 -2
  77. package/dist/assets/layout-DW9T7Upe.js +0 -5
  78. package/dist/assets/panels-CDCHQBRn.js +0 -1
@@ -0,0 +1,534 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { useCellActions, useCellIds } from "@/core/cells/cells";
4
+ import type { CellId } from "@/core/cells/ids";
5
+ import type { CellColumnId } from "@/utils/id-tree";
6
+ import { useEffect, useRef, useState } from "react";
7
+ import type { ICellRendererProps } from "../editor/renderers/types";
8
+ import type { SlidesLayout } from "../editor/renderers/slides-layout/types";
9
+ import {
10
+ DndContext,
11
+ DragOverlay,
12
+ PointerSensor,
13
+ useSensor,
14
+ useSensors,
15
+ type DragMoveEvent,
16
+ type DragOverEvent,
17
+ type DragStartEvent,
18
+ type DragEndEvent,
19
+ closestCenter,
20
+ pointerWithin,
21
+ type CollisionDetection,
22
+ type UniqueIdentifier,
23
+ } from "@dnd-kit/core";
24
+ import {
25
+ SortableContext,
26
+ useSortable,
27
+ verticalListSortingStrategy,
28
+ } from "@dnd-kit/sortable";
29
+ import { restrictToVerticalAxis } from "@dnd-kit/modifiers";
30
+ import { cn } from "@/utils/cn";
31
+ import { Slide } from "./slide";
32
+ import { InfoIcon } from "lucide-react";
33
+ import { Logger } from "@/utils/Logger";
34
+
35
+ type Props = ICellRendererProps<SlidesLayout>;
36
+ type SlideCell = Props["cells"][number];
37
+ type CellIdsState = ReturnType<typeof useCellIds>;
38
+ type DropPosition = "before" | "after";
39
+
40
+ interface ProjectedDropTarget {
41
+ overId: CellId;
42
+ position: DropPosition;
43
+ }
44
+
45
+ interface ResolvedDropTarget {
46
+ cellId: CellId;
47
+ columnId: CellColumnId;
48
+ index: number;
49
+ }
50
+
51
+ interface SlideThumbnailCardProps extends React.HTMLAttributes<HTMLDivElement> {
52
+ cell: SlideCell;
53
+ isActiveSlide?: boolean;
54
+ isActiveDragSource?: boolean;
55
+ isOverlay?: boolean;
56
+ isVisible?: boolean;
57
+ ref?: React.Ref<HTMLDivElement>;
58
+ }
59
+
60
+ interface SlideThumbnailRowProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
61
+ cell: SlideCell;
62
+ isActiveSlide?: boolean;
63
+ dropIndicator?: DropPosition | null;
64
+ isActiveDragSource?: boolean;
65
+ isVisible?: boolean;
66
+ ref?: React.Ref<HTMLButtonElement>;
67
+ }
68
+
69
+ interface SlidesMinimapProps {
70
+ cells: SlideCell[];
71
+ canReorder: boolean;
72
+ activeCellId: CellId | null;
73
+ onSlideClick: (index: number) => void;
74
+ }
75
+
76
+ const THUMBNAIL_WIDTH = 256;
77
+ const THUMBNAIL_HEIGHT = 144;
78
+ const THUMBNAIL_SCALE = 0.3;
79
+ const MINIMAP_AUTO_SCROLL = {
80
+ threshold: { x: 0, y: 0.1 },
81
+ };
82
+ const MINIMAP_GAP = 8;
83
+ const VISIBILITY_ROOT_MARGIN = "200px 0px";
84
+ const minimapCollisionDetection: CollisionDetection = (args) => {
85
+ const pointerCollisions = pointerWithin(args);
86
+ return pointerCollisions.length > 0 ? pointerCollisions : closestCenter(args);
87
+ };
88
+
89
+ /**
90
+ * Tracks which `[data-cell-id]` elements inside a scrollable container are
91
+ * within (or near) the viewport using a single shared IntersectionObserver.
92
+ * A MutationObserver re-observes when children are added or removed.
93
+ * Off-screen thumbnails can skip rendering expensive <Slide> content.
94
+ */
95
+ function useVisibleCellIds(
96
+ containerRef: React.RefObject<HTMLDivElement | null>,
97
+ ): ReadonlySet<CellId> {
98
+ const [visibleIds, setVisibleIds] = useState<ReadonlySet<CellId>>(
99
+ () => new Set(),
100
+ );
101
+
102
+ useEffect(() => {
103
+ const container = containerRef.current;
104
+ if (!container) {
105
+ return;
106
+ }
107
+
108
+ const intersectionObserver = new IntersectionObserver(
109
+ (entries) => {
110
+ setVisibleIds((prev) => {
111
+ const next = new Set(prev);
112
+ let changed = false;
113
+ for (const entry of entries) {
114
+ const id = (entry.target as HTMLElement).dataset.cellId as
115
+ | CellId
116
+ | undefined;
117
+ if (!id) {
118
+ continue;
119
+ }
120
+ if (entry.isIntersecting && !next.has(id)) {
121
+ next.add(id);
122
+ changed = true;
123
+ } else if (!entry.isIntersecting && next.has(id)) {
124
+ next.delete(id);
125
+ changed = true;
126
+ }
127
+ }
128
+ return changed ? next : prev;
129
+ });
130
+ },
131
+ { root: container, rootMargin: VISIBILITY_ROOT_MARGIN },
132
+ );
133
+
134
+ const observeAll = () => {
135
+ intersectionObserver.disconnect();
136
+ for (const el of container.querySelectorAll<HTMLElement>(
137
+ "[data-cell-id]",
138
+ )) {
139
+ intersectionObserver.observe(el);
140
+ }
141
+ };
142
+
143
+ observeAll();
144
+
145
+ const mutationObserver = new MutationObserver(observeAll);
146
+ mutationObserver.observe(container, { childList: true });
147
+
148
+ return () => {
149
+ intersectionObserver.disconnect();
150
+ mutationObserver.disconnect();
151
+ };
152
+ }, [containerRef]);
153
+
154
+ return visibleIds;
155
+ }
156
+
157
+ export const SlidesMinimap = ({
158
+ cells,
159
+ canReorder,
160
+ activeCellId,
161
+ onSlideClick,
162
+ }: SlidesMinimapProps) => {
163
+ const cellIds = useCellIds();
164
+ const { moveCellToIndex } = useCellActions();
165
+ const containerRef = useRef<HTMLDivElement>(null);
166
+ const visibleIds = useVisibleCellIds(containerRef);
167
+ const [activeId, setActiveId] = useState<CellId | null>(null);
168
+ const [dropTarget, setDropTarget] = useState<ProjectedDropTarget | null>(
169
+ null,
170
+ );
171
+
172
+ useEffect(() => {
173
+ if (!activeCellId || !containerRef.current) {
174
+ return;
175
+ }
176
+ const el = containerRef.current.querySelector(
177
+ `[data-cell-id="${activeCellId}"]`,
178
+ );
179
+ el?.scrollIntoView({ block: "nearest", behavior: "instant" });
180
+ }, [activeCellId]);
181
+
182
+ const sensors = useSensors(
183
+ useSensor(PointerSensor, {
184
+ activationConstraint: {
185
+ distance: 8,
186
+ },
187
+ }),
188
+ );
189
+
190
+ const activeCell = activeId
191
+ ? (cells.find((cell) => cell.id === activeId) ?? null)
192
+ : null;
193
+
194
+ const resetDragState = () => {
195
+ setActiveId(null);
196
+ setDropTarget(null);
197
+ };
198
+
199
+ const updateDropTarget = (event: DragMoveEvent | DragOverEvent) => {
200
+ const next = projectDropTarget(event);
201
+
202
+ setDropTarget((prev) => {
203
+ if (prev?.overId === next?.overId && prev?.position === next?.position) {
204
+ return prev;
205
+ }
206
+ return next;
207
+ });
208
+ };
209
+
210
+ const handleDragStart = (event: DragStartEvent) => {
211
+ setDropTarget(null);
212
+ const cellId = asCellId(event.active.id);
213
+ if (cellId) {
214
+ setActiveId(cellId);
215
+ }
216
+ };
217
+
218
+ const handleDragEnd = (_event: DragEndEvent) => {
219
+ try {
220
+ if (activeId && dropTarget) {
221
+ const resolvedTarget = resolveDropTarget({
222
+ cellIds,
223
+ activeId,
224
+ target: dropTarget,
225
+ });
226
+ if (resolvedTarget) {
227
+ moveCellToIndex(resolvedTarget);
228
+ }
229
+ }
230
+ } catch (e) {
231
+ Logger.warn("Drop failed", e);
232
+ } finally {
233
+ resetDragState();
234
+ }
235
+ };
236
+
237
+ if (!canReorder) {
238
+ return (
239
+ <SlideThumbnailsContainer ref={containerRef}>
240
+ <div className="text-xs text-gray-500 flex items-center gap-0.5">
241
+ <InfoIcon className="h-3 w-3" />
242
+ Reordering is not supported in multi-column mode
243
+ </div>
244
+ {cells.map((cell, index) => (
245
+ <SlideThumbnailRow
246
+ key={cell.id}
247
+ cell={cell}
248
+ isActiveSlide={cell.id === activeCellId}
249
+ isVisible={visibleIds.has(cell.id)}
250
+ onClick={() => onSlideClick(index)}
251
+ />
252
+ ))}
253
+ </SlideThumbnailsContainer>
254
+ );
255
+ }
256
+
257
+ return (
258
+ <DndContext
259
+ autoScroll={MINIMAP_AUTO_SCROLL}
260
+ collisionDetection={minimapCollisionDetection}
261
+ modifiers={[restrictToVerticalAxis]}
262
+ sensors={sensors}
263
+ onDragStart={handleDragStart}
264
+ onDragMove={updateDropTarget}
265
+ onDragOver={updateDropTarget}
266
+ onDragEnd={handleDragEnd}
267
+ onDragCancel={resetDragState}
268
+ >
269
+ <SlideThumbnailsContainer ref={containerRef}>
270
+ <SortableContext
271
+ items={cells.map((cell) => cell.id)}
272
+ strategy={verticalListSortingStrategy}
273
+ >
274
+ {cells.map((cell, index) => (
275
+ <SortableSlideThumbnail
276
+ key={cell.id}
277
+ cell={cell}
278
+ isActive={activeId === cell.id}
279
+ isActiveSlide={cell.id === activeCellId}
280
+ isVisible={visibleIds.has(cell.id)}
281
+ dropIndicator={
282
+ dropTarget?.overId === cell.id && activeId !== cell.id
283
+ ? dropTarget.position
284
+ : null
285
+ }
286
+ onClick={() => onSlideClick(index)}
287
+ />
288
+ ))}
289
+ </SortableContext>
290
+ </SlideThumbnailsContainer>
291
+ <DragOverlay>
292
+ {activeCell && (
293
+ <SlideThumbnailCard
294
+ cell={activeCell}
295
+ isOverlay={true}
296
+ isActiveDragSource={true}
297
+ />
298
+ )}
299
+ </DragOverlay>
300
+ </DndContext>
301
+ );
302
+ };
303
+
304
+ const SlideThumbnailsContainer = ({
305
+ children,
306
+ ref,
307
+ }: {
308
+ children: React.ReactNode;
309
+ ref?: React.Ref<HTMLDivElement>;
310
+ }) => {
311
+ return (
312
+ <div
313
+ ref={ref}
314
+ className="h-full overflow-auto flex flex-col scrollbar-thin"
315
+ >
316
+ {children}
317
+ </div>
318
+ );
319
+ };
320
+
321
+ interface SortableSlideThumbnailProps {
322
+ cell: SlideCell;
323
+ dropIndicator?: DropPosition | null;
324
+ isActive: boolean;
325
+ isActiveSlide?: boolean;
326
+ isVisible?: boolean;
327
+ onClick?: () => void;
328
+ }
329
+
330
+ const SortableSlideThumbnail = ({
331
+ cell,
332
+ dropIndicator,
333
+ isActive,
334
+ isActiveSlide,
335
+ isVisible,
336
+ onClick,
337
+ }: SortableSlideThumbnailProps) => {
338
+ const { attributes, listeners, setNodeRef } = useSortable({
339
+ id: cell.id,
340
+ });
341
+
342
+ return (
343
+ <SlideThumbnailRow
344
+ ref={setNodeRef}
345
+ cell={cell}
346
+ dropIndicator={dropIndicator}
347
+ isActiveDragSource={isActive}
348
+ isActiveSlide={isActiveSlide}
349
+ isVisible={isVisible}
350
+ onClick={onClick}
351
+ {...attributes}
352
+ {...listeners}
353
+ />
354
+ );
355
+ };
356
+
357
+ const SlideThumbnailRow = ({
358
+ cell,
359
+ className,
360
+ style,
361
+ dropIndicator,
362
+ isActiveSlide = false,
363
+ isActiveDragSource = false,
364
+ isVisible,
365
+ onClick,
366
+ ref,
367
+ ...props
368
+ }: SlideThumbnailRowProps) => {
369
+ const rowStyle: React.CSSProperties = {
370
+ paddingTop: MINIMAP_GAP,
371
+ paddingBottom: MINIMAP_GAP,
372
+ ...style,
373
+ };
374
+
375
+ return (
376
+ <button
377
+ ref={ref}
378
+ type="button"
379
+ data-cell-id={cell.id}
380
+ className={cn(
381
+ "relative shrink-0 appearance-none text-left p-0 bg-transparent outline-none",
382
+ className,
383
+ )}
384
+ style={rowStyle}
385
+ onClick={onClick}
386
+ {...props}
387
+ >
388
+ {dropIndicator && (
389
+ <div
390
+ className={cn(
391
+ "absolute left-2 right-2 h-0.5 rounded-full bg-blue-500 z-20 pointer-events-none",
392
+ dropIndicator === "after"
393
+ ? "bottom-0 translate-y-1/2"
394
+ : "top-0 -translate-y-1/2",
395
+ )}
396
+ />
397
+ )}
398
+ <SlideThumbnailCard
399
+ cell={cell}
400
+ isActiveSlide={isActiveSlide}
401
+ isActiveDragSource={isActiveDragSource}
402
+ isVisible={isVisible}
403
+ />
404
+ </button>
405
+ );
406
+ };
407
+
408
+ const SlideThumbnailCard = ({
409
+ cell,
410
+ className,
411
+ style,
412
+ isActiveSlide = false,
413
+ isActiveDragSource = false,
414
+ isOverlay = false,
415
+ isVisible = false,
416
+ ref,
417
+ ...props
418
+ }: SlideThumbnailCardProps) => {
419
+ const outerStyle: React.CSSProperties = {
420
+ width: THUMBNAIL_WIDTH,
421
+ height: THUMBNAIL_HEIGHT,
422
+ contain: "strict",
423
+ ...(isOverlay
424
+ ? null
425
+ : {
426
+ contentVisibility: "auto",
427
+ containIntrinsicSize: `${THUMBNAIL_WIDTH}px ${THUMBNAIL_HEIGHT}px`,
428
+ }),
429
+ ...style,
430
+ };
431
+
432
+ const showContent = isVisible || isOverlay;
433
+
434
+ return (
435
+ <div
436
+ ref={ref}
437
+ className={cn(
438
+ "border-2 shrink-0 rounded-md relative select-none bg-background cursor-pointer active:cursor-grabbing",
439
+ isActiveSlide || isActiveDragSource || isOverlay
440
+ ? "border-blue-500"
441
+ : "border-border",
442
+ isActiveDragSource && !isOverlay && "opacity-35",
443
+ isOverlay && "opacity-95 shadow-lg",
444
+ className,
445
+ )}
446
+ style={outerStyle}
447
+ {...props}
448
+ >
449
+ {showContent && (
450
+ <div
451
+ className="flex p-6 box-border pointer-events-none mo-slide-content overflow-hidden"
452
+ style={{
453
+ transform: `scale(${THUMBNAIL_SCALE})`,
454
+ transformOrigin: "top left",
455
+ width: THUMBNAIL_WIDTH / THUMBNAIL_SCALE,
456
+ height: THUMBNAIL_HEIGHT / THUMBNAIL_SCALE,
457
+ }}
458
+ >
459
+ <Slide cellId={cell.id} status={cell.status} output={cell.output} />
460
+ </div>
461
+ )}
462
+ </div>
463
+ );
464
+ };
465
+
466
+ function projectDropTarget(
467
+ event: DragMoveEvent | DragOverEvent,
468
+ ): ProjectedDropTarget | null {
469
+ const { active, over } = event;
470
+ if (!over) {
471
+ return null;
472
+ }
473
+
474
+ const activeId = asCellId(active.id);
475
+ const overId = asCellId(over.id);
476
+ if (!activeId || !overId || activeId === overId) {
477
+ return null;
478
+ }
479
+
480
+ const activeRect =
481
+ active.rect.current.translated ?? active.rect.current.initial;
482
+ if (!activeRect) {
483
+ return null;
484
+ }
485
+
486
+ const pointerY = activeRect.top + activeRect.height / 2;
487
+ const overCenter = over.rect.top + over.rect.height / 2;
488
+
489
+ return {
490
+ overId,
491
+ position: pointerY < overCenter ? "before" : "after",
492
+ };
493
+ }
494
+
495
+ function resolveDropTarget({
496
+ cellIds,
497
+ activeId,
498
+ target,
499
+ }: {
500
+ cellIds: CellIdsState;
501
+ activeId: CellId;
502
+ target: ProjectedDropTarget;
503
+ }): ResolvedDropTarget | null {
504
+ if (activeId === target.overId) {
505
+ return null;
506
+ }
507
+ if (cellIds.colLength !== 1) {
508
+ Logger.warn("Multi-column mode is not supported");
509
+ return null;
510
+ }
511
+
512
+ const column = cellIds.findWithId(target.overId);
513
+ const overIndex = column.indexOfOrThrow(target.overId);
514
+
515
+ return {
516
+ cellId: activeId,
517
+ columnId: column.id,
518
+ index: target.position === "after" ? overIndex + 1 : overIndex,
519
+ };
520
+ }
521
+
522
+ /**
523
+ * Narrows a dnd-kit UniqueIdentifier (string | number) back to CellId.
524
+ * Safe because we only pass CellId values as sortable item IDs.
525
+ */
526
+ function asCellId(id: UniqueIdentifier): CellId | null {
527
+ return typeof id === "string" ? (id as CellId) : null;
528
+ }
529
+
530
+ export const exportedForTesting = {
531
+ useVisibleCellIds,
532
+ projectDropTarget,
533
+ resolveDropTarget,
534
+ };
@@ -0,0 +1,29 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { outputIsLoading } from "@/core/cells/cell";
4
+ import { OutputArea } from "../editor/Output";
5
+ import { memo } from "react";
6
+ import type { CellId } from "@/core/cells/ids";
7
+ import type { CellRuntimeState } from "@/core/cells/types";
8
+
9
+ interface SlideContentProps extends Pick<
10
+ CellRuntimeState,
11
+ "output" | "status"
12
+ > {
13
+ cellId: CellId;
14
+ }
15
+
16
+ export const Slide = memo(({ output, cellId, status }: SlideContentProps) => {
17
+ const loading = outputIsLoading(status);
18
+ return (
19
+ <OutputArea
20
+ className="contents"
21
+ allowExpand={false}
22
+ output={output}
23
+ cellId={cellId}
24
+ stale={loading}
25
+ loading={loading}
26
+ />
27
+ );
28
+ });
29
+ Slide.displayName = "Slide";
@@ -22,6 +22,8 @@ interface SlidesComponentProps {
22
22
  index?: string | null;
23
23
  height?: string | number | null;
24
24
  wrapAround?: boolean;
25
+ activeIndex?: number | null;
26
+ onActiveIndexChange?: (index: number) => void;
25
27
  }
26
28
 
27
29
  const SlidesComponent = ({
@@ -30,11 +32,23 @@ const SlidesComponent = ({
30
32
  height,
31
33
  forceKeyboardNavigation = false,
32
34
  wrapAround = false,
35
+ activeIndex,
36
+ onActiveIndexChange,
33
37
  }: PropsWithChildren<SlidesComponentProps>): JSX.Element => {
34
38
  const el = React.useRef<SwiperRef>(null);
35
39
  const [isFullscreen, setIsFullscreen] = React.useState(false);
36
40
  const { hasFullscreen } = useIframeCapabilities();
37
41
 
42
+ useEffect(() => {
43
+ if (activeIndex != null && el.current?.swiper.realIndex !== activeIndex) {
44
+ if (wrapAround) {
45
+ el.current?.swiper.slideToLoop(activeIndex);
46
+ } else {
47
+ el.current?.swiper.slideTo(activeIndex);
48
+ }
49
+ }
50
+ }, [activeIndex, wrapAround]);
51
+
38
52
  useEventListener(document, "fullscreenchange", () => {
39
53
  if (document.fullscreenElement) {
40
54
  el.current?.swiper.keyboard.enable();
@@ -63,7 +77,7 @@ const SlidesComponent = ({
63
77
  )}
64
78
  spaceBetween={50}
65
79
  style={{
66
- height: isFullscreen ? "100%" : height || "550px",
80
+ height: isFullscreen ? "100%" : height || "650px",
67
81
  }}
68
82
  slidesPerView={1}
69
83
  modules={modules}
@@ -85,6 +99,7 @@ const SlidesComponent = ({
85
99
  // that overlay content more legible
86
100
  speed={1}
87
101
  loop={wrapAround}
102
+ onSlideChange={(swiper) => onActiveIndexChange?.(swiper.realIndex)}
88
103
  >
89
104
  {React.Children.map(children, (child, index) => {
90
105
  if (child == null) {