Split menu components for better responsibility handling

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-03-26 10:48:16 +04:00
parent 0b02171e4a
commit cc93378996
24 changed files with 1706 additions and 1262 deletions

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// 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 && (
<BlockMenuTrigger
editor={editor}
hoveredBlock={hoveredBlock}
/>
)}
{slashState.active && (
<BlockMenuContent
editor={editor}
slashState={slashState}
/>
)}
</>
);
}

View File

@@ -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<Editor["chain"]>;
@@ -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<string, unknown>).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<HTMLDivElement | null>(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 && (
<button
ref={(node) => {
triggerRefs.setFloating(node);
}}
onClick={handleTriggerClick}
onMouseDown={e => e.preventDefault()}
type="button"
style={{
...triggerStyles,
visibility: isPositioned ? "visible" : "hidden",
}}
className={trigger()}
>
<PlusIcon size={16} weight="bold" />
</button>
)}
{slashState.active && (
<div
ref={(node) => {
slashDropdownRef.current = node;
slashMenuRefs.setFloating(node);
}}
style={slashMenuStyles}
onMouseDown={e => e.preventDefault()}
className={menu()}
>
{filteredItems.length > 0
? filteredItems.map((item, index) => (
<MenuButton
key={item.label}
active={index === slashActiveIndex}
onClick={() => handleSlashAction(item)}
>
<item.icon size={16} weight="bold" />
{item.label}
</MenuButton>
))
: (
<div className="px-2 py-1.5 text-sm text-txt-tertiary">
No results
</div>
)}
</div>
)}
</>
<div
ref={(node) => {
slashDropdownRef.current = node;
slashMenuRefs.setFloating(node);
}}
style={slashMenuStyles}
onMouseDown={e => e.preventDefault()}
className={menu()}
>
{filteredItems.length > 0
? filteredItems.map((item, index) => (
<MenuButton
key={item.label}
active={index === slashActiveIndex}
onClick={() => handleSlashAction(item)}
>
<item.icon size={16} weight="bold" />
{item.label}
</MenuButton>
))
: (
<div className="px-2 py-1.5 text-sm text-txt-tertiary">
No results
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,87 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// 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 (
<button
ref={(node) => {
triggerRefs.setFloating(node);
}}
onClick={handleTriggerClick}
onMouseDown={e => e.preventDefault()}
type="button"
style={{
...triggerStyles,
visibility: isPositioned ? "visible" : "hidden",
}}
className={trigger()}
>
<PlusIcon size={16} weight="bold" />
</button>
);
}

View File

@@ -0,0 +1,15 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// 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"],
},
});

View File

@@ -1,290 +0,0 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// 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<NodeSelection["content"]>, 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<Element | null>(null);
const [dropdownEl, setDropdownEl] = useState<HTMLElement | null>(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<typeof editor.chain>) => ReturnType<typeof editor.chain>,
) => {
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<HTMLButtonElement>) => {
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 (
<>
<button
ref={(node) => {
triggerRefs.setFloating(node);
setTriggerEl(node);
menuRefs.setReference(node);
}}
{...getReferenceProps()}
draggable
onDragStart={onDragStart}
onDragEnd={() => setHoveredBlock(null)}
type="button"
style={{
...triggerStyles,
visibility: isPositioned ? "visible" : "hidden",
}}
className={trigger()}
>
<DotsSixVerticalIcon size={20} weight="bold" />
</button>
{menuOpen && (
<div
ref={(node) => {
setDropdownEl(node);
menuRefs.setFloating(node);
}}
style={menuStyles}
{...getFloatingProps()}
onMouseDown={e => e.preventDefault()}
className={menu()}
>
<div className="p-1 font-semibold text-sm">Turn into</div>
<MenuButton
active={hoveredBlock != null && isBlockNodeType(editor, hoveredBlock, "paragraph")}
onClick={() => handleAction(chain => chain.setParagraph())}
>
<TextTIcon size={16} weight="bold" />
Text
</MenuButton>
<MenuButton
active={hoveredBlock != null && isBlockNodeType(editor, hoveredBlock, "heading", { level: 1 })}
onClick={() => handleAction(chain => chain.toggleHeading({ level: 1 }))}
>
<TextHOneIcon size={16} weight="bold" />
Heading 1
</MenuButton>
<MenuButton
active={hoveredBlock != null && isBlockNodeType(editor, hoveredBlock, "heading", { level: 2 })}
onClick={() => handleAction(chain => chain.toggleHeading({ level: 2 }))}
>
<TextHTwoIcon size={16} weight="bold" />
Heading 2
</MenuButton>
<MenuButton
active={hoveredBlock != null && isBlockNodeType(editor, hoveredBlock, "heading", { level: 3 })}
onClick={() => handleAction(chain => chain.toggleHeading({ level: 3 }))}
>
<TextHThreeIcon size={16} weight="bold" />
Heading 3
</MenuButton>
<MenuButton
active={hoveredBlock != null && isBlockNodeType(editor, hoveredBlock, "bulletList")}
onClick={() => handleAction(chain => chain.toggleBulletList())}
>
<ListBulletsIcon size={16} weight="bold" />
Bullet List
</MenuButton>
<MenuButton
active={hoveredBlock != null && isBlockNodeType(editor, hoveredBlock, "orderedList")}
onClick={() => handleAction(chain => chain.toggleOrderedList())}
>
<ListNumbersIcon size={16} weight="bold" />
Ordered List
</MenuButton>
<MenuButton
active={hoveredBlock != null && isBlockNodeType(editor, hoveredBlock, "codeBlock")}
onClick={() => handleAction(chain => chain.toggleCodeBlock())}
>
<CodeBlockIcon size={16} weight="bold" />
Code Block
</MenuButton>
<MenuButton
active={hoveredBlock != null && isBlockNodeType(editor, hoveredBlock, "blockquote")}
onClick={() => handleAction(chain => chain.toggleBlockquote())}
>
<QuotesIcon size={16} weight="bold" />
Blockquote
</MenuButton>
</div>
)}
</>
);
}

