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,
|
useFloating,
|
||||||
} from "@floating-ui/react";
|
} from "@floating-ui/react";
|
||||||
import type { Icon } from "@phosphor-icons/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 { CodeBlockIcon, GridFourIcon, ListBulletsIcon, ListNumbersIcon, MinusIcon, QuotesIcon, TextHOneIcon, TextHThreeIcon, TextHTwoIcon, TextTIcon } from "@phosphor-icons/react";
|
||||||
import { type Editor, useEditorState } from "@tiptap/react";
|
import { type Editor } from "@tiptap/react";
|
||||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { tv } from "tailwind-variants";
|
|
||||||
|
|
||||||
import { useBlockTrigger } from "./_lib/useBlockTrigger";
|
import { getSlashStorage } from "../_lib/getSlashStorage";
|
||||||
import { useHoveredBlock } from "./_lib/useHoveredBlock";
|
import { MenuButton } from "../MenuButton";
|
||||||
import { MenuButton } from "./MenuButton";
|
import { deactivateSlashCommand } from "../SlashCommandExtension";
|
||||||
import type { SlashCommandStorage } from "./SlashCommandExtension";
|
|
||||||
import { activateSlashCommand, deactivateSlashCommand } from "./SlashCommandExtension";
|
import { blockMenuVariants } from "./variants";
|
||||||
|
|
||||||
|
const { menu } = blockMenuVariants();
|
||||||
|
|
||||||
type ChainCommands = ReturnType<Editor["chain"]>;
|
type ChainCommands = ReturnType<Editor["chain"]>;
|
||||||
|
|
||||||
@@ -42,47 +43,15 @@ const BLOCK_ITEMS: BlockItem[] = [
|
|||||||
{ label: "Table", icon: GridFourIcon, action: chain => chain.insertTable() },
|
{ label: "Table", icon: GridFourIcon, action: chain => chain.insertTable() },
|
||||||
];
|
];
|
||||||
|
|
||||||
const blockMenuVariants = tv({
|
type BlockMenuContentProps = {
|
||||||
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 = {
|
|
||||||
editor: Editor;
|
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 [slashNav, setSlashNav] = useState({ index: 0, query: "" });
|
||||||
const slashDropdownRef = useRef<HTMLDivElement | null>(null);
|
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
|
const slashActiveIndex = slashState.query === slashNav.query
|
||||||
? slashNav.index
|
? slashNav.index
|
||||||
: 0;
|
: 0;
|
||||||
@@ -192,101 +161,32 @@ export function BlockMenu({ editor }: BlockMenuProps) {
|
|||||||
};
|
};
|
||||||
}, [editor, slashState.active, slashState.query, filteredItems, slashActiveIndex, handleSlashAction]);
|
}, [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 (
|
return (
|
||||||
<>
|
<div
|
||||||
{hoveredBlock != null && (
|
ref={(node) => {
|
||||||
<button
|
slashDropdownRef.current = node;
|
||||||
ref={(node) => {
|
slashMenuRefs.setFloating(node);
|
||||||
triggerRefs.setFloating(node);
|
}}
|
||||||
}}
|
style={slashMenuStyles}
|
||||||
onClick={handleTriggerClick}
|
onMouseDown={e => e.preventDefault()}
|
||||||
onMouseDown={e => e.preventDefault()}
|
className={menu()}
|
||||||
type="button"
|
>
|
||||||
style={{
|
{filteredItems.length > 0
|
||||||
...triggerStyles,
|
? filteredItems.map((item, index) => (
|
||||||
visibility: isPositioned ? "visible" : "hidden",
|
<MenuButton
|
||||||
}}
|
key={item.label}
|
||||||
className={trigger()}
|
active={index === slashActiveIndex}
|
||||||
>
|
onClick={() => handleSlashAction(item)}
|
||||||
<PlusIcon size={16} weight="bold" />
|
>
|
||||||
</button>
|
<item.icon size={16} weight="bold" />
|
||||||
)}
|
{item.label}
|
||||||
{slashState.active && (
|
</MenuButton>
|
||||||
<div
|
))
|
||||||
ref={(node) => {
|
: (
|
||||||
slashDropdownRef.current = node;
|
<div className="px-2 py-1.5 text-sm text-txt-tertiary">
|
||||||
slashMenuRefs.setFloating(node);
|
No results
|
||||||
}}
|
</div>
|
||||||
style={slashMenuStyles}
|
)}
|
||||||
onMouseDown={e => e.preventDefault()}
|
</div>
|
||||||
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 { type ComponentProps, useEffect, useRef } from "react";
|
||||||
import { tv } from "tailwind-variants";
|
import { tv } from "tailwind-variants";
|
||||||
|
|
||||||
import { BlockMenu } from "./BlockMenu";
|
import { BlockMenu } from "./BlockMenu/BlockMenu";
|
||||||
import { BubbleMenu } from "./BubbleMenu";
|
import { BubbleMenu } from "./BubbleMenu";
|
||||||
import { FocusedCellExtension } from "./FocusedCellExtension";
|
import { FocusedCellExtension } from "./FocusedCellExtension";
|
||||||
import { LinkExtension } from "./LinkExtension";
|
import { LinkExtension } from "./LinkExtension";
|
||||||
import { OptionsMenu } from "./OptionsMenu";
|
import { OptionsMenu } from "./OptionsMenu/OptionsMenu";
|
||||||
import { PlaceholderExtension } from "./PlaceholderExtension";
|
import { PlaceholderExtension } from "./PlaceholderExtension";
|
||||||
import { SlashCommandExtension } from "./SlashCommandExtension";
|
import { SlashCommandExtension } from "./SlashCommandExtension";
|
||||||
import { TableCellMenu } from "./TableCellMenu";
|
import { TableCellMenu } from "./TableCellMenu/TableCellMenu";
|
||||||
import { TableColumnMenu } from "./TableColumnMenu";
|
import { TableColumnMenu } from "./TableColumnMenu/TableColumnMenu";
|
||||||
import { TableRowMenu } from "./TableRowMenu";
|
import { TableRowMenu } from "./TableRowMenu/TableRowMenu";
|
||||||
|
|
||||||
const extensions = [
|
const extensions = [
|
||||||
Document,
|
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.
|
// that can be found in the LICENSE file.
|
||||||
|
|
||||||
import { autoUpdate, offset, useFloating } from "@floating-ui/react";
|
import { autoUpdate, offset, useFloating } from "@floating-ui/react";
|
||||||
import { BroomIcon, CircleIcon, DotsThreeCircleVerticalIcon, IntersectIcon, SplitHorizontalIcon } from "@phosphor-icons/react";
|
import { CircleIcon, DotsThreeCircleVerticalIcon } from "@phosphor-icons/react";
|
||||||
import { TextSelection } from "@tiptap/pm/state";
|
|
||||||
import { cellAround, CellSelection, TableMap } from "@tiptap/pm/tables";
|
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 { useLayoutEffect, useRef, useState } from "react";
|
||||||
import { tv } from "tailwind-variants";
|
|
||||||
|
|
||||||
import { cellDomElement } from "./_lib/cellDomElement";
|
import { cellDomElement } from "../_lib/cellDomElement";
|
||||||
import { DRAG_THRESHOLD } from "./_lib/constants";
|
import { DRAG_THRESHOLD } from "../_lib/constants";
|
||||||
import { useTableDropdownMenu } from "./_lib/useTableDropdownMenu";
|
import type { DropdownMenu } from "../_lib/useTableDropdownMenu";
|
||||||
import { MenuButton } from "./MenuButton";
|
|
||||||
|
|
||||||
const tableCellMenuVariants = tv({
|
import { tableCellMenuVariants } from "./variants";
|
||||||
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"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { trigger, menu } = tableCellMenuVariants();
|
const { trigger } = tableCellMenuVariants();
|
||||||
|
|
||||||
type TableCellMenuProps = {
|
type TableCellMenuTriggerProps = {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
|
activeCellEl: HTMLElement;
|
||||||
|
menuOpen: boolean;
|
||||||
|
setMenuOpen: DropdownMenu["setMenuOpen"];
|
||||||
|
setTriggerEl: DropdownMenu["setTriggerEl"];
|
||||||
|
menuRefs: DropdownMenu["menuRefs"];
|
||||||
};
|
};
|
||||||
|
|
||||||
function getActiveCellEl(editor: Editor): HTMLElement | null {
|
export function TableCellMenuTrigger({
|
||||||
const { selection } = editor.state;
|
editor,
|
||||||
|
activeCellEl,
|
||||||
if (selection instanceof CellSelection) {
|
menuOpen,
|
||||||
return cellDomElement(editor, selection.$headCell.pos);
|
setMenuOpen,
|
||||||
}
|
setTriggerEl,
|
||||||
|
menuRefs,
|
||||||
const $pos = editor.state.doc.resolve(selection.from);
|
}: TableCellMenuTriggerProps) {
|
||||||
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 [handleHovered, setHandleHovered] = useState(false);
|
const [handleHovered, setHandleHovered] = useState(false);
|
||||||
const draggingRef = useRef(false);
|
const draggingRef = useRef(false);
|
||||||
const dragStartPos = useRef({ x: 0, y: 0 });
|
const dragStartPos = useRef({ x: 0, y: 0 });
|
||||||
@@ -64,14 +42,6 @@ export function TableCellMenu({ editor }: TableCellMenuProps) {
|
|||||||
tableStart: number;
|
tableStart: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
const activeCellEl = useEditorState({
|
|
||||||
editor,
|
|
||||||
selector: ({ editor: e }) => {
|
|
||||||
if (e.isDestroyed || !e.isEditable) return null;
|
|
||||||
return getActiveCellEl(e);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
refs: handleRefs,
|
refs: handleRefs,
|
||||||
floatingStyles: handleStyles,
|
floatingStyles: handleStyles,
|
||||||
@@ -85,11 +55,6 @@ export function TableCellMenu({ editor }: TableCellMenuProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!activeCellEl) {
|
|
||||||
handleRefs.setReference(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ed = editor;
|
const ed = editor;
|
||||||
const fallback = activeCellEl;
|
const fallback = activeCellEl;
|
||||||
|
|
||||||
@@ -119,8 +84,6 @@ export function TableCellMenu({ editor }: TableCellMenuProps) {
|
|||||||
});
|
});
|
||||||
}, [activeCellEl, editor, handleRefs]);
|
}, [activeCellEl, editor, handleRefs]);
|
||||||
|
|
||||||
if (!activeCellEl) return null;
|
|
||||||
|
|
||||||
const getAnchorCellPos = (): number | null => {
|
const getAnchorCellPos = (): number | null => {
|
||||||
try {
|
try {
|
||||||
const { selection, doc } = editor.state;
|
const { selection, doc } = editor.state;
|
||||||
@@ -230,97 +193,30 @@ export function TableCellMenu({ editor }: TableCellMenuProps) {
|
|||||||
document.addEventListener("mouseup", onMouseUp);
|
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 (
|
return (
|
||||||
<>
|
<button
|
||||||
<button
|
ref={(node) => {
|
||||||
ref={(node) => {
|
handleRefs.setFloating(node);
|
||||||
handleRefs.setFloating(node);
|
setTriggerEl(node);
|
||||||
setTriggerEl(node);
|
menuRefs.setReference(node);
|
||||||
menuRefs.setReference(node);
|
}}
|
||||||
}}
|
onMouseDown={onHandleMouseDown}
|
||||||
onMouseDown={onHandleMouseDown}
|
onMouseEnter={() => setHandleHovered(true)}
|
||||||
onMouseEnter={() => setHandleHovered(true)}
|
onMouseLeave={() => setHandleHovered(false)}
|
||||||
onMouseLeave={() => setHandleHovered(false)}
|
type="button"
|
||||||
type="button"
|
style={{
|
||||||
style={{
|
...handleStyles,
|
||||||
...handleStyles,
|
visibility: isPositioned ? "visible" : "hidden",
|
||||||
visibility: isPositioned ? "visible" : "hidden",
|
}}
|
||||||
}}
|
className={trigger()}
|
||||||
className={trigger()}
|
>
|
||||||
>
|
{handleHovered || menuOpen
|
||||||
{handleHovered || menuOpen
|
? (
|
||||||
? (
|
<div className="rounded-full bg-level-0 w-4.5 h-3.5 my-0.5 flex items-center">
|
||||||
<div className="rounded-full bg-level-0 w-4.5 h-3.5 my-0.5 flex items-center">
|
<DotsThreeCircleVerticalIcon size={18} weight="fill" />
|
||||||
<DotsThreeCircleVerticalIcon size={18} weight="fill" />
|
</div>
|
||||||
</div>
|
)
|
||||||
)
|
: <CircleIcon size={10} weight="fill" />}
|
||||||
: <CircleIcon size={10} weight="fill" />}
|
</button>
|
||||||
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
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.
|
// that can be found in the LICENSE file.
|
||||||
|
|
||||||
import { autoUpdate, offset, size, useFloating } from "@floating-ui/react";
|
import { autoUpdate, offset, size, useFloating } from "@floating-ui/react";
|
||||||
import {
|
import { DotsThreeIcon } from "@phosphor-icons/react";
|
||||||
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 { cellAround, CellSelection, TableMap } from "@tiptap/pm/tables";
|
import { cellAround, CellSelection, TableMap } from "@tiptap/pm/tables";
|
||||||
import { type Editor } from "@tiptap/react";
|
import { type Editor } from "@tiptap/react";
|
||||||
import { useEffect, useLayoutEffect, useRef, useState } from "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 { DRAG_THRESHOLD } from "./_lib/constants";
|
import type { DropdownMenu } from "../_lib/useTableDropdownMenu";
|
||||||
import { useTableDropdownMenu } from "./_lib/useTableDropdownMenu";
|
|
||||||
import { MenuButton } from "./MenuButton";
|
|
||||||
|
|
||||||
const tableColumnMenuVariants = tv({
|
import { getColumnRect, type HoveredColumn, moveColumn } from "./TableColumnMenu";
|
||||||
slots: {
|
import { tableColumnMenuVariants } from "./variants";
|
||||||
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"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { trigger, menu } = tableColumnMenuVariants();
|
const { trigger } = tableColumnMenuVariants();
|
||||||
|
|
||||||
type HoveredColumn = {
|
type TableColumnMenuTriggerProps = {
|
||||||
colIndex: number;
|
|
||||||
tableStart: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TableColumnMenuProps = {
|
|
||||||
editor: Editor;
|
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(
|
export function TableColumnMenuTrigger({
|
||||||
editor: Editor,
|
editor,
|
||||||
tableStart: number,
|
hoveredCol,
|
||||||
colIndex: number,
|
setHoveredCol,
|
||||||
): DOMRect | null {
|
menuOpen,
|
||||||
try {
|
setMenuOpen,
|
||||||
const tableNodePos = tableStart - 1;
|
setTriggerEl,
|
||||||
const table = editor.state.doc.nodeAt(tableNodePos);
|
menuRefs,
|
||||||
if (!table) return null;
|
}: TableColumnMenuTriggerProps) {
|
||||||
|
|
||||||
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);
|
|
||||||
const [dragIndicator, setDragIndicator] = useState<{
|
const [dragIndicator, setDragIndicator] = useState<{
|
||||||
left: number;
|
left: number;
|
||||||
top: number;
|
top: number;
|
||||||
@@ -217,7 +128,7 @@ export function TableColumnMenu({ editor }: TableColumnMenuProps) {
|
|||||||
rafId.current = null;
|
rafId.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [editor, menuOpen]);
|
}, [editor, menuOpen, setHoveredCol]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
refs: handleRefs,
|
refs: handleRefs,
|
||||||
@@ -258,8 +169,6 @@ export function TableColumnMenu({ editor }: TableColumnMenuProps) {
|
|||||||
});
|
});
|
||||||
}, [hoveredCol, editor, handleRefs]);
|
}, [hoveredCol, editor, handleRefs]);
|
||||||
|
|
||||||
if (!hoveredCol && !menuOpen) return null;
|
|
||||||
|
|
||||||
const computeTargetGap = (clientX: number, tableStart: number): number => {
|
const computeTargetGap = (clientX: number, tableStart: number): number => {
|
||||||
const table = editor.state.doc.nodeAt(tableStart - 1);
|
const table = editor.state.doc.nodeAt(tableStart - 1);
|
||||||
if (!table) return 0;
|
if (!table) return 0;
|
||||||
@@ -420,195 +329,6 @@ export function TableColumnMenu({ editor }: TableColumnMenuProps) {
|
|||||||
document.addEventListener("mouseup", onMouseUp);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -629,46 +349,6 @@ export function TableColumnMenu({ editor }: TableColumnMenuProps) {
|
|||||||
>
|
>
|
||||||
<DotsThreeIcon size={16} weight="bold" />
|
<DotsThreeIcon size={16} weight="bold" />
|
||||||
</button>
|
</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 && (
|
{dragIndicator && (
|
||||||
<div
|
<div
|
||||||
style={{
|
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.
|
// that can be found in the LICENSE file.
|
||||||
|
|
||||||
import { autoUpdate, offset, size, useFloating } from "@floating-ui/react";
|
import { autoUpdate, offset, size, useFloating } from "@floating-ui/react";
|
||||||
import {
|
import { DotsThreeVerticalIcon } from "@phosphor-icons/react";
|
||||||
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 { cellAround, CellSelection, TableMap } from "@tiptap/pm/tables";
|
import { cellAround, CellSelection, TableMap } from "@tiptap/pm/tables";
|
||||||
import { type Editor } from "@tiptap/react";
|
import { type Editor } from "@tiptap/react";
|
||||||
import { useEffect, useLayoutEffect, useRef, useState } from "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 { DRAG_THRESHOLD } from "./_lib/constants";
|
import type { DropdownMenu } from "../_lib/useTableDropdownMenu";
|
||||||
import { useTableDropdownMenu } from "./_lib/useTableDropdownMenu";
|
|
||||||
import { MenuButton } from "./MenuButton";
|
|
||||||
|
|
||||||
const tableRowMenuVariants = tv({
|
import { getRowRect, type HoveredRow, moveRow } from "./TableRowMenu";
|
||||||
slots: {
|
import { tableRowMenuVariants } from "./variants";
|
||||||
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"],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { trigger, menu } = tableRowMenuVariants();
|
const { trigger } = tableRowMenuVariants();
|
||||||
|
|
||||||
type HoveredRow = {
|
type TableRowMenuTriggerProps = {
|
||||||
rowIndex: number;
|
|
||||||
tableStart: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type TableRowMenuProps = {
|
|
||||||
editor: Editor;
|
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(
|
export function TableRowMenuTrigger({
|
||||||
editor: Editor,
|
editor,
|
||||||
tableStart: number,
|
hoveredRow,
|
||||||
rowIndex: number,
|
setHoveredRow,
|
||||||
): DOMRect | null {
|
menuOpen,
|
||||||
try {
|
setMenuOpen,
|
||||||
const tableNodePos = tableStart - 1;
|
setTriggerEl,
|
||||||
const table = editor.state.doc.nodeAt(tableNodePos);
|
menuRefs,
|
||||||
if (!table) return null;
|
}: TableRowMenuTriggerProps) {
|
||||||
|
|
||||||
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);
|
|
||||||
const [dragIndicator, setDragIndicator] = useState<{
|
const [dragIndicator, setDragIndicator] = useState<{
|
||||||
left: number;
|
left: number;
|
||||||
top: number;
|
top: number;
|
||||||
@@ -213,7 +128,7 @@ export function TableRowMenu({ editor }: TableRowMenuProps) {
|
|||||||
rafId.current = null;
|
rafId.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [editor, menuOpen]);
|
}, [editor, menuOpen, setHoveredRow]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
refs: handleRefs,
|
refs: handleRefs,
|
||||||
@@ -254,8 +169,6 @@ export function TableRowMenu({ editor }: TableRowMenuProps) {
|
|||||||
});
|
});
|
||||||
}, [hoveredRow, editor, handleRefs]);
|
}, [hoveredRow, editor, handleRefs]);
|
||||||
|
|
||||||
if (!hoveredRow && !menuOpen) return null;
|
|
||||||
|
|
||||||
const computeTargetGap = (clientY: number, tableStart: number): number => {
|
const computeTargetGap = (clientY: number, tableStart: number): number => {
|
||||||
const table = editor.state.doc.nodeAt(tableStart - 1);
|
const table = editor.state.doc.nodeAt(tableStart - 1);
|
||||||
if (!table) return 0;
|
if (!table) return 0;
|
||||||
@@ -416,193 +329,6 @@ export function TableRowMenu({ editor }: TableRowMenuProps) {
|
|||||||
document.addEventListener("mouseup", onMouseUp);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
@@ -623,46 +349,6 @@ export function TableRowMenu({ editor }: TableRowMenuProps) {
|
|||||||
>
|
>
|
||||||
<DotsThreeVerticalIcon size={16} weight="bold" />
|
<DotsThreeVerticalIcon size={16} weight="bold" />
|
||||||
</button>
|
</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 && (
|
{dragIndicator && (
|
||||||
<div
|
<div
|
||||||
style={{
|
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";
|
} from "@floating-ui/react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export type DropdownMenu = ReturnType<typeof useTableDropdownMenu>;
|
||||||
|
|
||||||
export function useTableDropdownMenu() {
|
export function useTableDropdownMenu() {
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [triggerEl, setTriggerEl] = useState<Element | null>(null);
|
const [triggerEl, setTriggerEl] = useState<Element | null>(null);
|
||||||
|
|||||||
Reference in New Issue
Block a user