/* Copyright 2026 Marimo. All rights reserved. */ import { ClipboardCopyIcon, ClipboardPasteIcon, CopyIcon, ImageIcon, ScissorsIcon, SearchIcon, } from "lucide-react"; import React, { Fragment } from "react"; import { renderMinimalShortcut } from "@/components/shortcuts/renderShortcut"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu"; import { menuItemVariants } from "@/components/ui/menu-items"; import { Tooltip } from "@/components/ui/tooltip"; import { toast } from "@/components/ui/use-toast"; import { useCellData, useCellRuntime } from "@/core/cells/cells"; import { CellOutputId } from "@/core/cells/ids"; import { isOutputEmpty } from "@/core/cells/outputs"; import { goToDefinitionAtCursorPosition } from "@/core/codemirror/go-to-definition/utils"; import { sendToPanelManager } from "@/core/vscode/vscode-bindings"; import { copyImageToClipboard, copyToClipboard } from "@/utils/copy"; import { getImageExtension } from "@/utils/filenames"; import { Logger } from "@/utils/Logger"; import type { ActionButton } from "../actions/types"; import { type CellActionButtonProps, useCellActionButtons, } from "../actions/useCellActionButton"; interface Props extends Pick< CellActionButtonProps, "cellId" | "getEditorView" > { children: React.ReactNode; } export const CellActionsContextMenu = ({ children, cellId, getEditorView, }: Props) => { const cellData = useCellData(cellId); const cellRuntime = useCellRuntime(cellId); const actions = useCellActionButtons({ cell: { cellId: cellId, name: cellData.name, config: cellData.config, status: cellRuntime.status, hasOutput: !isOutputEmpty(cellRuntime.output), hasConsoleOutput: cellRuntime.consoleOutputs.length > 0, getEditorView, }, }); const [imageRightClicked, setImageRightClicked] = React.useState(); const DEFAULT_CONTEXT_MENU_ITEMS: ActionButton[] = [ { label: "Copy", hidden: Boolean(imageRightClicked), icon: , handle: async () => { // Has selection, use browser copy const hasSelection = window.getSelection()?.toString(); if (hasSelection) { document.execCommand("copy"); return; } // No selection, copy the full cell output const output = document.getElementById(CellOutputId.create(cellId)); if (!output) { Logger.warn("cell-context-menu: output not found"); return; } // Copy the output of the cell await copyToClipboard(output.textContent ?? ""); }, }, { label: "Cut", hidden: Boolean(imageRightClicked), icon: , handle: () => { document.execCommand("cut"); }, }, { label: "Paste", hidden: Boolean(imageRightClicked), icon: , handle: async () => { const editorView = getEditorView(); if (!editorView) { return; } // We can't use the native browser paste since we don't have focus // so instead we use the editorViewView try { const clipText = await navigator.clipboard.readText(); if (clipText) { // Get the current selection, or the start of the document if nothing is selected const { from, to } = editorView.state.selection.main; // Create a new transaction that replaces the selection with the clipboard text const tr = editorView.state.update({ changes: { from, to, insert: clipText }, }); // Apply the transaction editorView.dispatch(tr); } } catch (error) { Logger.error("Failed to paste from clipboard", error); // Try vscode or other parent sendToPanelManager({ command: "paste" }); } }, }, { label: "Copy image", hidden: !imageRightClicked, icon: , handle: async () => { if (imageRightClicked) { await copyImageToClipboard(imageRightClicked.src) .then(() => { toast({ title: "Copied image to clipboard", }); }) .catch((error) => { toast({ title: "Failed to copy image to clipboard. Try downloading instead.", description: error.message, }); Logger.error("Failed to copy image to clipboard", error); }); } }, }, { icon: , label: "Download image", hidden: !imageRightClicked, handle: () => { if (imageRightClicked) { const link = document.createElement("a"); link.href = imageRightClicked.src; const ext = getImageExtension(imageRightClicked.src) || "png"; link.download = `image.${ext}`; link.click(); } }, }, { label: "Go to Definition", icon: , handle: () => { const editorView = getEditorView(); if (editorView) { goToDefinitionAtCursorPosition(editorView); } }, }, ]; const allActions: ActionButton[][] = [DEFAULT_CONTEXT_MENU_ITEMS, ...actions]; const visibleActions = allActions .map((group) => group.filter((action) => !action.hidden && !action.redundant), ) .filter((group) => group.length > 0); return ( { if (evt.target instanceof HTMLImageElement) { setImageRightClicked(evt.target); } else { setImageRightClicked(undefined); } }} asChild={true} > {children} {visibleActions.map((group, i) => ( {group.map((action) => { let body = (
{action.icon && (
{action.icon}
)}
{action.label}
{action.hotkey && renderMinimalShortcut(action.hotkey)} {action.rightElement}
); if (action.tooltip) { body = ( {body} ); } return ( { // Set disableClick items such as cell name input // to div to prevent roving focus action.disableClick ? (
{ evt.stopPropagation(); }} // Prevent keydown propagation, that focus does not jump to shortcut which start with same letter // e.g. input "C", then focus jump to "Copy" > {body}
) : ( { if (action.disableClick || action.disabled) { return; } action.handle(evt); }} variant={action.variant} > {body} ) }
); })} {i < visibleActions.length - 1 && }
))}
); };