View File

@@ -0,0 +1,89 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// 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<React.SetStateAction<Element | null>>;
setDropdownEl: React.Dispatch<React.SetStateAction<HTMLElement | null>>;
menuRefs: ReturnType<typeof useFloating>["refs"];
menuStyles: ReturnType<typeof useFloating>["floatingStyles"];
getReferenceProps: ReturnType<typeof useInteractions>["getReferenceProps"];
getFloatingProps: ReturnType<typeof useInteractions>["getFloatingProps"];
};
type OptionsMenuProps = {
editor: Editor;
};
export function OptionsMenu({ editor }: OptionsMenuProps) {
const [menuOpen, setMenuOpen] = useState(false);
const [triggerEl, setTriggerEl] = useState<Element | null>(null);
const [dropdownEl, setDropdownEl] = useState<HTMLElement | null>(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 (
<>
<OptionsMenuTrigger
editor={editor}
hoveredBlock={hoveredBlock}
setHoveredBlock={setHoveredBlock}
setTriggerEl={setTriggerEl}
menuRefs={menuRefs}
getReferenceProps={getReferenceProps}
/>
{menuOpen && (
<OptionsMenuContent
editor={editor}
hoveredBlock={hoveredBlock}
setMenuOpen={setMenuOpen}
setDropdownEl={setDropdownEl}
menuRefs={menuRefs}
menuStyles={menuStyles}
getFloatingProps={getFloatingProps}
/>
)}
</>
);
}

View File

@@ -0,0 +1,191 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// 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<React.SetStateAction<boolean>>;
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<typeof editor.chain>) => ReturnType<typeof editor.chain>,
) => {
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 (
<div
ref={(node) => {
setDropdownEl(node);
menuRefs.setFloating(node);
}}
style={menuStyles}
{...getFloatingProps()}
onMouseDown={e => e.preventDefault()}
className={menu()}
>
<div className="p-1 font-semibold text-sm">Turn into</div>
<MenuButton
active={hoveredBlock != null && isBlockNodeType(editor, hoveredBlock, "paragraph")}
onClick={() => handleAction(chain => chain.setParagraph())}
>
<TextTIcon size={16} weight="bold" />
Text
</MenuButton>
<MenuButton
active={hoveredBlock != null && isBlockNodeType(editor, hoveredBlock, "heading", { level: 1 })}
onClick={() => handleAction(chain => chain.toggleHeading({ level: 1 }))}
>
<TextHOneIcon size={16} weight="bold" />
Heading 1
</MenuButton>
<MenuButton
active={hoveredBlock != null && isBlockNodeType(editor, hoveredBlock, "heading", { level: 2 })}
onClick={() => handleAction(chain => chain.toggleHeading({ level: 2 }))}
>
<TextHTwoIcon size={16} weight="bold" />
Heading 2
</MenuButton>
<MenuButton
active={hoveredBlock != null && isBlockNodeType(editor, hoveredBlock, "heading", { level: 3 })}
onClick={() => handleAction(chain => chain.toggleHeading({ level: 3 }))}
>
<TextHThreeIcon size={16} weight="bold" />
Heading 3
</MenuButton>
<MenuButton
active={hoveredBlock != null && isBlockNodeType(editor, hoveredBlock, "bulletList")}
onClick={() => handleAction(chain => chain.toggleBulletList())}
>
<ListBulletsIcon size={16} weight="bold" />
Bullet List
</MenuButton>
<MenuButton
active={hoveredBlock != null && isBlockNodeType(editor, hoveredBlock, "orderedList")}
onClick={() => handleAction(chain => chain.toggleOrderedList())}
>
<ListNumbersIcon size={16} weight="bold" />
Ordered List
</MenuButton>
<MenuButton
active={hoveredBlock != null && isBlockNodeType(editor, hoveredBlock, "codeBlock")}
onClick={() => handleAction(chain => chain.toggleCodeBlock())}
>
<CodeBlockIcon size={16} weight="bold" />
Code Block
</MenuButton>
<MenuButton
active={hoveredBlock != null && isBlockNodeType(editor, hoveredBlock, "blockquote")}
onClick={() => handleAction(chain => chain.toggleBlockquote())}
>
<QuotesIcon size={16} weight="bold" />
Blockquote
</MenuButton>
</div>
);
}

