/** * Block Menu Component * * Floating menu that appears when a block is selected via drag handle click. * Provides block actions: * - Turn into (transform to different block type) * - Duplicate * - Delete * * Uses Floating UI for positioning relative to the selected block. */ import { Button } from "@cloudflare/kumo"; import { useFloating, offset, flip, shift, autoUpdate } from "@floating-ui/react"; import type { MessageDescriptor } from "@lingui/core"; import { msg } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import { DotsSixVertical, Paragraph, TextHOne, TextHTwo, TextHThree, Quotes, Code, List, ListNumbers, Copy, Trash, type Icon as PhosphorIcon, } from "@phosphor-icons/react"; import { NodeSelection } from "@tiptap/pm/state"; import type { Editor } from "@tiptap/react"; import * as React from "react"; import { createPortal } from "react-dom"; import { useStableCallback } from "../../lib/hooks"; import { cn } from "../../lib/utils"; import { CaretNext, CaretPrev } from "../ArrowIcons.js"; /** * Block transform options */ interface BlockTransform { id: string; label: MessageDescriptor; icon: PhosphorIcon; transform: (editor: Editor) => void; } const blockTransforms: BlockTransform[] = [ { id: "paragraph", label: msg`Paragraph`, icon: Paragraph, transform: (editor) => { editor.chain().focus().setNode("paragraph").run(); }, }, { id: "heading1", label: msg`Heading 1`, icon: TextHOne, transform: (editor) => { editor.chain().focus().setNode("heading", { level: 1 }).run(); }, }, { id: "heading2", label: msg`Heading 2`, icon: TextHTwo, transform: (editor) => { editor.chain().focus().setNode("heading", { level: 2 }).run(); }, }, { id: "heading3", label: msg`Heading 3`, icon: TextHThree, transform: (editor) => { editor.chain().focus().setNode("heading", { level: 3 }).run(); }, }, { id: "blockquote", label: msg`Quote`, icon: Quotes, transform: (editor) => { editor.chain().focus().toggleBlockquote().run(); }, }, { id: "codeBlock", label: msg`Code Block`, icon: Code, transform: (editor) => { editor.chain().focus().toggleCodeBlock().run(); }, }, { id: "bulletList", label: msg`Bullet List`, icon: List, transform: (editor) => { editor.chain().focus().toggleBulletList().run(); }, }, { id: "orderedList", label: msg`Numbered List`, icon: ListNumbers, transform: (editor) => { editor.chain().focus().toggleOrderedList().run(); }, }, ]; interface BlockMenuProps { editor: Editor; /** The DOM element of the selected block (for positioning) */ anchorElement: HTMLElement | null; /** Whether the menu is open */ isOpen: boolean; /** Callback to close the menu */ onClose: () => void; } /** * Block Menu - floating menu for block-level actions */ export function BlockMenu({ editor, anchorElement, isOpen, onClose }: BlockMenuProps) { const { t } = useLingui(); const [showTransforms, setShowTransforms] = React.useState(false); const menuRef = React.useRef(null); const stableOnClose = useStableCallback(onClose); const { refs, floatingStyles } = useFloating({ open: isOpen, placement: "left-start", middleware: [offset({ mainAxis: 8, crossAxis: 0 }), flip(), shift({ padding: 8 })], whileElementsMounted: autoUpdate, }); // Sync the anchor element React.useEffect(() => { if (anchorElement) { refs.setReference(anchorElement); } }, [anchorElement, refs]); // Close on escape React.useEffect(() => { if (!isOpen) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { e.preventDefault(); if (showTransforms) { setShowTransforms(false); } else { stableOnClose(); } } }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); }, [isOpen, stableOnClose, showTransforms]); // Close on click outside React.useEffect(() => { if (!isOpen) return; const handleClickOutside = (e: MouseEvent) => { const target = e.target; // Don't close if clicking on the drag handle or menu itself if (target instanceof Node && menuRef.current?.contains(target)) return; if (target instanceof Element && target.closest("[data-block-handle]")) return; stableOnClose(); }; // Delay to avoid immediate close from the click that opened it const timer = setTimeout(() => { document.addEventListener("mousedown", handleClickOutside); }, 0); return () => { clearTimeout(timer); document.removeEventListener("mousedown", handleClickOutside); }; }, [isOpen, stableOnClose]); // Reset submenu state when menu closes React.useEffect(() => { if (!isOpen) { setShowTransforms(false); } }, [isOpen]); const handleDuplicate = () => { if (!(editor.state.selection instanceof NodeSelection)) { onClose(); return; } const { selection, doc } = editor.state; const { from, to } = selection; const slice = doc.slice(from, to); editor .chain() .focus() .command(({ tr }) => { tr.insert(to, slice.content); return true; }) .run(); onClose(); }; const handleDelete = () => { if (!(editor.state.selection instanceof NodeSelection)) { onClose(); return; } editor.chain().focus().deleteSelection().run(); onClose(); }; const handleTransform = (transform: BlockTransform) => { transform.transform(editor); onClose(); }; if (!isOpen) return null; return createPortal(
{ menuRef.current = node; refs.setFloating(node); }} style={floatingStyles} className="z-[100] rounded-lg border bg-kumo-overlay shadow-lg min-w-[180px] overflow-hidden" > {showTransforms ? ( // Transform submenu
{blockTransforms.map((transform) => ( ))}
) : ( // Main menu
)}
, document.body, ); } /** * Block Drag Handle Component * * Shown in the left gutter of each block. Clicking opens the block menu, * dragging reorders blocks. */ interface BlockHandleProps { onClick: (e: React.MouseEvent) => void; onDragStart?: (e: React.DragEvent) => void; selected?: boolean; } export function BlockHandle({ onClick, onDragStart, selected }: BlockHandleProps) { const { t } = useLingui(); return ( ); } export { blockTransforms }; export type { BlockTransform };