Split menu components for better responsibility handling
Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
48
packages/ui/src/RichEditor/BlockMenu/BlockMenu.tsx
Normal file
48
packages/ui/src/RichEditor/BlockMenu/BlockMenu.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
87
packages/ui/src/RichEditor/BlockMenu/BlockMenuTrigger.tsx
Normal file
87
packages/ui/src/RichEditor/BlockMenu/BlockMenuTrigger.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
packages/ui/src/RichEditor/BlockMenu/variants.ts
Normal file
15
packages/ui/src/RichEditor/BlockMenu/variants.ts
Normal 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"],
|
||||
},
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
89
packages/ui/src/RichEditor/OptionsMenu/OptionsMenu.tsx
Normal file
89
packages/ui/src/RichEditor/OptionsMenu/OptionsMenu.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
191
packages/ui/src/RichEditor/OptionsMenu/OptionsMenuContent.tsx
Normal file
191
packages/ui/src/RichEditor/OptionsMenu/OptionsMenuContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
15
packages/ui/src/RichEditor/OptionsMenu/variants.ts
Normal file
15
packages/ui/src/RichEditor/OptionsMenu/variants.ts
Normal 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"],
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
74
packages/ui/src/RichEditor/TableCellMenu/TableCellMenu.tsx
Normal file
74
packages/ui/src/RichEditor/TableCellMenu/TableCellMenu.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
15
packages/ui/src/RichEditor/TableCellMenu/variants.ts
Normal file
15
packages/ui/src/RichEditor/TableCellMenu/variants.ts
Normal 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"],
|
||||
},
|
||||
});
|
||||
132
packages/ui/src/RichEditor/TableColumnMenu/TableColumnMenu.tsx
Normal file
132
packages/ui/src/RichEditor/TableColumnMenu/TableColumnMenu.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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={{
|
||||
16
packages/ui/src/RichEditor/TableColumnMenu/variants.ts
Normal file
16
packages/ui/src/RichEditor/TableColumnMenu/variants.ts
Normal 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"],
|
||||
},
|
||||
});
|
||||
128
packages/ui/src/RichEditor/TableRowMenu/TableRowMenu.tsx
Normal file
128
packages/ui/src/RichEditor/TableRowMenu/TableRowMenu.tsx
Normal 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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
266
packages/ui/src/RichEditor/TableRowMenu/TableRowMenuContent.tsx
Normal file
266
packages/ui/src/RichEditor/TableRowMenu/TableRowMenuContent.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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={{
|
||||
16
packages/ui/src/RichEditor/TableRowMenu/variants.ts
Normal file
16
packages/ui/src/RichEditor/TableRowMenu/variants.ts
Normal 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"],
|
||||
},
|
||||
});
|
||||
15
packages/ui/src/RichEditor/_lib/getSlashStorage.ts
Normal file
15
packages/ui/src/RichEditor/_lib/getSlashStorage.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user