View File

@@ -0,0 +1,97 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// 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<NodeSelection["content"]>, 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<HTMLButtonElement>) => {
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 (
<button
ref={(node) => {
triggerRefs.setFloating(node);
setTriggerEl(node);
menuRefs.setReference(node);
}}
{...getReferenceProps()}
draggable
onDragStart={onDragStart}
onDragEnd={() => setHoveredBlock(null)}
type="button"
style={{
...triggerStyles,
visibility: isPositioned ? "visible" : "hidden",
}}
className={trigger()}
>
<DotsSixVerticalIcon size={20} weight="bold" />
</button>
);
}

View File

@@ -0,0 +1,15 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// 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"],
},
});

View File

@@ -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,

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// 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 (
<>
<TableCellMenuTrigger
editor={editor}
activeCellEl={activeCellEl}
menuOpen={menuOpen}
setMenuOpen={setMenuOpen}
setTriggerEl={setTriggerEl}
menuRefs={menuRefs}
/>
{menuOpen && (
<TableCellMenuContent
editor={editor}
setMenuOpen={setMenuOpen}
setDropdownEl={setDropdownEl}
menuRefs={menuRefs}
menuStyles={menuStyles}
getFloatingProps={getFloatingProps}
/>
)}
</>
);
}

View File

@@ -0,0 +1,98 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// 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 (
<div
ref={(node) => {
setDropdownEl(node);
menuRefs.setFloating(node);
}}
style={menuStyles}
{...getFloatingProps()}
onMouseDown={e => e.preventDefault()}
className={menu()}
>
{editor.can().mergeCells() && (
<MenuButton onClick={handleMergeCells}>
<IntersectIcon size={16} weight="bold" />
Merge cells
</MenuButton>
)}
{editor.can().splitCell() && (
<MenuButton onClick={handleSplitCell}>
<SplitHorizontalIcon size={16} weight="bold" />
Split cells
</MenuButton>
)}
<MenuButton onClick={handleClearContents}>
<BroomIcon size={16} weight="bold" />
Clear contents
</MenuButton>
</div>
);
}

View File

@@ -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 (
<>
<button
ref={(node) => {
handleRefs.setFloating(node);
setTriggerEl(node);
menuRefs.setReference(node);
}}
onMouseDown={onHandleMouseDown}
onMouseEnter={() => setHandleHovered(true)}
onMouseLeave={() => setHandleHovered(false)}
type="button"
style={{
...handleStyles,
visibility: isPositioned ? "visible" : "hidden",
}}
className={trigger()}
>
{handleHovered || menuOpen
? (
<div className="rounded-full bg-level-0 w-4.5 h-3.5 my-0.5 flex items-center">
<DotsThreeCircleVerticalIcon size={18} weight="fill" />
</div>
)
: <CircleIcon size={10} weight="fill" />}
</button>
{menuOpen && (
<div
ref={(node) => {
setDropdownEl(node);
menuRefs.setFloating(node);
}}
style={menuStyles}
{...getFloatingProps()}
onMouseDown={e => e.preventDefault()}
className={menu()}
>
{editor.can().mergeCells() && (
<MenuButton onClick={handleMergeCells}>
<IntersectIcon size={16} weight="bold" />
Merge cells
</MenuButton>
)}
{editor.can().splitCell() && (
<MenuButton onClick={handleSplitCell}>
<SplitHorizontalIcon size={16} weight="bold" />
Split cells
</MenuButton>
)}
<MenuButton onClick={handleClearContents}>
<BroomIcon size={16} weight="bold" />
Clear contents
</MenuButton>
</div>
)}
</>
<button
ref={(node) => {
handleRefs.setFloating(node);
setTriggerEl(node);
menuRefs.setReference(node);
}}
onMouseDown={onHandleMouseDown}
onMouseEnter={() => setHandleHovered(true)}
onMouseLeave={() => setHandleHovered(false)}
type="button"
style={{
...handleStyles,
visibility: isPositioned ? "visible" : "hidden",
}}
className={trigger()}
>
{handleHovered || menuOpen
? (
<div className="rounded-full bg-level-0 w-4.5 h-3.5 my-0.5 flex items-center">
<DotsThreeCircleVerticalIcon size={18} weight="fill" />
</div>
)
: <CircleIcon size={10} weight="fill" />}
</button>
);
}

