From cc933789969fe610c65d09232338daffbdc218ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Thu, 26 Mar 2026 10:48:16 +0400 Subject: [PATCH] Split menu components for better responsibility handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Émile Ré --- .../ui/src/RichEditor/BlockMenu/BlockMenu.tsx | 48 +++ .../BlockMenuContent.tsx} | 176 ++------- .../RichEditor/BlockMenu/BlockMenuTrigger.tsx | 87 +++++ .../ui/src/RichEditor/BlockMenu/variants.ts | 15 + packages/ui/src/RichEditor/OptionsMenu.tsx | 290 -------------- .../RichEditor/OptionsMenu/OptionsMenu.tsx | 89 +++++ .../OptionsMenu/OptionsMenuContent.tsx | 191 +++++++++ .../OptionsMenu/OptionsMenuTrigger.tsx | 97 +++++ .../ui/src/RichEditor/OptionsMenu/variants.ts | 15 + packages/ui/src/RichEditor/RichEditor.tsx | 10 +- .../TableCellMenu/TableCellMenu.tsx | 74 ++++ .../TableCellMenu/TableCellMenuContent.tsx | 98 +++++ .../TableCellMenuTrigger.tsx} | 194 +++------- .../src/RichEditor/TableCellMenu/variants.ts | 15 + .../TableColumnMenu/TableColumnMenu.tsx | 132 +++++++ .../TableColumnMenuContent.tsx | 268 +++++++++++++ .../TableColumnMenuTrigger.tsx} | 366 ++---------------- .../RichEditor/TableColumnMenu/variants.ts | 16 + .../RichEditor/TableRowMenu/TableRowMenu.tsx | 128 ++++++ .../TableRowMenu/TableRowMenuContent.tsx | 266 +++++++++++++ .../TableRowMenuTrigger.tsx} | 360 ++--------------- .../src/RichEditor/TableRowMenu/variants.ts | 16 + .../ui/src/RichEditor/_lib/getSlashStorage.ts | 15 + .../RichEditor/_lib/useTableDropdownMenu.ts | 2 + 24 files changed, 1706 insertions(+), 1262 deletions(-) create mode 100644 packages/ui/src/RichEditor/BlockMenu/BlockMenu.tsx rename packages/ui/src/RichEditor/{BlockMenu.tsx => BlockMenu/BlockMenuContent.tsx} (54%) create mode 100644 packages/ui/src/RichEditor/BlockMenu/BlockMenuTrigger.tsx create mode 100644 packages/ui/src/RichEditor/BlockMenu/variants.ts delete mode 100644 packages/ui/src/RichEditor/OptionsMenu.tsx create mode 100644 packages/ui/src/RichEditor/OptionsMenu/OptionsMenu.tsx create mode 100644 packages/ui/src/RichEditor/OptionsMenu/OptionsMenuContent.tsx create mode 100644 packages/ui/src/RichEditor/OptionsMenu/OptionsMenuTrigger.tsx create mode 100644 packages/ui/src/RichEditor/OptionsMenu/variants.ts create mode 100644 packages/ui/src/RichEditor/TableCellMenu/TableCellMenu.tsx create mode 100644 packages/ui/src/RichEditor/TableCellMenu/TableCellMenuContent.tsx rename packages/ui/src/RichEditor/{TableCellMenu.tsx => TableCellMenu/TableCellMenuTrigger.tsx} (54%) create mode 100644 packages/ui/src/RichEditor/TableCellMenu/variants.ts create mode 100644 packages/ui/src/RichEditor/TableColumnMenu/TableColumnMenu.tsx create mode 100644 packages/ui/src/RichEditor/TableColumnMenu/TableColumnMenuContent.tsx rename packages/ui/src/RichEditor/{TableColumnMenu.tsx => TableColumnMenu/TableColumnMenuTrigger.tsx} (50%) create mode 100644 packages/ui/src/RichEditor/TableColumnMenu/variants.ts create mode 100644 packages/ui/src/RichEditor/TableRowMenu/TableRowMenu.tsx create mode 100644 packages/ui/src/RichEditor/TableRowMenu/TableRowMenuContent.tsx rename packages/ui/src/RichEditor/{TableRowMenu.tsx => TableRowMenu/TableRowMenuTrigger.tsx} (51%) create mode 100644 packages/ui/src/RichEditor/TableRowMenu/variants.ts create mode 100644 packages/ui/src/RichEditor/_lib/getSlashStorage.ts diff --git a/packages/ui/src/RichEditor/BlockMenu/BlockMenu.tsx b/packages/ui/src/RichEditor/BlockMenu/BlockMenu.tsx new file mode 100644 index 000000000..e2d17a3b2 --- /dev/null +++ b/packages/ui/src/RichEditor/BlockMenu/BlockMenu.tsx @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Probo Inc . +// Use of this source code is governed by the ISC license +// that can be found in the LICENSE file. + +import { type Editor, useEditorState } from "@tiptap/react"; + +import { getSlashStorage } from "../_lib/getSlashStorage"; +import { useHoveredBlock } from "../_lib/useHoveredBlock"; + +import { BlockMenuContent } from "./BlockMenuContent"; +import { BlockMenuTrigger } from "./BlockMenuTrigger"; + +type BlockMenuProps = { + editor: Editor; +}; + +export function BlockMenu({ editor }: BlockMenuProps) { + const slashState = useEditorState({ + editor, + selector: ({ editor: e }) => { + const s = getSlashStorage(e); + return { + active: s?.active ?? false, + query: s?.query ?? "", + from: s?.from ?? 0, + }; + }, + }); + + const { hoveredBlock } = useHoveredBlock(editor, slashState.active); + + return ( + <> + {hoveredBlock != null && ( + + )} + {slashState.active && ( + + )} + + ); +} diff --git a/packages/ui/src/RichEditor/BlockMenu.tsx b/packages/ui/src/RichEditor/BlockMenu/BlockMenuContent.tsx similarity index 54% rename from packages/ui/src/RichEditor/BlockMenu.tsx rename to packages/ui/src/RichEditor/BlockMenu/BlockMenuContent.tsx index 4af16217f..43df9640c 100644 --- a/packages/ui/src/RichEditor/BlockMenu.tsx +++ b/packages/ui/src/RichEditor/BlockMenu/BlockMenuContent.tsx @@ -10,16 +10,17 @@ import { useFloating, } from "@floating-ui/react"; import type { Icon } from "@phosphor-icons/react"; -import { CodeBlockIcon, GridFourIcon, ListBulletsIcon, ListNumbersIcon, MinusIcon, PlusIcon, QuotesIcon, TextHOneIcon, TextHThreeIcon, TextHTwoIcon, TextTIcon } from "@phosphor-icons/react"; -import { type Editor, useEditorState } from "@tiptap/react"; +import { CodeBlockIcon, GridFourIcon, ListBulletsIcon, ListNumbersIcon, MinusIcon, QuotesIcon, TextHOneIcon, TextHThreeIcon, TextHTwoIcon, TextTIcon } from "@phosphor-icons/react"; +import { type Editor } from "@tiptap/react"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; -import { tv } from "tailwind-variants"; -import { useBlockTrigger } from "./_lib/useBlockTrigger"; -import { useHoveredBlock } from "./_lib/useHoveredBlock"; -import { MenuButton } from "./MenuButton"; -import type { SlashCommandStorage } from "./SlashCommandExtension"; -import { activateSlashCommand, deactivateSlashCommand } from "./SlashCommandExtension"; +import { getSlashStorage } from "../_lib/getSlashStorage"; +import { MenuButton } from "../MenuButton"; +import { deactivateSlashCommand } from "../SlashCommandExtension"; + +import { blockMenuVariants } from "./variants"; + +const { menu } = blockMenuVariants(); type ChainCommands = ReturnType; @@ -42,47 +43,15 @@ const BLOCK_ITEMS: BlockItem[] = [ { label: "Table", icon: GridFourIcon, action: chain => chain.insertTable() }, ]; -const blockMenuVariants = tv({ - slots: { - trigger: [ - "z-10 flex size-6 items-center justify-center", - "rounded text-txt-tertiary hover:bg-subtle hover:text-txt-primary text-xl font-light cursor-pointer", - ], - menu: ["rounded-lg border border-border-mid bg-level-0 p-1 shadow-md z-20"], - }, -}); - -const { trigger, menu } = blockMenuVariants(); - -function getSlashStorage(editor: Editor): SlashCommandStorage | undefined { - return (editor.storage as unknown as Record).slashCommand as - | SlashCommandStorage - | undefined; -} - -type BlockMenuProps = { +type BlockMenuContentProps = { editor: Editor; + slashState: { active: boolean; query: string; from: number }; }; -export function BlockMenu({ editor }: BlockMenuProps) { +export function BlockMenuContent({ editor, slashState }: BlockMenuContentProps) { const [slashNav, setSlashNav] = useState({ index: 0, query: "" }); const slashDropdownRef = useRef(null); - const slashState = useEditorState({ - editor, - selector: ({ editor: e }) => { - const s = getSlashStorage(e); - return { - active: s?.active ?? false, - query: s?.query ?? "", - from: s?.from ?? 0, - }; - }, - }); - - const { hoveredBlock } = useHoveredBlock(editor, slashState.active); - const { triggerRefs, triggerStyles, isPositioned } = useBlockTrigger(hoveredBlock, 40); - const slashActiveIndex = slashState.query === slashNav.query ? slashNav.index : 0; @@ -192,101 +161,32 @@ export function BlockMenu({ editor }: BlockMenuProps) { }; }, [editor, slashState.active, slashState.query, filteredItems, slashActiveIndex, handleSlashAction]); - const handleTriggerClick = () => { - if (!hoveredBlock) return; - - try { - const pos = editor.view.posAtDOM(hoveredBlock, 0); - const $pos = editor.state.doc.resolve(pos); - - const rootPos = $pos.depth >= 1 ? $pos.before(1) : pos; - const rootNode = $pos.depth >= 1 ? $pos.node(1) : $pos.nodeAfter; - - if (rootNode && rootNode.isTextblock && rootNode.content.size === 0) { - const textPos = rootPos + 1; - - editor.chain() - .focus() - .setTextSelection(textPos) - .insertContent("/") - .run(); - - const s = getSlashStorage(editor); - if (s) activateSlashCommand(s, textPos); - return; - } - - let insertPos: number; - if ($pos.depth >= 1) { - insertPos = rootPos + rootNode!.nodeSize; - } else { - const nodeAfter = $pos.nodeAfter; - insertPos = pos + (nodeAfter?.nodeSize ?? 1); - } - - const textPos = insertPos + 1; - - editor.chain() - .focus() - .insertContentAt(insertPos, { type: "paragraph" }) - .setTextSelection(textPos) - .insertContent("/") - .run(); - - const s = getSlashStorage(editor); - if (s) activateSlashCommand(s, textPos); - } catch { - // Block may no longer be in the document - } - }; - return ( - <> - {hoveredBlock != null && ( - - )} - {slashState.active && ( -
{ - slashDropdownRef.current = node; - slashMenuRefs.setFloating(node); - }} - style={slashMenuStyles} - onMouseDown={e => e.preventDefault()} - className={menu()} - > - {filteredItems.length > 0 - ? filteredItems.map((item, index) => ( - handleSlashAction(item)} - > - - {item.label} - - )) - : ( -
- No results -
- )} -
- )} - +
{ + slashDropdownRef.current = node; + slashMenuRefs.setFloating(node); + }} + style={slashMenuStyles} + onMouseDown={e => e.preventDefault()} + className={menu()} + > + {filteredItems.length > 0 + ? filteredItems.map((item, index) => ( + handleSlashAction(item)} + > + + {item.label} + + )) + : ( +
+ No results +
+ )} +
); } diff --git a/packages/ui/src/RichEditor/BlockMenu/BlockMenuTrigger.tsx b/packages/ui/src/RichEditor/BlockMenu/BlockMenuTrigger.tsx new file mode 100644 index 000000000..4773800af --- /dev/null +++ b/packages/ui/src/RichEditor/BlockMenu/BlockMenuTrigger.tsx @@ -0,0 +1,87 @@ +// Copyright (c) 2026 Probo Inc . +// Use of this source code is governed by the ISC license +// that can be found in the LICENSE file. + +import { PlusIcon } from "@phosphor-icons/react"; +import { type Editor } from "@tiptap/react"; + +import { getSlashStorage } from "../_lib/getSlashStorage"; +import { useBlockTrigger } from "../_lib/useBlockTrigger"; +import { activateSlashCommand } from "../SlashCommandExtension"; + +import { blockMenuVariants } from "./variants"; + +const { trigger } = blockMenuVariants(); + +type BlockMenuTriggerProps = { + editor: Editor; + hoveredBlock: HTMLElement; +}; + +export function BlockMenuTrigger({ editor, hoveredBlock }: BlockMenuTriggerProps) { + const { triggerRefs, triggerStyles, isPositioned } = useBlockTrigger(hoveredBlock, 40); + + const handleTriggerClick = () => { + try { + const pos = editor.view.posAtDOM(hoveredBlock, 0); + const $pos = editor.state.doc.resolve(pos); + + const rootPos = $pos.depth >= 1 ? $pos.before(1) : pos; + const rootNode = $pos.depth >= 1 ? $pos.node(1) : $pos.nodeAfter; + + if (rootNode && rootNode.isTextblock && rootNode.content.size === 0) { + const textPos = rootPos + 1; + + editor.chain() + .focus() + .setTextSelection(textPos) + .insertContent("/") + .run(); + + const s = getSlashStorage(editor); + if (s) activateSlashCommand(s, textPos); + return; + } + + let insertPos: number; + if ($pos.depth >= 1) { + insertPos = rootPos + rootNode!.nodeSize; + } else { + const nodeAfter = $pos.nodeAfter; + insertPos = pos + (nodeAfter?.nodeSize ?? 1); + } + + const textPos = insertPos + 1; + + editor.chain() + .focus() + .insertContentAt(insertPos, { type: "paragraph" }) + .setTextSelection(textPos) + .insertContent("/") + .run(); + + const s = getSlashStorage(editor); + if (s) activateSlashCommand(s, textPos); + } catch { + // Block may no longer be in the document + } + }; + + return ( + + ); +} diff --git a/packages/ui/src/RichEditor/BlockMenu/variants.ts b/packages/ui/src/RichEditor/BlockMenu/variants.ts new file mode 100644 index 000000000..5b162962e --- /dev/null +++ b/packages/ui/src/RichEditor/BlockMenu/variants.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 Probo Inc . +// Use of this source code is governed by the ISC license +// that can be found in the LICENSE file. + +import { tv } from "tailwind-variants"; + +export const blockMenuVariants = tv({ + slots: { + trigger: [ + "z-10 flex size-6 items-center justify-center", + "rounded text-txt-tertiary hover:bg-subtle hover:text-txt-primary text-xl font-light cursor-pointer", + ], + menu: ["rounded-lg border border-border-mid bg-level-0 p-1 shadow-md z-20"], + }, +}); diff --git a/packages/ui/src/RichEditor/OptionsMenu.tsx b/packages/ui/src/RichEditor/OptionsMenu.tsx deleted file mode 100644 index 15ea41de8..000000000 --- a/packages/ui/src/RichEditor/OptionsMenu.tsx +++ /dev/null @@ -1,290 +0,0 @@ -// Copyright (c) 2026 Probo Inc . -// Use of this source code is governed by the ISC license -// that can be found in the LICENSE file. - -import { - autoUpdate, - flip, - offset, - shift, - useClick, - useDismiss, - useFloating, - useFloatingRootContext, - useInteractions, -} from "@floating-ui/react"; -import { CodeBlockIcon, DotsSixVerticalIcon, ListBulletsIcon, ListNumbersIcon, QuotesIcon, TextHOneIcon, TextHThreeIcon, TextHTwoIcon, TextTIcon } from "@phosphor-icons/react"; -import { NodeSelection, TextSelection } from "@tiptap/pm/state"; -import type { EditorView } from "@tiptap/pm/view"; -import { type Editor } from "@tiptap/react"; -import { type DragEvent, useState } from "react"; -import { tv } from "tailwind-variants"; - -import { getBlockNode, isBlockNodeType } from "./_lib/getBlockNode"; -import { useBlockTrigger } from "./_lib/useBlockTrigger"; -import { useHoveredBlock } from "./_lib/useHoveredBlock"; -import { MenuButton } from "./MenuButton"; - -const optionsMenuVariants = tv({ - slots: { - trigger: [ - "z-10 flex size-6 items-center justify-center", - "rounded text-txt-tertiary hover:bg-subtle hover:text-txt-primary cursor-grab", - ], - menu: ["rounded-lg border border-border-mid bg-level-0 p-1 shadow-md z-20"], - }, -}); - -const { trigger, menu } = optionsMenuVariants(); - -function startDrag(view: EditorView, slice: ReturnType, node: NodeSelection) { - view.dragging = { slice, move: true, node } as typeof view.dragging; -} - -type OptionsMenuProps = { - editor: Editor; -}; - -export function OptionsMenu({ editor }: OptionsMenuProps) { - const [menuOpen, setMenuOpen] = useState(false); - const [triggerEl, setTriggerEl] = useState(null); - const [dropdownEl, setDropdownEl] = useState(null); - - const { hoveredBlock, setHoveredBlock } = useHoveredBlock(editor, menuOpen); - const { triggerRefs, triggerStyles, isPositioned } = useBlockTrigger(hoveredBlock, 16); - - const menuRootContext = useFloatingRootContext({ - open: menuOpen, - onOpenChange: setMenuOpen, - elements: { reference: triggerEl, floating: dropdownEl }, - }); - - const { refs: menuRefs, floatingStyles: menuStyles } = useFloating({ - rootContext: menuRootContext, - strategy: "fixed", - placement: "bottom-start", - middleware: [offset(4), flip(), shift()], - whileElementsMounted: autoUpdate, - }); - - const click = useClick(menuRootContext); - const dismiss = useDismiss(menuRootContext); - const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss]); - - const shouldShow = hoveredBlock != null || menuOpen; - - if (!shouldShow) return null; - - const handleAction = ( - applyCommand: (chain: ReturnType) => ReturnType, - ) => { - if (!hoveredBlock) { - setMenuOpen(false); - return; - } - const data = getBlockNode(editor, hoveredBlock); - if (!data) { - setMenuOpen(false); - return; - } - - try { - if (!data.node.isTextblock) { - if (!data.node.firstChild) { - const paragraph = editor.state.schema.nodes.paragraph.create(); - editor.chain() - .focus() - .command(({ tr }) => { - tr.replaceWith(data.pos, data.pos + data.node.nodeSize, paragraph); - return true; - }) - .run(); - - const $near = editor.state.doc.resolve(data.pos + 1); - const textPos = TextSelection.near($near).from; - - applyCommand( - editor.chain() - .focus() - .setTextSelection(textPos), - ).run(); - - setMenuOpen(false); - return; - } - - let textBlock = data.node.firstChild; - if (!textBlock) return; - while (!textBlock.isTextblock && textBlock.firstChild) { - textBlock = textBlock.firstChild; - } - if (!textBlock.isTextblock) return; - - const firstChildSize = data.node.firstChild.nodeSize; - const paragraph = editor.state.schema.nodes.paragraph.create( - null, - textBlock.content, - ); - - editor.chain() - .focus() - .command(({ tr }) => { - tr.insert(data.pos, paragraph); - const wrapperPos = data.pos + paragraph.nodeSize; - const wrapperNode = tr.doc.nodeAt(wrapperPos); - if (!wrapperNode) return false; - - if (wrapperNode.childCount <= 1) { - tr.delete(wrapperPos, wrapperPos + wrapperNode.nodeSize); - } else { - tr.delete(wrapperPos + 1, wrapperPos + 1 + firstChildSize); - } - - return true; - }) - .run(); - } - - const $near = editor.state.doc.resolve(data.pos + 1); - const textPos = TextSelection.near($near).from; - - applyCommand( - editor.chain() - .focus() - .setTextSelection(textPos), - ).run(); - } catch { - // Block may no longer be in the document - } - - setMenuOpen(false); - }; - - const onDragStart = (e: DragEvent) => { - if (!hoveredBlock) return; - const data = getBlockNode(editor, hoveredBlock); - if (!data) return; - - try { - const view = editor.view; - const selection = NodeSelection.create(view.state.doc, data.pos); - const slice = selection.content(); - - const { tr } = view.state; - tr.setSelection(selection); - view.dispatch(tr); - - if (e.dataTransfer) { - e.dataTransfer.clearData(); - e.dataTransfer.setData("text/plain", ""); - e.dataTransfer.effectAllowed = "move"; - - const wrapper = document.createElement("div"); - wrapper.append(hoveredBlock.cloneNode(true)); - wrapper.style.position = "absolute"; - wrapper.style.top = "-10000px"; - document.body.append(wrapper); - e.dataTransfer.setDragImage(wrapper, 0, 0); - document.addEventListener("dragend", () => wrapper.remove(), { once: true }); - } - - startDrag(view, slice, selection); - } catch { - // Block may no longer be in the document - } - }; - - return ( - <> - - {menuOpen && ( -
{ - setDropdownEl(node); - menuRefs.setFloating(node); - }} - style={menuStyles} - {...getFloatingProps()} - onMouseDown={e => e.preventDefault()} - className={menu()} - > -
Turn into
- handleAction(chain => chain.setParagraph())} - > - - Text - - handleAction(chain => chain.toggleHeading({ level: 1 }))} - > - - Heading 1 - - handleAction(chain => chain.toggleHeading({ level: 2 }))} - > - - Heading 2 - - handleAction(chain => chain.toggleHeading({ level: 3 }))} - > - - Heading 3 - - handleAction(chain => chain.toggleBulletList())} - > - - Bullet List - - handleAction(chain => chain.toggleOrderedList())} - > - - Ordered List - - handleAction(chain => chain.toggleCodeBlock())} - > - - Code Block - - handleAction(chain => chain.toggleBlockquote())} - > - - Blockquote - -
- )} - - ); -} diff --git a/packages/ui/src/RichEditor/OptionsMenu/OptionsMenu.tsx b/packages/ui/src/RichEditor/OptionsMenu/OptionsMenu.tsx new file mode 100644 index 000000000..8ed203e80 --- /dev/null +++ b/packages/ui/src/RichEditor/OptionsMenu/OptionsMenu.tsx @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Probo Inc . +// Use of this source code is governed by the ISC license +// that can be found in the LICENSE file. + +import { + autoUpdate, + flip, + offset, + shift, + useClick, + useDismiss, + useFloating, + useFloatingRootContext, + useInteractions, +} from "@floating-ui/react"; +import { type Editor } from "@tiptap/react"; +import { useState } from "react"; + +import { useHoveredBlock } from "../_lib/useHoveredBlock"; + +import { OptionsMenuContent } from "./OptionsMenuContent"; +import { OptionsMenuTrigger } from "./OptionsMenuTrigger"; + +export type OptionsMenuFloating = { + setTriggerEl: React.Dispatch>; + setDropdownEl: React.Dispatch>; + menuRefs: ReturnType["refs"]; + menuStyles: ReturnType["floatingStyles"]; + getReferenceProps: ReturnType["getReferenceProps"]; + getFloatingProps: ReturnType["getFloatingProps"]; +}; + +type OptionsMenuProps = { + editor: Editor; +}; + +export function OptionsMenu({ editor }: OptionsMenuProps) { + const [menuOpen, setMenuOpen] = useState(false); + const [triggerEl, setTriggerEl] = useState(null); + const [dropdownEl, setDropdownEl] = useState(null); + + const { hoveredBlock, setHoveredBlock } = useHoveredBlock(editor, menuOpen); + + const menuRootContext = useFloatingRootContext({ + open: menuOpen, + onOpenChange: setMenuOpen, + elements: { reference: triggerEl, floating: dropdownEl }, + }); + + const { refs: menuRefs, floatingStyles: menuStyles } = useFloating({ + rootContext: menuRootContext, + strategy: "fixed", + placement: "bottom-start", + middleware: [offset(4), flip(), shift()], + whileElementsMounted: autoUpdate, + }); + + const click = useClick(menuRootContext); + const dismiss = useDismiss(menuRootContext); + const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss]); + + const shouldShow = hoveredBlock != null || menuOpen; + + if (!shouldShow) return null; + + return ( + <> + + {menuOpen && ( + + )} + + ); +} diff --git a/packages/ui/src/RichEditor/OptionsMenu/OptionsMenuContent.tsx b/packages/ui/src/RichEditor/OptionsMenu/OptionsMenuContent.tsx new file mode 100644 index 000000000..03fd3459d --- /dev/null +++ b/packages/ui/src/RichEditor/OptionsMenu/OptionsMenuContent.tsx @@ -0,0 +1,191 @@ +// Copyright (c) 2026 Probo Inc . +// Use of this source code is governed by the ISC license +// that can be found in the LICENSE file. + +import { CodeBlockIcon, ListBulletsIcon, ListNumbersIcon, QuotesIcon, TextHOneIcon, TextHThreeIcon, TextHTwoIcon, TextTIcon } from "@phosphor-icons/react"; +import { TextSelection } from "@tiptap/pm/state"; +import { type Editor } from "@tiptap/react"; + +import { getBlockNode, isBlockNodeType } from "../_lib/getBlockNode"; +import { MenuButton } from "../MenuButton"; + +import type { OptionsMenuFloating } from "./OptionsMenu"; +import { optionsMenuVariants } from "./variants"; + +const { menu } = optionsMenuVariants(); + +type OptionsMenuContentProps = { + editor: Editor; + hoveredBlock: HTMLElement | null; + setMenuOpen: React.Dispatch>; + setDropdownEl: OptionsMenuFloating["setDropdownEl"]; + menuRefs: OptionsMenuFloating["menuRefs"]; + menuStyles: OptionsMenuFloating["menuStyles"]; + getFloatingProps: OptionsMenuFloating["getFloatingProps"]; +}; + +export function OptionsMenuContent({ + editor, + hoveredBlock, + setMenuOpen, + setDropdownEl, + menuRefs, + menuStyles, + getFloatingProps, +}: OptionsMenuContentProps) { + const handleAction = ( + applyCommand: (chain: ReturnType) => ReturnType, + ) => { + if (!hoveredBlock) { + setMenuOpen(false); + return; + } + const data = getBlockNode(editor, hoveredBlock); + if (!data) { + setMenuOpen(false); + return; + } + + try { + if (!data.node.isTextblock) { + if (!data.node.firstChild) { + const paragraph = editor.state.schema.nodes.paragraph.create(); + editor.chain() + .focus() + .command(({ tr }) => { + tr.replaceWith(data.pos, data.pos + data.node.nodeSize, paragraph); + return true; + }) + .run(); + + const $near = editor.state.doc.resolve(data.pos + 1); + const textPos = TextSelection.near($near).from; + + applyCommand( + editor.chain() + .focus() + .setTextSelection(textPos), + ).run(); + + setMenuOpen(false); + return; + } + + let textBlock = data.node.firstChild; + if (!textBlock) return; + while (!textBlock.isTextblock && textBlock.firstChild) { + textBlock = textBlock.firstChild; + } + if (!textBlock.isTextblock) return; + + const firstChildSize = data.node.firstChild.nodeSize; + const paragraph = editor.state.schema.nodes.paragraph.create( + null, + textBlock.content, + ); + + editor.chain() + .focus() + .command(({ tr }) => { + tr.insert(data.pos, paragraph); + const wrapperPos = data.pos + paragraph.nodeSize; + const wrapperNode = tr.doc.nodeAt(wrapperPos); + if (!wrapperNode) return false; + + if (wrapperNode.childCount <= 1) { + tr.delete(wrapperPos, wrapperPos + wrapperNode.nodeSize); + } else { + tr.delete(wrapperPos + 1, wrapperPos + 1 + firstChildSize); + } + + return true; + }) + .run(); + } + + const $near = editor.state.doc.resolve(data.pos + 1); + const textPos = TextSelection.near($near).from; + + applyCommand( + editor.chain() + .focus() + .setTextSelection(textPos), + ).run(); + } catch { + // Block may no longer be in the document + } + + setMenuOpen(false); + }; + + return ( +
{ + setDropdownEl(node); + menuRefs.setFloating(node); + }} + style={menuStyles} + {...getFloatingProps()} + onMouseDown={e => e.preventDefault()} + className={menu()} + > +
Turn into
+ handleAction(chain => chain.setParagraph())} + > + + Text + + handleAction(chain => chain.toggleHeading({ level: 1 }))} + > + + Heading 1 + + handleAction(chain => chain.toggleHeading({ level: 2 }))} + > + + Heading 2 + + handleAction(chain => chain.toggleHeading({ level: 3 }))} + > + + Heading 3 + + handleAction(chain => chain.toggleBulletList())} + > + + Bullet List + + handleAction(chain => chain.toggleOrderedList())} + > + + Ordered List + + handleAction(chain => chain.toggleCodeBlock())} + > + + Code Block + + handleAction(chain => chain.toggleBlockquote())} + > + + Blockquote + +
+ ); +} diff --git a/packages/ui/src/RichEditor/OptionsMenu/OptionsMenuTrigger.tsx b/packages/ui/src/RichEditor/OptionsMenu/OptionsMenuTrigger.tsx new file mode 100644 index 000000000..89fbcf56f --- /dev/null +++ b/packages/ui/src/RichEditor/OptionsMenu/OptionsMenuTrigger.tsx @@ -0,0 +1,97 @@ +// Copyright (c) 2026 Probo Inc . +// Use of this source code is governed by the ISC license +// that can be found in the LICENSE file. + +import { DotsSixVerticalIcon } from "@phosphor-icons/react"; +import { NodeSelection } from "@tiptap/pm/state"; +import type { EditorView } from "@tiptap/pm/view"; +import { type Editor } from "@tiptap/react"; +import type { DragEvent } from "react"; + +import { getBlockNode } from "../_lib/getBlockNode"; +import { useBlockTrigger } from "../_lib/useBlockTrigger"; + +import type { OptionsMenuFloating } from "./OptionsMenu"; +import { optionsMenuVariants } from "./variants"; + +const { trigger } = optionsMenuVariants(); + +function startDrag(view: EditorView, slice: ReturnType, node: NodeSelection) { + view.dragging = { slice, move: true, node } as typeof view.dragging; +} + +type OptionsMenuTriggerProps = { + editor: Editor; + hoveredBlock: HTMLElement | null; + setHoveredBlock: (block: HTMLElement | null) => void; + setTriggerEl: OptionsMenuFloating["setTriggerEl"]; + menuRefs: OptionsMenuFloating["menuRefs"]; + getReferenceProps: OptionsMenuFloating["getReferenceProps"]; +}; + +export function OptionsMenuTrigger({ + editor, + hoveredBlock, + setHoveredBlock, + setTriggerEl, + menuRefs, + getReferenceProps, +}: OptionsMenuTriggerProps) { + const { triggerRefs, triggerStyles, isPositioned } = useBlockTrigger(hoveredBlock, 16); + + const onDragStart = (e: DragEvent) => { + if (!hoveredBlock) return; + const data = getBlockNode(editor, hoveredBlock); + if (!data) return; + + try { + const view = editor.view; + const selection = NodeSelection.create(view.state.doc, data.pos); + const slice = selection.content(); + + const { tr } = view.state; + tr.setSelection(selection); + view.dispatch(tr); + + if (e.dataTransfer) { + e.dataTransfer.clearData(); + e.dataTransfer.setData("text/plain", ""); + e.dataTransfer.effectAllowed = "move"; + + const wrapper = document.createElement("div"); + wrapper.append(hoveredBlock.cloneNode(true)); + wrapper.style.position = "absolute"; + wrapper.style.top = "-10000px"; + document.body.append(wrapper); + e.dataTransfer.setDragImage(wrapper, 0, 0); + document.addEventListener("dragend", () => wrapper.remove(), { once: true }); + } + + startDrag(view, slice, selection); + } catch { + // Block may no longer be in the document + } + }; + + return ( + + ); +} diff --git a/packages/ui/src/RichEditor/OptionsMenu/variants.ts b/packages/ui/src/RichEditor/OptionsMenu/variants.ts new file mode 100644 index 000000000..0e57b4701 --- /dev/null +++ b/packages/ui/src/RichEditor/OptionsMenu/variants.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 Probo Inc . +// Use of this source code is governed by the ISC license +// that can be found in the LICENSE file. + +import { tv } from "tailwind-variants"; + +export const optionsMenuVariants = tv({ + slots: { + trigger: [ + "z-10 flex size-6 items-center justify-center", + "rounded text-txt-tertiary hover:bg-subtle hover:text-txt-primary cursor-grab", + ], + menu: ["rounded-lg border border-border-mid bg-level-0 p-1 shadow-md z-20"], + }, +}); diff --git a/packages/ui/src/RichEditor/RichEditor.tsx b/packages/ui/src/RichEditor/RichEditor.tsx index 34dd3e8b2..4deeacf63 100644 --- a/packages/ui/src/RichEditor/RichEditor.tsx +++ b/packages/ui/src/RichEditor/RichEditor.tsx @@ -22,16 +22,16 @@ import { type Content, EditorContent, useEditor, useEditorState } from "@tiptap/ import { type ComponentProps, useEffect, useRef } from "react"; import { tv } from "tailwind-variants"; -import { BlockMenu } from "./BlockMenu"; +import { BlockMenu } from "./BlockMenu/BlockMenu"; import { BubbleMenu } from "./BubbleMenu"; import { FocusedCellExtension } from "./FocusedCellExtension"; import { LinkExtension } from "./LinkExtension"; -import { OptionsMenu } from "./OptionsMenu"; +import { OptionsMenu } from "./OptionsMenu/OptionsMenu"; import { PlaceholderExtension } from "./PlaceholderExtension"; import { SlashCommandExtension } from "./SlashCommandExtension"; -import { TableCellMenu } from "./TableCellMenu"; -import { TableColumnMenu } from "./TableColumnMenu"; -import { TableRowMenu } from "./TableRowMenu"; +import { TableCellMenu } from "./TableCellMenu/TableCellMenu"; +import { TableColumnMenu } from "./TableColumnMenu/TableColumnMenu"; +import { TableRowMenu } from "./TableRowMenu/TableRowMenu"; const extensions = [ Document, diff --git a/packages/ui/src/RichEditor/TableCellMenu/TableCellMenu.tsx b/packages/ui/src/RichEditor/TableCellMenu/TableCellMenu.tsx new file mode 100644 index 000000000..70974cf1b --- /dev/null +++ b/packages/ui/src/RichEditor/TableCellMenu/TableCellMenu.tsx @@ -0,0 +1,74 @@ +// Copyright (c) 2026 Probo Inc . +// Use of this source code is governed by the ISC license +// that can be found in the LICENSE file. + +import { cellAround, CellSelection } from "@tiptap/pm/tables"; +import { type Editor, useEditorState } from "@tiptap/react"; + +import { cellDomElement } from "../_lib/cellDomElement"; +import { useTableDropdownMenu } from "../_lib/useTableDropdownMenu"; + +import { TableCellMenuContent } from "./TableCellMenuContent"; +import { TableCellMenuTrigger } from "./TableCellMenuTrigger"; + +type TableCellMenuProps = { + editor: Editor; +}; + +function getActiveCellEl(editor: Editor): HTMLElement | null { + const { selection } = editor.state; + + if (selection instanceof CellSelection) { + return cellDomElement(editor, selection.$headCell.pos); + } + + const $pos = editor.state.doc.resolve(selection.from); + const cell = cellAround($pos); + if (!cell) return null; + return cellDomElement(editor, cell.pos); +} + +export function TableCellMenu({ editor }: TableCellMenuProps) { + const { + menuOpen, + setMenuOpen, + setTriggerEl, + setDropdownEl, + menuRefs, + menuStyles, + getFloatingProps, + } = useTableDropdownMenu(); + + const activeCellEl = useEditorState({ + editor, + selector: ({ editor: e }) => { + if (e.isDestroyed || !e.isEditable) return null; + return getActiveCellEl(e); + }, + }); + + if (!activeCellEl) return null; + + return ( + <> + + {menuOpen && ( + + )} + + ); +} diff --git a/packages/ui/src/RichEditor/TableCellMenu/TableCellMenuContent.tsx b/packages/ui/src/RichEditor/TableCellMenu/TableCellMenuContent.tsx new file mode 100644 index 000000000..d773716f7 --- /dev/null +++ b/packages/ui/src/RichEditor/TableCellMenu/TableCellMenuContent.tsx @@ -0,0 +1,98 @@ +// Copyright (c) 2026 Probo Inc . +// Use of this source code is governed by the ISC license +// that can be found in the LICENSE file. + +import { BroomIcon, IntersectIcon, SplitHorizontalIcon } from "@phosphor-icons/react"; +import { TextSelection } from "@tiptap/pm/state"; +import { cellAround, CellSelection } from "@tiptap/pm/tables"; +import { type Editor } from "@tiptap/react"; + +import type { DropdownMenu } from "../_lib/useTableDropdownMenu"; +import { MenuButton } from "../MenuButton"; + +import { tableCellMenuVariants } from "./variants"; + +const { menu } = tableCellMenuVariants(); + +type TableCellMenuContentProps = { + editor: Editor; + setMenuOpen: DropdownMenu["setMenuOpen"]; + setDropdownEl: DropdownMenu["setDropdownEl"]; + menuRefs: DropdownMenu["menuRefs"]; + menuStyles: DropdownMenu["menuStyles"]; + getFloatingProps: DropdownMenu["getFloatingProps"]; +}; + +export function TableCellMenuContent({ + editor, + setMenuOpen, + setDropdownEl, + menuRefs, + menuStyles, + getFloatingProps, +}: TableCellMenuContentProps) { + const handleMergeCells = () => { + editor.chain().focus().mergeCells().run(); + setMenuOpen(false); + }; + + const handleSplitCell = () => { + editor.chain().focus().splitCell().run(); + setMenuOpen(false); + }; + + const handleClearContents = () => { + const { state } = editor.view; + + if (state.selection instanceof CellSelection) { + editor.commands.deleteSelection(); + } else { + const { dispatch } = editor.view; + const { tr, schema } = state; + const $pos = state.doc.resolve(state.selection.from); + const cell = cellAround($pos); + if (cell) { + const cellNode = state.doc.nodeAt(cell.pos); + if (cellNode) { + const start = cell.pos + 1; + const end = cell.pos + cellNode.nodeSize - 1; + tr.replaceWith(start, end, schema.nodes.paragraph.create()); + tr.setSelection(TextSelection.create(tr.doc, start + 1)); + dispatch(tr); + } + } + } + + setMenuOpen(false); + }; + + return ( +
{ + setDropdownEl(node); + menuRefs.setFloating(node); + }} + style={menuStyles} + {...getFloatingProps()} + onMouseDown={e => e.preventDefault()} + className={menu()} + > + {editor.can().mergeCells() && ( + + + Merge cells + + )} + {editor.can().splitCell() && ( + + + Split cells + + )} + + + Clear contents + +
+ ); +} diff --git a/packages/ui/src/RichEditor/TableCellMenu.tsx b/packages/ui/src/RichEditor/TableCellMenu/TableCellMenuTrigger.tsx similarity index 54% rename from packages/ui/src/RichEditor/TableCellMenu.tsx rename to packages/ui/src/RichEditor/TableCellMenu/TableCellMenuTrigger.tsx index f02da49f4..a4552b93d 100644 --- a/packages/ui/src/RichEditor/TableCellMenu.tsx +++ b/packages/ui/src/RichEditor/TableCellMenu/TableCellMenuTrigger.tsx @@ -3,58 +3,36 @@ // that can be found in the LICENSE file. import { autoUpdate, offset, useFloating } from "@floating-ui/react"; -import { BroomIcon, CircleIcon, DotsThreeCircleVerticalIcon, IntersectIcon, SplitHorizontalIcon } from "@phosphor-icons/react"; -import { TextSelection } from "@tiptap/pm/state"; +import { CircleIcon, DotsThreeCircleVerticalIcon } from "@phosphor-icons/react"; import { cellAround, CellSelection, TableMap } from "@tiptap/pm/tables"; -import { type Editor, useEditorState } from "@tiptap/react"; +import { type Editor } from "@tiptap/react"; import { useLayoutEffect, useRef, useState } from "react"; -import { tv } from "tailwind-variants"; -import { cellDomElement } from "./_lib/cellDomElement"; -import { DRAG_THRESHOLD } from "./_lib/constants"; -import { useTableDropdownMenu } from "./_lib/useTableDropdownMenu"; -import { MenuButton } from "./MenuButton"; +import { cellDomElement } from "../_lib/cellDomElement"; +import { DRAG_THRESHOLD } from "../_lib/constants"; +import type { DropdownMenu } from "../_lib/useTableDropdownMenu"; -const tableCellMenuVariants = tv({ - slots: { - trigger: [ - "z-10 flex size-5 items-center justify-center", - "rounded text-border-info cursor-pointer", - ], - menu: ["rounded-lg border border-border-mid bg-level-0 p-1 shadow-md z-20"], - }, -}); +import { tableCellMenuVariants } from "./variants"; -const { trigger, menu } = tableCellMenuVariants(); +const { trigger } = tableCellMenuVariants(); -type TableCellMenuProps = { +type TableCellMenuTriggerProps = { editor: Editor; + activeCellEl: HTMLElement; + menuOpen: boolean; + setMenuOpen: DropdownMenu["setMenuOpen"]; + setTriggerEl: DropdownMenu["setTriggerEl"]; + menuRefs: DropdownMenu["menuRefs"]; }; -function getActiveCellEl(editor: Editor): HTMLElement | null { - const { selection } = editor.state; - - if (selection instanceof CellSelection) { - return cellDomElement(editor, selection.$headCell.pos); - } - - const $pos = editor.state.doc.resolve(selection.from); - const cell = cellAround($pos); - if (!cell) return null; - return cellDomElement(editor, cell.pos); -} - -export function TableCellMenu({ editor }: TableCellMenuProps) { - const { - menuOpen, - setMenuOpen, - setTriggerEl, - setDropdownEl, - menuRefs, - menuStyles, - getFloatingProps, - } = useTableDropdownMenu(); - +export function TableCellMenuTrigger({ + editor, + activeCellEl, + menuOpen, + setMenuOpen, + setTriggerEl, + menuRefs, +}: TableCellMenuTriggerProps) { const [handleHovered, setHandleHovered] = useState(false); const draggingRef = useRef(false); const dragStartPos = useRef({ x: 0, y: 0 }); @@ -64,14 +42,6 @@ export function TableCellMenu({ editor }: TableCellMenuProps) { tableStart: number; } | null>(null); - const activeCellEl = useEditorState({ - editor, - selector: ({ editor: e }) => { - if (e.isDestroyed || !e.isEditable) return null; - return getActiveCellEl(e); - }, - }); - const { refs: handleRefs, floatingStyles: handleStyles, @@ -85,11 +55,6 @@ export function TableCellMenu({ editor }: TableCellMenuProps) { }); useLayoutEffect(() => { - if (!activeCellEl) { - handleRefs.setReference(null); - return; - } - const ed = editor; const fallback = activeCellEl; @@ -119,8 +84,6 @@ export function TableCellMenu({ editor }: TableCellMenuProps) { }); }, [activeCellEl, editor, handleRefs]); - if (!activeCellEl) return null; - const getAnchorCellPos = (): number | null => { try { const { selection, doc } = editor.state; @@ -230,97 +193,30 @@ export function TableCellMenu({ editor }: TableCellMenuProps) { document.addEventListener("mouseup", onMouseUp); }; - const handleMergeCells = () => { - editor.chain().focus().mergeCells().run(); - setMenuOpen(false); - }; - - const handleSplitCell = () => { - editor.chain().focus().splitCell().run(); - setMenuOpen(false); - }; - - const handleClearContents = () => { - const { state } = editor.view; - - if (state.selection instanceof CellSelection) { - editor.commands.deleteSelection(); - } else { - const { dispatch } = editor.view; - const { tr, schema } = state; - const $pos = state.doc.resolve(state.selection.from); - const cell = cellAround($pos); - if (cell) { - const cellNode = state.doc.nodeAt(cell.pos); - if (cellNode) { - const start = cell.pos + 1; - const end = cell.pos + cellNode.nodeSize - 1; - tr.replaceWith(start, end, schema.nodes.paragraph.create()); - tr.setSelection(TextSelection.create(tr.doc, start + 1)); - dispatch(tr); - } - } - } - - setMenuOpen(false); - }; - return ( - <> - - {menuOpen && ( -
{ - setDropdownEl(node); - menuRefs.setFloating(node); - }} - style={menuStyles} - {...getFloatingProps()} - onMouseDown={e => e.preventDefault()} - className={menu()} - > - {editor.can().mergeCells() && ( - - - Merge cells - - )} - {editor.can().splitCell() && ( - - - Split cells - - )} - - - Clear contents - -
- )} - + ); } diff --git a/packages/ui/src/RichEditor/TableCellMenu/variants.ts b/packages/ui/src/RichEditor/TableCellMenu/variants.ts new file mode 100644 index 000000000..4390d9719 --- /dev/null +++ b/packages/ui/src/RichEditor/TableCellMenu/variants.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 Probo Inc . +// Use of this source code is governed by the ISC license +// that can be found in the LICENSE file. + +import { tv } from "tailwind-variants"; + +export const tableCellMenuVariants = tv({ + slots: { + trigger: [ + "z-10 flex size-5 items-center justify-center", + "rounded text-border-info cursor-pointer", + ], + menu: ["rounded-lg border border-border-mid bg-level-0 p-1 shadow-md z-20"], + }, +}); diff --git a/packages/ui/src/RichEditor/TableColumnMenu/TableColumnMenu.tsx b/packages/ui/src/RichEditor/TableColumnMenu/TableColumnMenu.tsx new file mode 100644 index 000000000..577223014 --- /dev/null +++ b/packages/ui/src/RichEditor/TableColumnMenu/TableColumnMenu.tsx @@ -0,0 +1,132 @@ +// Copyright (c) 2026 Probo Inc . +// Use of this source code is governed by the ISC license +// that can be found in the LICENSE file. + +import type { Node as PMNode } from "@tiptap/pm/model"; +import { TableMap } from "@tiptap/pm/tables"; +import { type Editor } from "@tiptap/react"; +import { useState } from "react"; + +import { cellDomElement } from "../_lib/cellDomElement"; +import { useTableDropdownMenu } from "../_lib/useTableDropdownMenu"; + +import { TableColumnMenuContent } from "./TableColumnMenuContent"; +import { TableColumnMenuTrigger } from "./TableColumnMenuTrigger"; + +export type HoveredColumn = { + colIndex: number; + tableStart: number; +}; + +export function getColumnRect( + editor: Editor, + tableStart: number, + colIndex: number, +): DOMRect | null { + try { + const tableNodePos = tableStart - 1; + const table = editor.state.doc.nodeAt(tableNodePos); + if (!table) return null; + + const map = TableMap.get(table); + if (colIndex < 0 || colIndex >= map.width) return null; + + const cellPos = map.positionAt(0, colIndex, table) + tableStart; + const el = cellDomElement(editor, cellPos); + if (!el) return null; + + const topRect = el.getBoundingClientRect(); + let bottom = topRect.bottom; + + if (map.height > 1) { + const lastCellPos + = map.positionAt(map.height - 1, colIndex, table) + tableStart; + const lastEl = cellDomElement(editor, lastCellPos); + if (lastEl) { + bottom = lastEl.getBoundingClientRect().bottom; + } + } + + return new DOMRect( + topRect.left, + topRect.top, + topRect.width, + bottom - topRect.top, + ); + } catch { + return null; + } +} + +export function moveColumn( + editor: Editor, + tableStart: number, + fromCol: number, + toCol: number, +) { + if (fromCol === toCol) return; + + const tableNodePos = tableStart - 1; + const table = editor.state.doc.nodeAt(tableNodePos); + if (!table) return; + + const rows: PMNode[] = []; + table.forEach((row) => { + const cells: PMNode[] = []; + row.forEach(cell => cells.push(cell)); + const [moved] = cells.splice(fromCol, 1); + cells.splice(toCol, 0, moved); + rows.push(row.type.create(row.attrs, cells)); + }); + + const newTable = table.type.create(table.attrs, rows); + const { tr } = editor.state; + tr.replaceWith(tableNodePos, tableNodePos + table.nodeSize, newTable); + editor.view.dispatch(tr); +} + +type TableColumnMenuProps = { + editor: Editor; +}; + +export function TableColumnMenu({ editor }: TableColumnMenuProps) { + const { + menuOpen, + setMenuOpen, + setTriggerEl, + setDropdownEl, + menuRefs, + menuStyles, + getFloatingProps, + } = useTableDropdownMenu(); + + const [hoveredCol, setHoveredCol] = useState(null); + + if (!hoveredCol && !menuOpen) return null; + + return ( + <> + + {menuOpen && hoveredCol && ( + + )} + + ); +} diff --git a/packages/ui/src/RichEditor/TableColumnMenu/TableColumnMenuContent.tsx b/packages/ui/src/RichEditor/TableColumnMenu/TableColumnMenuContent.tsx new file mode 100644 index 000000000..2e7c80674 --- /dev/null +++ b/packages/ui/src/RichEditor/TableColumnMenu/TableColumnMenuContent.tsx @@ -0,0 +1,268 @@ +// Copyright (c) 2026 Probo Inc . +// Use of this source code is governed by the ISC license +// that can be found in the LICENSE file. + +import { + BroomIcon, + CopyIcon, + CrownSimpleIcon, + PlusIcon, + TrashIcon, +} from "@phosphor-icons/react"; +import { TextSelection } from "@tiptap/pm/state"; +import { CellSelection, TableMap } from "@tiptap/pm/tables"; +import { type Editor } from "@tiptap/react"; + +import type { DropdownMenu } from "../_lib/useTableDropdownMenu"; +import { MenuButton } from "../MenuButton"; + +import { type HoveredColumn } from "./TableColumnMenu"; +import { tableColumnMenuVariants } from "./variants"; + +const { menu } = tableColumnMenuVariants(); + +type TableColumnMenuContentProps = { + editor: Editor; + hoveredCol: HoveredColumn; + setMenuOpen: DropdownMenu["setMenuOpen"]; + setHoveredCol: React.Dispatch>; + setDropdownEl: DropdownMenu["setDropdownEl"]; + menuRefs: DropdownMenu["menuRefs"]; + menuStyles: DropdownMenu["menuStyles"]; + getFloatingProps: DropdownMenu["getFloatingProps"]; +}; + +export function TableColumnMenuContent({ + editor, + hoveredCol, + setMenuOpen, + setHoveredCol, + setDropdownEl, + menuRefs, + menuStyles, + getFloatingProps, +}: TableColumnMenuContentProps) { + const currentCol = hoveredCol; + const isFirstColumn = currentCol.colIndex === 0; + + const isHeaderColumn = (): boolean => { + if (currentCol.colIndex !== 0) return false; + try { + const table = editor.state.doc.nodeAt(currentCol.tableStart - 1); + if (!table) return false; + const map = TableMap.get(table); + for (let row = 0; row < map.height; row++) { + const cellPos = map.map[row * map.width] + currentCol.tableStart; + const cellNode = editor.state.doc.nodeAt(cellPos); + if (!cellNode || cellNode.type.name !== "tableHeader") return false; + } + return true; + } catch { + return false; + } + }; + + const handleToggleHeaderColumn = () => { + if (currentCol.colIndex !== 0) return; + const { tableStart } = currentCol; + + try { + const table = editor.state.doc.nodeAt(tableStart - 1); + if (!table) return; + + const map = TableMap.get(table); + const cellPos = map.positionAt(0, 0, table) + tableStart; + + editor + .chain() + .focus() + .command(({ tr }) => { + tr.setSelection(TextSelection.create(tr.doc, cellPos + 1)); + return true; + }) + .toggleHeaderColumn() + .run(); + } catch { + // table may have changed + } + + setMenuOpen(false); + }; + + const handleDeleteColumn = () => { + const { colIndex, tableStart } = currentCol; + + try { + const table = editor.state.doc.nodeAt(tableStart - 1); + if (!table) return; + + const map = TableMap.get(table); + const cellPos = map.positionAt(0, colIndex, table) + tableStart; + + editor + .chain() + .focus() + .command(({ tr }) => { + tr.setSelection(TextSelection.create(tr.doc, cellPos + 1)); + return true; + }) + .deleteColumn() + .run(); + } catch { + // table may have changed + } + + setMenuOpen(false); + setHoveredCol(null); + }; + + const handleDuplicateColumn = () => { + const { colIndex, tableStart } = currentCol; + + try { + const table = editor.state.doc.nodeAt(tableStart - 1); + if (!table) return; + + const map = TableMap.get(table); + + editor + .chain() + .focus() + .command(({ tr }) => { + for (let row = map.height - 1; row >= 0; row--) { + const cellOffset = map.map[row * map.width + colIndex]; + const cell = table.nodeAt(cellOffset); + if (!cell) continue; + + const insertPos = cellOffset + tableStart + cell.nodeSize; + tr.insert(insertPos, cell); + } + return true; + }) + .run(); + } catch { + // table may have changed + } + + setMenuOpen(false); + }; + + const handleInsertLeft = () => { + const { colIndex, tableStart } = currentCol; + + try { + const table = editor.state.doc.nodeAt(tableStart - 1); + if (!table) return; + + const map = TableMap.get(table); + const cellPos = map.positionAt(0, colIndex, table) + tableStart; + + editor + .chain() + .focus() + .command(({ tr }) => { + tr.setSelection(TextSelection.create(tr.doc, cellPos + 1)); + return true; + }) + .addColumnBefore() + .run(); + } catch { + // table may have changed + } + + setMenuOpen(false); + }; + + const handleInsertRight = () => { + const { colIndex, tableStart } = currentCol; + + try { + const table = editor.state.doc.nodeAt(tableStart - 1); + if (!table) return; + + const map = TableMap.get(table); + const cellPos = map.positionAt(0, colIndex, table) + tableStart; + + editor + .chain() + .focus() + .command(({ tr }) => { + tr.setSelection(TextSelection.create(tr.doc, cellPos + 1)); + return true; + }) + .addColumnAfter() + .run(); + } catch { + // table may have changed + } + + setMenuOpen(false); + }; + + const handleClearContents = () => { + const { colIndex, tableStart } = currentCol; + + try { + const table = editor.state.doc.nodeAt(tableStart - 1); + if (!table) return; + + const map = TableMap.get(table); + const firstCellPos = map.map[colIndex] + tableStart; + const lastCellPos + = map.map[(map.height - 1) * map.width + colIndex] + tableStart; + + const $anchor = editor.state.doc.resolve(firstCellPos); + const $head = editor.state.doc.resolve(lastCellPos); + + editor.view.dispatch( + editor.state.tr.setSelection(new CellSelection($anchor, $head)), + ); + editor.commands.deleteSelection(); + } catch { + // table may have changed + } + + setMenuOpen(false); + }; + + return ( +
{ + setDropdownEl(node); + menuRefs.setFloating(node); + }} + data-column-menu + style={menuStyles} + {...getFloatingProps()} + onMouseDown={e => e.preventDefault()} + className={menu()} + > + {isFirstColumn && ( + + + Header column + + )} + + + Insert column left + + + + Insert column right + + + + Duplicate column + + + + Clear contents + + + + Delete column + +
+ ); +} diff --git a/packages/ui/src/RichEditor/TableColumnMenu.tsx b/packages/ui/src/RichEditor/TableColumnMenu/TableColumnMenuTrigger.tsx similarity index 50% rename from packages/ui/src/RichEditor/TableColumnMenu.tsx rename to packages/ui/src/RichEditor/TableColumnMenu/TableColumnMenuTrigger.tsx index ad79d816d..d9f823d52 100644 --- a/packages/ui/src/RichEditor/TableColumnMenu.tsx +++ b/packages/ui/src/RichEditor/TableColumnMenu/TableColumnMenuTrigger.tsx @@ -3,127 +3,38 @@ // that can be found in the LICENSE file. import { autoUpdate, offset, size, useFloating } from "@floating-ui/react"; -import { - BroomIcon, - CopyIcon, - CrownSimpleIcon, - DotsThreeIcon, - PlusIcon, - TrashIcon, -} from "@phosphor-icons/react"; -import type { Node as PMNode } from "@tiptap/pm/model"; -import { TextSelection } from "@tiptap/pm/state"; +import { DotsThreeIcon } from "@phosphor-icons/react"; import { cellAround, CellSelection, TableMap } from "@tiptap/pm/tables"; import { type Editor } from "@tiptap/react"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { tv } from "tailwind-variants"; -import { cellDomElement } from "./_lib/cellDomElement"; -import { DRAG_THRESHOLD } from "./_lib/constants"; -import { useTableDropdownMenu } from "./_lib/useTableDropdownMenu"; -import { MenuButton } from "./MenuButton"; +import { DRAG_THRESHOLD } from "../_lib/constants"; +import type { DropdownMenu } from "../_lib/useTableDropdownMenu"; -const tableColumnMenuVariants = tv({ - slots: { - trigger: [ - "z-10 flex items-center justify-center", - "rounded text-txt-tertiary bg-subtle hover:bg-border-solid cursor-grab", - "py-0.5 h-3", - ], - menu: ["rounded-lg border border-border-mid bg-level-0 p-1 shadow-md z-20"], - }, -}); +import { getColumnRect, type HoveredColumn, moveColumn } from "./TableColumnMenu"; +import { tableColumnMenuVariants } from "./variants"; -const { trigger, menu } = tableColumnMenuVariants(); +const { trigger } = tableColumnMenuVariants(); -type HoveredColumn = { - colIndex: number; - tableStart: number; -}; - -type TableColumnMenuProps = { +type TableColumnMenuTriggerProps = { editor: Editor; + hoveredCol: HoveredColumn | null; + setHoveredCol: React.Dispatch>; + menuOpen: boolean; + setMenuOpen: DropdownMenu["setMenuOpen"]; + setTriggerEl: DropdownMenu["setTriggerEl"]; + menuRefs: DropdownMenu["menuRefs"]; }; -function getColumnRect( - editor: Editor, - tableStart: number, - colIndex: number, -): DOMRect | null { - try { - const tableNodePos = tableStart - 1; - const table = editor.state.doc.nodeAt(tableNodePos); - if (!table) return null; - - const map = TableMap.get(table); - if (colIndex < 0 || colIndex >= map.width) return null; - - const cellPos = map.positionAt(0, colIndex, table) + tableStart; - const el = cellDomElement(editor, cellPos); - if (!el) return null; - - const topRect = el.getBoundingClientRect(); - let bottom = topRect.bottom; - - if (map.height > 1) { - const lastCellPos - = map.positionAt(map.height - 1, colIndex, table) + tableStart; - const lastEl = cellDomElement(editor, lastCellPos); - if (lastEl) { - bottom = lastEl.getBoundingClientRect().bottom; - } - } - - return new DOMRect( - topRect.left, - topRect.top, - topRect.width, - bottom - topRect.top, - ); - } catch { - return null; - } -} - -function moveColumn( - editor: Editor, - tableStart: number, - fromCol: number, - toCol: number, -) { - if (fromCol === toCol) return; - - const tableNodePos = tableStart - 1; - const table = editor.state.doc.nodeAt(tableNodePos); - if (!table) return; - - const rows: PMNode[] = []; - table.forEach((row) => { - const cells: PMNode[] = []; - row.forEach(cell => cells.push(cell)); - const [moved] = cells.splice(fromCol, 1); - cells.splice(toCol, 0, moved); - rows.push(row.type.create(row.attrs, cells)); - }); - - const newTable = table.type.create(table.attrs, rows); - const { tr } = editor.state; - tr.replaceWith(tableNodePos, tableNodePos + table.nodeSize, newTable); - editor.view.dispatch(tr); -} - -export function TableColumnMenu({ editor }: TableColumnMenuProps) { - const { - menuOpen, - setMenuOpen, - setTriggerEl, - setDropdownEl, - menuRefs, - menuStyles, - getFloatingProps, - } = useTableDropdownMenu(); - - const [hoveredCol, setHoveredCol] = useState(null); +export function TableColumnMenuTrigger({ + editor, + hoveredCol, + setHoveredCol, + menuOpen, + setMenuOpen, + setTriggerEl, + menuRefs, +}: TableColumnMenuTriggerProps) { const [dragIndicator, setDragIndicator] = useState<{ left: number; top: number; @@ -217,7 +128,7 @@ export function TableColumnMenu({ editor }: TableColumnMenuProps) { rafId.current = null; } }; - }, [editor, menuOpen]); + }, [editor, menuOpen, setHoveredCol]); const { refs: handleRefs, @@ -258,8 +169,6 @@ export function TableColumnMenu({ editor }: TableColumnMenuProps) { }); }, [hoveredCol, editor, handleRefs]); - if (!hoveredCol && !menuOpen) return null; - const computeTargetGap = (clientX: number, tableStart: number): number => { const table = editor.state.doc.nodeAt(tableStart - 1); if (!table) return 0; @@ -420,195 +329,6 @@ export function TableColumnMenu({ editor }: TableColumnMenuProps) { document.addEventListener("mouseup", onMouseUp); }; - const currentCol = hoveredCol; - - const isFirstColumn = currentCol?.colIndex === 0; - - const isHeaderColumn = (): boolean => { - if (!currentCol || currentCol.colIndex !== 0) return false; - try { - const table = editor.state.doc.nodeAt(currentCol.tableStart - 1); - if (!table) return false; - const map = TableMap.get(table); - for (let row = 0; row < map.height; row++) { - const cellPos = map.map[row * map.width] + currentCol.tableStart; - const cellNode = editor.state.doc.nodeAt(cellPos); - if (!cellNode || cellNode.type.name !== "tableHeader") return false; - } - return true; - } catch { - return false; - } - }; - - const handleToggleHeaderColumn = () => { - if (!currentCol || currentCol.colIndex !== 0) return; - const { tableStart } = currentCol; - - try { - const table = editor.state.doc.nodeAt(tableStart - 1); - if (!table) return; - - const map = TableMap.get(table); - const cellPos = map.positionAt(0, 0, table) + tableStart; - - editor - .chain() - .focus() - .command(({ tr }) => { - tr.setSelection(TextSelection.create(tr.doc, cellPos + 1)); - return true; - }) - .toggleHeaderColumn() - .run(); - } catch { - // table may have changed - } - - setMenuOpen(false); - }; - - const handleDeleteColumn = () => { - if (!currentCol) return; - const { colIndex, tableStart } = currentCol; - - try { - const table = editor.state.doc.nodeAt(tableStart - 1); - if (!table) return; - - const map = TableMap.get(table); - const cellPos = map.positionAt(0, colIndex, table) + tableStart; - - editor - .chain() - .focus() - .command(({ tr }) => { - tr.setSelection(TextSelection.create(tr.doc, cellPos + 1)); - return true; - }) - .deleteColumn() - .run(); - } catch { - // table may have changed - } - - setMenuOpen(false); - setHoveredCol(null); - }; - - const handleDuplicateColumn = () => { - if (!currentCol) return; - const { colIndex, tableStart } = currentCol; - - try { - const table = editor.state.doc.nodeAt(tableStart - 1); - if (!table) return; - - const map = TableMap.get(table); - - editor - .chain() - .focus() - .command(({ tr }) => { - for (let row = map.height - 1; row >= 0; row--) { - const cellOffset = map.map[row * map.width + colIndex]; - const cell = table.nodeAt(cellOffset); - if (!cell) continue; - - const insertPos = cellOffset + tableStart + cell.nodeSize; - tr.insert(insertPos, cell); - } - return true; - }) - .run(); - } catch { - // table may have changed - } - - setMenuOpen(false); - }; - - const handleInsertLeft = () => { - if (!currentCol) return; - const { colIndex, tableStart } = currentCol; - - try { - const table = editor.state.doc.nodeAt(tableStart - 1); - if (!table) return; - - const map = TableMap.get(table); - const cellPos = map.positionAt(0, colIndex, table) + tableStart; - - editor - .chain() - .focus() - .command(({ tr }) => { - tr.setSelection(TextSelection.create(tr.doc, cellPos + 1)); - return true; - }) - .addColumnBefore() - .run(); - } catch { - // table may have changed - } - - setMenuOpen(false); - }; - - const handleInsertRight = () => { - if (!currentCol) return; - const { colIndex, tableStart } = currentCol; - - try { - const table = editor.state.doc.nodeAt(tableStart - 1); - if (!table) return; - - const map = TableMap.get(table); - const cellPos = map.positionAt(0, colIndex, table) + tableStart; - - editor - .chain() - .focus() - .command(({ tr }) => { - tr.setSelection(TextSelection.create(tr.doc, cellPos + 1)); - return true; - }) - .addColumnAfter() - .run(); - } catch { - // table may have changed - } - - setMenuOpen(false); - }; - - const handleClearContents = () => { - if (!currentCol) return; - const { colIndex, tableStart } = currentCol; - - try { - const table = editor.state.doc.nodeAt(tableStart - 1); - if (!table) return; - - const map = TableMap.get(table); - const firstCellPos = map.map[colIndex] + tableStart; - const lastCellPos - = map.map[(map.height - 1) * map.width + colIndex] + tableStart; - - const $anchor = editor.state.doc.resolve(firstCellPos); - const $head = editor.state.doc.resolve(lastCellPos); - - editor.view.dispatch( - editor.state.tr.setSelection(new CellSelection($anchor, $head)), - ); - editor.commands.deleteSelection(); - } catch { - // table may have changed - } - - setMenuOpen(false); - }; - return ( <> - {menuOpen && ( -
{ - setDropdownEl(node); - menuRefs.setFloating(node); - }} - data-column-menu - style={menuStyles} - {...getFloatingProps()} - onMouseDown={e => e.preventDefault()} - className={menu()} - > - {isFirstColumn && ( - - - Header column - - )} - - - Insert column left - - - - Insert column right - - - - Duplicate column - - - - Clear contents - - - - Delete column - -
- )} {dragIndicator && (
. +// Use of this source code is governed by the ISC license +// that can be found in the LICENSE file. + +import { tv } from "tailwind-variants"; + +export const tableColumnMenuVariants = tv({ + slots: { + trigger: [ + "z-10 flex items-center justify-center", + "rounded text-txt-tertiary bg-subtle hover:bg-border-solid cursor-grab", + "py-0.5 h-3", + ], + menu: ["rounded-lg border border-border-mid bg-level-0 p-1 shadow-md z-20"], + }, +}); diff --git a/packages/ui/src/RichEditor/TableRowMenu/TableRowMenu.tsx b/packages/ui/src/RichEditor/TableRowMenu/TableRowMenu.tsx new file mode 100644 index 000000000..9fb69eb73 --- /dev/null +++ b/packages/ui/src/RichEditor/TableRowMenu/TableRowMenu.tsx @@ -0,0 +1,128 @@ +// Copyright (c) 2026 Probo Inc . +// Use of this source code is governed by the ISC license +// that can be found in the LICENSE file. + +import type { Node as PMNode } from "@tiptap/pm/model"; +import { TableMap } from "@tiptap/pm/tables"; +import { type Editor } from "@tiptap/react"; +import { useState } from "react"; + +import { cellDomElement } from "../_lib/cellDomElement"; +import { useTableDropdownMenu } from "../_lib/useTableDropdownMenu"; + +import { TableRowMenuContent } from "./TableRowMenuContent"; +import { TableRowMenuTrigger } from "./TableRowMenuTrigger"; + +export type HoveredRow = { + rowIndex: number; + tableStart: number; +}; + +export function getRowRect( + editor: Editor, + tableStart: number, + rowIndex: number, +): DOMRect | null { + try { + const tableNodePos = tableStart - 1; + const table = editor.state.doc.nodeAt(tableNodePos); + if (!table) return null; + + const map = TableMap.get(table); + if (rowIndex < 0 || rowIndex >= map.height) return null; + + const cellPos = map.positionAt(rowIndex, 0, table) + tableStart; + const el = cellDomElement(editor, cellPos); + if (!el) return null; + + const leftRect = el.getBoundingClientRect(); + let right = leftRect.right; + + if (map.width > 1) { + const lastCellPos + = map.positionAt(rowIndex, map.width - 1, table) + tableStart; + const lastEl = cellDomElement(editor, lastCellPos); + if (lastEl) { + right = lastEl.getBoundingClientRect().right; + } + } + + return new DOMRect( + leftRect.left, + leftRect.top, + right - leftRect.left, + leftRect.height, + ); + } catch { + return null; + } +} + +export function moveRow( + editor: Editor, + tableStart: number, + fromRow: number, + toRow: number, +) { + if (fromRow === toRow) return; + + const tableNodePos = tableStart - 1; + const table = editor.state.doc.nodeAt(tableNodePos); + if (!table) return; + + const rows: PMNode[] = []; + table.forEach(row => rows.push(row)); + const [moved] = rows.splice(fromRow, 1); + rows.splice(toRow, 0, moved); + + const newTable = table.type.create(table.attrs, rows); + const { tr } = editor.state; + tr.replaceWith(tableNodePos, tableNodePos + table.nodeSize, newTable); + editor.view.dispatch(tr); +} + +type TableRowMenuProps = { + editor: Editor; +}; + +export function TableRowMenu({ editor }: TableRowMenuProps) { + const { + menuOpen, + setMenuOpen, + setTriggerEl, + setDropdownEl, + menuRefs, + menuStyles, + getFloatingProps, + } = useTableDropdownMenu(); + + const [hoveredRow, setHoveredRow] = useState(null); + + if (!hoveredRow && !menuOpen) return null; + + return ( + <> + + {menuOpen && hoveredRow && ( + + )} + + ); +} diff --git a/packages/ui/src/RichEditor/TableRowMenu/TableRowMenuContent.tsx b/packages/ui/src/RichEditor/TableRowMenu/TableRowMenuContent.tsx new file mode 100644 index 000000000..915e22d4a --- /dev/null +++ b/packages/ui/src/RichEditor/TableRowMenu/TableRowMenuContent.tsx @@ -0,0 +1,266 @@ +// Copyright (c) 2026 Probo Inc . +// Use of this source code is governed by the ISC license +// that can be found in the LICENSE file. + +import { + BroomIcon, + CopyIcon, + CrownSimpleIcon, + PlusIcon, + TrashIcon, +} from "@phosphor-icons/react"; +import { TextSelection } from "@tiptap/pm/state"; +import { CellSelection, TableMap } from "@tiptap/pm/tables"; +import { type Editor } from "@tiptap/react"; + +import type { DropdownMenu } from "../_lib/useTableDropdownMenu"; +import { MenuButton } from "../MenuButton"; + +import { type HoveredRow } from "./TableRowMenu"; +import { tableRowMenuVariants } from "./variants"; + +const { menu } = tableRowMenuVariants(); + +type TableRowMenuContentProps = { + editor: Editor; + hoveredRow: HoveredRow; + setMenuOpen: DropdownMenu["setMenuOpen"]; + setHoveredRow: React.Dispatch>; + setDropdownEl: DropdownMenu["setDropdownEl"]; + menuRefs: DropdownMenu["menuRefs"]; + menuStyles: DropdownMenu["menuStyles"]; + getFloatingProps: DropdownMenu["getFloatingProps"]; +}; + +export function TableRowMenuContent({ + editor, + hoveredRow, + setMenuOpen, + setHoveredRow, + setDropdownEl, + menuRefs, + menuStyles, + getFloatingProps, +}: TableRowMenuContentProps) { + const currentRow = hoveredRow; + const isFirstRow = currentRow.rowIndex === 0; + + const isHeaderRow = (): boolean => { + if (currentRow.rowIndex !== 0) return false; + try { + const table = editor.state.doc.nodeAt(currentRow.tableStart - 1); + if (!table) return false; + const map = TableMap.get(table); + for (let col = 0; col < map.width; col++) { + const cellPos = map.map[col] + currentRow.tableStart; + const cellNode = editor.state.doc.nodeAt(cellPos); + if (!cellNode || cellNode.type.name !== "tableHeader") return false; + } + return true; + } catch { + return false; + } + }; + + const handleToggleHeaderRow = () => { + if (currentRow.rowIndex !== 0) return; + const { tableStart } = currentRow; + + try { + const table = editor.state.doc.nodeAt(tableStart - 1); + if (!table) return; + + const map = TableMap.get(table); + const cellPos = map.positionAt(0, 0, table) + tableStart; + + editor + .chain() + .focus() + .command(({ tr }) => { + tr.setSelection(TextSelection.create(tr.doc, cellPos + 1)); + return true; + }) + .toggleHeaderRow() + .run(); + } catch { + // table may have changed + } + + setMenuOpen(false); + }; + + const handleDeleteRow = () => { + const { rowIndex, tableStart } = currentRow; + + try { + const table = editor.state.doc.nodeAt(tableStart - 1); + if (!table) return; + + const map = TableMap.get(table); + const cellPos = map.positionAt(rowIndex, 0, table) + tableStart; + + editor + .chain() + .focus() + .command(({ tr }) => { + tr.setSelection(TextSelection.create(tr.doc, cellPos + 1)); + return true; + }) + .deleteRow() + .run(); + } catch { + // table may have changed + } + + setMenuOpen(false); + setHoveredRow(null); + }; + + const handleDuplicateRow = () => { + const { rowIndex, tableStart } = currentRow; + + try { + const table = editor.state.doc.nodeAt(tableStart - 1); + if (!table) return; + + const rowNode = table.child(rowIndex); + + let insertPos = tableStart; + for (let i = 0; i <= rowIndex; i++) { + insertPos += table.child(i).nodeSize; + } + + editor + .chain() + .focus() + .command(({ tr }) => { + tr.insert(insertPos, rowNode); + return true; + }) + .run(); + } catch { + // table may have changed + } + + setMenuOpen(false); + }; + + const handleInsertAbove = () => { + const { rowIndex, tableStart } = currentRow; + + try { + const table = editor.state.doc.nodeAt(tableStart - 1); + if (!table) return; + + const map = TableMap.get(table); + const cellPos = map.positionAt(rowIndex, 0, table) + tableStart; + + editor + .chain() + .focus() + .command(({ tr }) => { + tr.setSelection(TextSelection.create(tr.doc, cellPos + 1)); + return true; + }) + .addRowBefore() + .run(); + } catch { + // table may have changed + } + + setMenuOpen(false); + }; + + const handleInsertBelow = () => { + const { rowIndex, tableStart } = currentRow; + + try { + const table = editor.state.doc.nodeAt(tableStart - 1); + if (!table) return; + + const map = TableMap.get(table); + const cellPos = map.positionAt(rowIndex, 0, table) + tableStart; + + editor + .chain() + .focus() + .command(({ tr }) => { + tr.setSelection(TextSelection.create(tr.doc, cellPos + 1)); + return true; + }) + .addRowAfter() + .run(); + } catch { + // table may have changed + } + + setMenuOpen(false); + }; + + const handleClearContents = () => { + const { rowIndex, tableStart } = currentRow; + + try { + const table = editor.state.doc.nodeAt(tableStart - 1); + if (!table) return; + + const map = TableMap.get(table); + const firstCellPos = map.map[rowIndex * map.width] + tableStart; + const lastCellPos + = map.map[rowIndex * map.width + (map.width - 1)] + tableStart; + + const $anchor = editor.state.doc.resolve(firstCellPos); + const $head = editor.state.doc.resolve(lastCellPos); + + editor.view.dispatch( + editor.state.tr.setSelection(new CellSelection($anchor, $head)), + ); + editor.commands.deleteSelection(); + } catch { + // table may have changed + } + + setMenuOpen(false); + }; + + return ( +
{ + setDropdownEl(node); + menuRefs.setFloating(node); + }} + data-row-menu + style={menuStyles} + {...getFloatingProps()} + onMouseDown={e => e.preventDefault()} + className={menu()} + > + {isFirstRow && ( + + + Header row + + )} + + + Insert row above + + + + Insert row below + + + + Duplicate row + + + + Clear contents + + + + Delete row + +
+ ); +} diff --git a/packages/ui/src/RichEditor/TableRowMenu.tsx b/packages/ui/src/RichEditor/TableRowMenu/TableRowMenuTrigger.tsx similarity index 51% rename from packages/ui/src/RichEditor/TableRowMenu.tsx rename to packages/ui/src/RichEditor/TableRowMenu/TableRowMenuTrigger.tsx index 357abd235..68ec03a49 100644 --- a/packages/ui/src/RichEditor/TableRowMenu.tsx +++ b/packages/ui/src/RichEditor/TableRowMenu/TableRowMenuTrigger.tsx @@ -3,123 +3,38 @@ // that can be found in the LICENSE file. import { autoUpdate, offset, size, useFloating } from "@floating-ui/react"; -import { - BroomIcon, - CopyIcon, - CrownSimpleIcon, - DotsThreeVerticalIcon, - PlusIcon, - TrashIcon, -} from "@phosphor-icons/react"; -import type { Node as PMNode } from "@tiptap/pm/model"; -import { TextSelection } from "@tiptap/pm/state"; +import { DotsThreeVerticalIcon } from "@phosphor-icons/react"; import { cellAround, CellSelection, TableMap } from "@tiptap/pm/tables"; import { type Editor } from "@tiptap/react"; import { useEffect, useLayoutEffect, useRef, useState } from "react"; -import { tv } from "tailwind-variants"; -import { cellDomElement } from "./_lib/cellDomElement"; -import { DRAG_THRESHOLD } from "./_lib/constants"; -import { useTableDropdownMenu } from "./_lib/useTableDropdownMenu"; -import { MenuButton } from "./MenuButton"; +import { DRAG_THRESHOLD } from "../_lib/constants"; +import type { DropdownMenu } from "../_lib/useTableDropdownMenu"; -const tableRowMenuVariants = tv({ - slots: { - trigger: [ - "z-10 flex flex-col items-center justify-center", - "rounded text-txt-tertiary bg-subtle hover:bg-border-solid cursor-grab", - "px-0.5 w-3", - ], - menu: ["rounded-lg border border-border-mid bg-level-0 p-1 shadow-md z-20"], - }, -}); +import { getRowRect, type HoveredRow, moveRow } from "./TableRowMenu"; +import { tableRowMenuVariants } from "./variants"; -const { trigger, menu } = tableRowMenuVariants(); +const { trigger } = tableRowMenuVariants(); -type HoveredRow = { - rowIndex: number; - tableStart: number; -}; - -type TableRowMenuProps = { +type TableRowMenuTriggerProps = { editor: Editor; + hoveredRow: HoveredRow | null; + setHoveredRow: React.Dispatch>; + menuOpen: boolean; + setMenuOpen: DropdownMenu["setMenuOpen"]; + setTriggerEl: DropdownMenu["setTriggerEl"]; + menuRefs: DropdownMenu["menuRefs"]; }; -function getRowRect( - editor: Editor, - tableStart: number, - rowIndex: number, -): DOMRect | null { - try { - const tableNodePos = tableStart - 1; - const table = editor.state.doc.nodeAt(tableNodePos); - if (!table) return null; - - const map = TableMap.get(table); - if (rowIndex < 0 || rowIndex >= map.height) return null; - - const cellPos = map.positionAt(rowIndex, 0, table) + tableStart; - const el = cellDomElement(editor, cellPos); - if (!el) return null; - - const leftRect = el.getBoundingClientRect(); - let right = leftRect.right; - - if (map.width > 1) { - const lastCellPos - = map.positionAt(rowIndex, map.width - 1, table) + tableStart; - const lastEl = cellDomElement(editor, lastCellPos); - if (lastEl) { - right = lastEl.getBoundingClientRect().right; - } - } - - return new DOMRect( - leftRect.left, - leftRect.top, - right - leftRect.left, - leftRect.height, - ); - } catch { - return null; - } -} - -function moveRow( - editor: Editor, - tableStart: number, - fromRow: number, - toRow: number, -) { - if (fromRow === toRow) return; - - const tableNodePos = tableStart - 1; - const table = editor.state.doc.nodeAt(tableNodePos); - if (!table) return; - - const rows: PMNode[] = []; - table.forEach(row => rows.push(row)); - const [moved] = rows.splice(fromRow, 1); - rows.splice(toRow, 0, moved); - - const newTable = table.type.create(table.attrs, rows); - const { tr } = editor.state; - tr.replaceWith(tableNodePos, tableNodePos + table.nodeSize, newTable); - editor.view.dispatch(tr); -} - -export function TableRowMenu({ editor }: TableRowMenuProps) { - const { - menuOpen, - setMenuOpen, - setTriggerEl, - setDropdownEl, - menuRefs, - menuStyles, - getFloatingProps, - } = useTableDropdownMenu(); - - const [hoveredRow, setHoveredRow] = useState(null); +export function TableRowMenuTrigger({ + editor, + hoveredRow, + setHoveredRow, + menuOpen, + setMenuOpen, + setTriggerEl, + menuRefs, +}: TableRowMenuTriggerProps) { const [dragIndicator, setDragIndicator] = useState<{ left: number; top: number; @@ -213,7 +128,7 @@ export function TableRowMenu({ editor }: TableRowMenuProps) { rafId.current = null; } }; - }, [editor, menuOpen]); + }, [editor, menuOpen, setHoveredRow]); const { refs: handleRefs, @@ -254,8 +169,6 @@ export function TableRowMenu({ editor }: TableRowMenuProps) { }); }, [hoveredRow, editor, handleRefs]); - if (!hoveredRow && !menuOpen) return null; - const computeTargetGap = (clientY: number, tableStart: number): number => { const table = editor.state.doc.nodeAt(tableStart - 1); if (!table) return 0; @@ -416,193 +329,6 @@ export function TableRowMenu({ editor }: TableRowMenuProps) { document.addEventListener("mouseup", onMouseUp); }; - const currentRow = hoveredRow; - - const isFirstRow = currentRow?.rowIndex === 0; - - const isHeaderRow = (): boolean => { - if (!currentRow || currentRow.rowIndex !== 0) return false; - try { - const table = editor.state.doc.nodeAt(currentRow.tableStart - 1); - if (!table) return false; - const map = TableMap.get(table); - for (let col = 0; col < map.width; col++) { - const cellPos = map.map[col] + currentRow.tableStart; - const cellNode = editor.state.doc.nodeAt(cellPos); - if (!cellNode || cellNode.type.name !== "tableHeader") return false; - } - return true; - } catch { - return false; - } - }; - - const handleToggleHeaderRow = () => { - if (!currentRow || currentRow.rowIndex !== 0) return; - const { tableStart } = currentRow; - - try { - const table = editor.state.doc.nodeAt(tableStart - 1); - if (!table) return; - - const map = TableMap.get(table); - const cellPos = map.positionAt(0, 0, table) + tableStart; - - editor - .chain() - .focus() - .command(({ tr }) => { - tr.setSelection(TextSelection.create(tr.doc, cellPos + 1)); - return true; - }) - .toggleHeaderRow() - .run(); - } catch { - // table may have changed - } - - setMenuOpen(false); - }; - - const handleDeleteRow = () => { - if (!currentRow) return; - const { rowIndex, tableStart } = currentRow; - - try { - const table = editor.state.doc.nodeAt(tableStart - 1); - if (!table) return; - - const map = TableMap.get(table); - const cellPos = map.positionAt(rowIndex, 0, table) + tableStart; - - editor - .chain() - .focus() - .command(({ tr }) => { - tr.setSelection(TextSelection.create(tr.doc, cellPos + 1)); - return true; - }) - .deleteRow() - .run(); - } catch { - // table may have changed - } - - setMenuOpen(false); - setHoveredRow(null); - }; - - const handleDuplicateRow = () => { - if (!currentRow) return; - const { rowIndex, tableStart } = currentRow; - - try { - const table = editor.state.doc.nodeAt(tableStart - 1); - if (!table) return; - - const rowNode = table.child(rowIndex); - - let insertPos = tableStart; - for (let i = 0; i <= rowIndex; i++) { - insertPos += table.child(i).nodeSize; - } - - editor - .chain() - .focus() - .command(({ tr }) => { - tr.insert(insertPos, rowNode); - return true; - }) - .run(); - } catch { - // table may have changed - } - - setMenuOpen(false); - }; - - const handleInsertAbove = () => { - if (!currentRow) return; - const { rowIndex, tableStart } = currentRow; - - try { - const table = editor.state.doc.nodeAt(tableStart - 1); - if (!table) return; - - const map = TableMap.get(table); - const cellPos = map.positionAt(rowIndex, 0, table) + tableStart; - - editor - .chain() - .focus() - .command(({ tr }) => { - tr.setSelection(TextSelection.create(tr.doc, cellPos + 1)); - return true; - }) - .addRowBefore() - .run(); - } catch { - // table may have changed - } - - setMenuOpen(false); - }; - - const handleInsertBelow = () => { - if (!currentRow) return; - const { rowIndex, tableStart } = currentRow; - - try { - const table = editor.state.doc.nodeAt(tableStart - 1); - if (!table) return; - - const map = TableMap.get(table); - const cellPos = map.positionAt(rowIndex, 0, table) + tableStart; - - editor - .chain() - .focus() - .command(({ tr }) => { - tr.setSelection(TextSelection.create(tr.doc, cellPos + 1)); - return true; - }) - .addRowAfter() - .run(); - } catch { - // table may have changed - } - - setMenuOpen(false); - }; - - const handleClearContents = () => { - if (!currentRow) return; - const { rowIndex, tableStart } = currentRow; - - try { - const table = editor.state.doc.nodeAt(tableStart - 1); - if (!table) return; - - const map = TableMap.get(table); - const firstCellPos = map.map[rowIndex * map.width] + tableStart; - const lastCellPos - = map.map[rowIndex * map.width + (map.width - 1)] + tableStart; - - const $anchor = editor.state.doc.resolve(firstCellPos); - const $head = editor.state.doc.resolve(lastCellPos); - - editor.view.dispatch( - editor.state.tr.setSelection(new CellSelection($anchor, $head)), - ); - editor.commands.deleteSelection(); - } catch { - // table may have changed - } - - setMenuOpen(false); - }; - return ( <> - {menuOpen && ( -
{ - setDropdownEl(node); - menuRefs.setFloating(node); - }} - data-row-menu - style={menuStyles} - {...getFloatingProps()} - onMouseDown={e => e.preventDefault()} - className={menu()} - > - {isFirstRow && ( - - - Header row - - )} - - - Insert row above - - - - Insert row below - - - - Duplicate row - - - - Clear contents - - - - Delete row - -
- )} {dragIndicator && (
. +// Use of this source code is governed by the ISC license +// that can be found in the LICENSE file. + +import { tv } from "tailwind-variants"; + +export const tableRowMenuVariants = tv({ + slots: { + trigger: [ + "z-10 flex flex-col items-center justify-center", + "rounded text-txt-tertiary bg-subtle hover:bg-border-solid cursor-grab", + "px-0.5 w-3", + ], + menu: ["rounded-lg border border-border-mid bg-level-0 p-1 shadow-md z-20"], + }, +}); diff --git a/packages/ui/src/RichEditor/_lib/getSlashStorage.ts b/packages/ui/src/RichEditor/_lib/getSlashStorage.ts new file mode 100644 index 000000000..d26dff15a --- /dev/null +++ b/packages/ui/src/RichEditor/_lib/getSlashStorage.ts @@ -0,0 +1,15 @@ +// Copyright (c) 2026 Probo Inc . +// Use of this source code is governed by the ISC license +// that can be found in the LICENSE file. + +import type { Editor } from "@tiptap/react"; + +import type { SlashCommandStorage } from "../SlashCommandExtension"; + +export function getSlashStorage( + editor: Editor, +): SlashCommandStorage | undefined { + return (editor.storage as unknown as Record).slashCommand as + | SlashCommandStorage + | undefined; +} diff --git a/packages/ui/src/RichEditor/_lib/useTableDropdownMenu.ts b/packages/ui/src/RichEditor/_lib/useTableDropdownMenu.ts index 859571904..2c55d4973 100644 --- a/packages/ui/src/RichEditor/_lib/useTableDropdownMenu.ts +++ b/packages/ui/src/RichEditor/_lib/useTableDropdownMenu.ts @@ -14,6 +14,8 @@ import { } from "@floating-ui/react"; import { useState } from "react"; +export type DropdownMenu = ReturnType; + export function useTableDropdownMenu() { const [menuOpen, setMenuOpen] = useState(false); const [triggerEl, setTriggerEl] = useState(null);