/* Copyright 2026 Marimo. All rights reserved. */ import React, { memo, type PropsWithChildren, useEffect, useMemo, useState, } from "react"; import { Responsive, WidthProvider } from "react-grid-layout"; import { OutputArea } from "@/components/editor/Output"; import type { CellRuntimeState } from "@/core/cells/types"; import type { ICellRendererProps } from "../types"; import type { GridLayout, GridLayoutCellSide } from "./types"; import "react-grid-layout/css/styles.css"; import "./styles.css"; import { BorderAllIcon } from "@radix-ui/react-icons"; import { AlignEndVerticalIcon, AlignHorizontalSpaceAroundIcon, AlignStartVerticalIcon, CheckIcon, GripHorizontalIcon, LockIcon, ScrollIcon, XIcon, } from "lucide-react"; import { TinyCode } from "@/components/editor/cell/TinyCode"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Label } from "@/components/ui/label"; import { NumberField } from "@/components/ui/number-field"; import { Switch } from "@/components/ui/switch"; import { outputIsLoading } from "@/core/cells/cell"; import type { CellId } from "@/core/cells/ids"; import type { AppMode } from "@/core/mode"; import { useIsDragging } from "@/hooks/useIsDragging"; import { cn } from "@/utils/cn"; import { Maps } from "@/utils/maps"; import { Objects } from "@/utils/objects"; import { Strings } from "@/utils/strings"; type Props = ICellRendererProps; const ReactGridLayout = WidthProvider(Responsive); const MARGIN: [number, number] = [0, 0]; const DRAG_HANDLE = "grid-drag-handle"; export const GridLayoutRenderer: React.FC = ({ layout, setLayout, cells, mode, }) => { const isReading = mode === "read"; const inGridIds = new Set(layout.cells.map((cell) => cell.i)); const [droppingItem, setDroppingItem] = useState<{ i: string; w?: number; h?: number; } | null>(null); const [isLocked, setIsLocked] = useState(false); const cols = useMemo( () => ({ // we only allow 1 responsive breakpoint // we can change this later if we want to support more, // but this increases complexity to the user lg: layout.columns, }), [layout.columns], ); // Add class to update the background of the app useEffect(() => { const appEl = document.getElementById("App"); if (layout.bordered) { appEl?.classList.add("grid-bordered"); } else { appEl?.classList.remove("grid-bordered"); } return () => { appEl?.classList.remove("grid-bordered"); }; }, [layout.bordered]); const { isDragging, ...dragProps } = useIsDragging(); const enableInteractions = !isReading && !isLocked; const layoutByCellId = Maps.keyBy(layout.cells, (cell) => cell.i); const handleMakeScrollable = (cellId: CellId) => (isScrollable: boolean) => { const scrollableCells = new Set(layout.scrollableCells); if (isScrollable) { scrollableCells.add(cellId); } else { scrollableCells.delete(cellId); } setLayout({ ...layout, scrollableCells: scrollableCells, }); }; const handleSetSide = (cellId: CellId) => (side: GridLayoutCellSide) => { const cellSide = new Map(layout.cellSide); if (side === cellSide.get(cellId)) { cellSide.delete(cellId); } else { cellSide.set(cellId, side); } setLayout({ ...layout, cellSide: cellSide, }); }; const styles: React.CSSProperties = {}; // Max width styles if (layout.maxWidth) { styles.maxWidth = `${layout.maxWidth}px`; } // Editing background styles if (enableInteractions) { styles.backgroundImage = "repeating-linear-gradient(var(--gray-4) 0 1px, transparent 1px 100%), repeating-linear-gradient(90deg, var(--gray-4) 0 1px, transparent 1px 100%)"; styles.backgroundSize = `calc((100% / ${layout.columns})) ${layout.rowHeight}px`; } let grid = ( { // Don't update state in read mode — the layout is static and // updating triggers a re-render cycle that causes an infinite loop // (React error https://react.dev/errors/185 Maximum update depth exceeded). // https://github.com/marimo-team/marimo/issues/8644 if (isReading) { return; } setLayout({ ...layout, cells: cellLayouts, }); }} droppingItem={ droppingItem ? { i: droppingItem.i, w: droppingItem.w || 2, h: droppingItem.h || 2, } : undefined } onDrop={(cellLayouts, dropped, _event) => { dragProps.onDragStop(); if (!dropped) { return; } setLayout({ ...layout, cells: [...cellLayouts, dropped], }); }} onDragStart={(_layout, _oldItem, _newItem, _placeholder, event) => { dragProps.onDragStart(event); }} onDrag={(_layout, _oldItem, _newItem, _placeholder, event) => { dragProps.onDragMove(event); }} onDragStop={() => { dragProps.onDragStop(); }} onResizeStop={() => { // Dispatch a resize event so widgets know to resize window.dispatchEvent(new Event("resize")); }} // When in read mode or locked, disable dragging and resizing isDraggable={enableInteractions} isDroppable={enableInteractions} isResizable={enableInteractions} draggableHandle={enableInteractions ? `.${DRAG_HANDLE}` : "noop"} > {cells .filter((cell) => inGridIds.has(cell.id)) .map((cell) => { const cellLayout = layoutByCellId.get(cell.id); const isScrollable = layout.scrollableCells.has(cell.id) ?? false; const side = layout.cellSide.get(cell.id); const gridCell = ( ); const notInGrid = cells.filter((cell) => !inGridIds.has(cell.id)); if (isReading) { if (layout.bordered) { grid = (
{grid}
); } const sidebarCells = notInGrid.filter((cell) => isSidebarCell(cell)); return ( <> {grid} {/* Render sidebar outputs even if they are not in grid (hidden) */}
{sidebarCells.map((cell) => { return (
); } if (layout.bordered) { grid = (
{grid}
); } return ( <>
{grid}
Outputs
{notInGrid.map((cell) => (
{ // get height of self const height = e.currentTarget.offsetHeight; setDroppingItem({ i: cell.id, w: layout.columns / 4, h: Math.ceil(height / layout.rowHeight) || 1, }); e.dataTransfer.setData("text/plain", ""); }} className={cn( DRAG_HANDLE, "droppable-element bg-background border-border border overflow-hidden p-2 rounded shrink-0", )} >
))}
); }; interface GridCellProps extends Pick { className?: string; code: string; cellId: CellId; mode: AppMode; hidden: boolean; isScrollable: boolean; side?: GridLayoutCellSide; } const GridCell = memo( ({ output, cellId, status, mode, code, hidden, isScrollable, side, className, }: GridCellProps) => { const loading = outputIsLoading(status); const isOutputEmpty = output == null || output.data === ""; // If not reading, show code when there is no output if (isOutputEmpty && mode !== "read") { return ; } return ( ); }, ); GridCell.displayName = "GridCell"; const GridControls: React.FC<{ layout: GridLayout; setLayout: (layout: GridLayout) => void; isLocked: boolean; setIsLocked: (isLocked: boolean) => void; }> = ({ layout, setLayout, isLocked, setIsLocked }) => { return (
{ setLayout({ ...layout, columns: valueAsNumber, }); }} />
{ setLayout({ ...layout, rowHeight: valueAsNumber, }); }} />
{ setLayout({ ...layout, maxWidth: Number.isNaN(valueAsNumber) ? undefined : valueAsNumber, }); }} />
{ setLayout({ ...layout, bordered, }); }} />
); }; const EditableGridCell = React.forwardRef( ( { children, isDragging, className, onDelete, isScrollable, setIsScrollable, side, setSide, display, ...rest }: PropsWithChildren<{ id: CellId; className?: string; isDragging: boolean; onDelete: () => void; isScrollable: boolean; setIsScrollable: (isScrollable: boolean) => void; side?: GridLayoutCellSide; setSide: (side: GridLayoutCellSide) => void; display: "top" | "bottom"; }>, ref: React.Ref, ) => { const [popoverOpened, setPopoverOpened] = useState<"side" | "scroll">(); return (
{children}
); }, ); EditableGridCell.displayName = "EditableGridCell"; interface GridHoverActionsProps { onDelete: () => void; isScrollable: boolean; setIsScrollable: (isScrollable: boolean) => void; side?: GridLayoutCellSide; setSide: (side: GridLayoutCellSide) => void; display: "top" | "bottom"; popoverOpened: "side" | "scroll" | undefined; setPopoverOpened: (popoverOpened: "side" | "scroll" | undefined) => void; } const GridHoverActions: React.FC = ({ display, onDelete, side, setSide, isScrollable, setIsScrollable, popoverOpened, setPopoverOpened, }) => { const buttonClassName = "h-4 w-4 opacity-60 hover:opacity-100"; const SideIcon = side === "left" ? AlignStartVerticalIcon : side === "right" ? AlignEndVerticalIcon : undefined; return (
setPopoverOpened(open ? "side" : undefined)} > {SideIcon ? ( ) : ( )} {Objects.entries(SIDE_TO_ICON).map(([option, Icon]) => ( setSide(option)}> {Strings.startCase(option)} {option === side && } ))} setPopoverOpened(open ? "scroll" : undefined)} > setIsScrollable(!isScrollable)}> Scrollable onDelete()} />
); }; function isSidebarCell(cell: CellRuntimeState) { // False-positives are ok here because we rendering these cells in a hidden div return ( typeof cell.output?.data === "string" && cell.output.data.includes("marimo-sidebar") ); } const SIDE_TO_ICON = { // We are only showing horizontal sides for now // top: AlignHorizontalSpaceAroundIcon, // bottom: AlignHorizontalSpaceAroundIcon, left: AlignStartVerticalIcon, right: AlignEndVerticalIcon, };