View File

@@ -0,0 +1,15 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// 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"],
},
});

View File

@@ -0,0 +1,132 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// 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<HoveredColumn | null>(null);
if (!hoveredCol && !menuOpen) return null;
return (
<>
<TableColumnMenuTrigger
editor={editor}
hoveredCol={hoveredCol}
setHoveredCol={setHoveredCol}
menuOpen={menuOpen}
setMenuOpen={setMenuOpen}
setTriggerEl={setTriggerEl}
menuRefs={menuRefs}
/>
{menuOpen && hoveredCol && (
<TableColumnMenuContent
editor={editor}
hoveredCol={hoveredCol}
setMenuOpen={setMenuOpen}
setHoveredCol={setHoveredCol}
setDropdownEl={setDropdownEl}
menuRefs={menuRefs}
menuStyles={menuStyles}
getFloatingProps={getFloatingProps}
/>
)}
</>
);
}

View File

@@ -0,0 +1,268 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// 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<React.SetStateAction<HoveredColumn | null>>;
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 (
<div
ref={(node) => {
setDropdownEl(node);
menuRefs.setFloating(node);
}}
data-column-menu
style={menuStyles}
{...getFloatingProps()}
onMouseDown={e => e.preventDefault()}
className={menu()}
>
{isFirstColumn && (
<MenuButton active={isHeaderColumn()} onClick={handleToggleHeaderColumn}>
<CrownSimpleIcon size={16} weight="bold" />
Header column
</MenuButton>
)}
<MenuButton onClick={handleInsertLeft}>
<PlusIcon size={16} weight="bold" />
Insert column left
</MenuButton>
<MenuButton onClick={handleInsertRight}>
<PlusIcon size={16} weight="bold" />
Insert column right
</MenuButton>
<MenuButton onClick={handleDuplicateColumn}>
<CopyIcon size={16} weight="bold" />
Duplicate column
</MenuButton>
<MenuButton onClick={handleClearContents}>
<BroomIcon size={16} weight="bold" />
Clear contents
</MenuButton>
<MenuButton onClick={handleDeleteColumn}>
<TrashIcon size={16} weight="bold" />
Delete column
</MenuButton>
</div>
);
}

View File

@@ -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<React.SetStateAction<HoveredColumn | null>>;
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<HoveredColumn | null>(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 (
<>
<button
@@ -629,46 +349,6 @@ export function TableColumnMenu({ editor }: TableColumnMenuProps) {
>
<DotsThreeIcon size={16} weight="bold" />
</button>
{menuOpen && (
<div
ref={(node) => {
setDropdownEl(node);
menuRefs.setFloating(node);
}}
data-column-menu
style={menuStyles}
{...getFloatingProps()}
onMouseDown={e => e.preventDefault()}
className={menu()}
>
{isFirstColumn && (
<MenuButton active={isHeaderColumn()} onClick={handleToggleHeaderColumn}>
<CrownSimpleIcon size={16} weight="bold" />
Header column
</MenuButton>
)}
<MenuButton onClick={handleInsertLeft}>
<PlusIcon size={16} weight="bold" />
Insert column left
</MenuButton>
<MenuButton onClick={handleInsertRight}>
<PlusIcon size={16} weight="bold" />
Insert column right
</MenuButton>
<MenuButton onClick={handleDuplicateColumn}>
<CopyIcon size={16} weight="bold" />
Duplicate column
</MenuButton>
<MenuButton onClick={handleClearContents}>
<BroomIcon size={16} weight="bold" />
Clear contents
</MenuButton>
<MenuButton onClick={handleDeleteColumn}>
<TrashIcon size={16} weight="bold" />
Delete column
</MenuButton>
</div>
)}
{dragIndicator && (
<div
style={{

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// 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"],
},
});

View File

@@ -0,0 +1,128 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// 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<HoveredRow | null>(null);
if (!hoveredRow && !menuOpen) return null;
return (
<>
<TableRowMenuTrigger
editor={editor}
hoveredRow={hoveredRow}
setHoveredRow={setHoveredRow}
menuOpen={menuOpen}
setMenuOpen={setMenuOpen}
setTriggerEl={setTriggerEl}
menuRefs={menuRefs}
/>
{menuOpen && hoveredRow && (
<TableRowMenuContent
editor={editor}
hoveredRow={hoveredRow}
setMenuOpen={setMenuOpen}
setHoveredRow={setHoveredRow}
setDropdownEl={setDropdownEl}
menuRefs={menuRefs}
menuStyles={menuStyles}
getFloatingProps={getFloatingProps}
/>
)}
</>
);
}

View File

@@ -0,0 +1,266 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// 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<React.SetStateAction<HoveredRow | null>>;
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 (
<div
ref={(node) => {
setDropdownEl(node);
menuRefs.setFloating(node);
}}
data-row-menu
style={menuStyles}
{...getFloatingProps()}
onMouseDown={e => e.preventDefault()}
className={menu()}
>
{isFirstRow && (
<MenuButton active={isHeaderRow()} onClick={handleToggleHeaderRow}>
<CrownSimpleIcon size={16} weight="bold" />
Header row
</MenuButton>
)}
<MenuButton onClick={handleInsertAbove}>
<PlusIcon size={16} weight="bold" />
Insert row above
</MenuButton>
<MenuButton onClick={handleInsertBelow}>
<PlusIcon size={16} weight="bold" />
Insert row below
</MenuButton>
<MenuButton onClick={handleDuplicateRow}>
<CopyIcon size={16} weight="bold" />
Duplicate row
</MenuButton>
<MenuButton onClick={handleClearContents}>
<BroomIcon size={16} weight="bold" />
Clear contents
</MenuButton>
<MenuButton onClick={handleDeleteRow}>
<TrashIcon size={16} weight="bold" />
Delete row
</MenuButton>
</div>
);
}

View File

@@ -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<React.SetStateAction<HoveredRow | null>>;
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<HoveredRow | null>(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 (
<>
<button
@@ -623,46 +349,6 @@ export function TableRowMenu({ editor }: TableRowMenuProps) {
>
<DotsThreeVerticalIcon size={16} weight="bold" />
</button>
{menuOpen && (
<div
ref={(node) => {
setDropdownEl(node);
menuRefs.setFloating(node);
}}
data-row-menu
style={menuStyles}
{...getFloatingProps()}
onMouseDown={e => e.preventDefault()}
className={menu()}
>
{isFirstRow && (
<MenuButton active={isHeaderRow()} onClick={handleToggleHeaderRow}>
<CrownSimpleIcon size={16} weight="bold" />
Header row
</MenuButton>
)}
<MenuButton onClick={handleInsertAbove}>
<PlusIcon size={16} weight="bold" />
Insert row above
</MenuButton>
<MenuButton onClick={handleInsertBelow}>
<PlusIcon size={16} weight="bold" />
Insert row below
</MenuButton>
<MenuButton onClick={handleDuplicateRow}>
<CopyIcon size={16} weight="bold" />
Duplicate row
</MenuButton>
<MenuButton onClick={handleClearContents}>
<BroomIcon size={16} weight="bold" />
Clear contents
</MenuButton>
<MenuButton onClick={handleDeleteRow}>
<TrashIcon size={16} weight="bold" />
Delete row
</MenuButton>
</div>
)}
{dragIndicator && (
<div
style={{

View File

@@ -0,0 +1,16 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// 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"],
},
});

View File

@@ -0,0 +1,15 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// 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<string, unknown>).slashCommand as
| SlashCommandStorage
| undefined;
}

View File

@@ -14,6 +14,8 @@ import {
} from "@floating-ui/react";
import { useState } from "react";
export type DropdownMenu = ReturnType<typeof useTableDropdownMenu>;
export function useTableDropdownMenu() {
const [menuOpen, setMenuOpen] = useState(false);
const [triggerEl, setTriggerEl] = useState<Element | null>(null);