Refactor duplicated code

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-03-25 16:56:58 +04:00
parent 48fbca6b04
commit aef0da4c52
12 changed files with 324 additions and 354 deletions

View File

@@ -11,15 +11,17 @@ import {
} from "@floating-ui/react";
import type { Icon } from "@phosphor-icons/react";
import { CodeBlockIcon, GridFourIcon, ListBulletsIcon, ListNumbersIcon, MinusIcon, PlusIcon, QuotesIcon, TextHOneIcon, TextHThreeIcon, TextHTwoIcon, TextTIcon } from "@phosphor-icons/react";
import { type useEditor, useEditorState } from "@tiptap/react";
import { type Editor, useEditorState } from "@tiptap/react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { tv } from "tailwind-variants";
import { useBlockTrigger } from "./_lib/useBlockTrigger";
import { useHoveredBlock } from "./_lib/useHoveredBlock";
import { MenuButton } from "./MenuButton";
import type { SlashCommandStorage } from "./SlashCommandExtension";
import { activateSlashCommand, deactivateSlashCommand } from "./SlashCommandExtension";
type ChainCommands = ReturnType<NonNullable<ReturnType<typeof useEditor>>["chain"]>;
type ChainCommands = ReturnType<Editor["chain"]>;
type BlockItem = {
label: string;
@@ -52,32 +54,18 @@ const blockMenuVariants = tv({
const { trigger, menu } = blockMenuVariants();
const TRIGGER_HEIGHT = 24;
function findClosestRootBlock(element: Element, editorDom: Element): HTMLElement | null {
let current: Element | null = element;
while (current?.parentElement && current.parentElement !== editorDom) {
current = current.parentElement;
}
return current?.parentElement === editorDom ? (current as HTMLElement) : null;
}
function getSlashStorage(editor: NonNullable<ReturnType<typeof useEditor>>): SlashCommandStorage | undefined {
function getSlashStorage(editor: Editor): SlashCommandStorage | undefined {
return (editor.storage as unknown as Record<string, unknown>).slashCommand as
| SlashCommandStorage
| undefined;
}
type BlockMenuProps = {
editor: ReturnType<typeof useEditor>;
editor: Editor;
};
export function BlockMenu({ editor }: BlockMenuProps) {
const [hoveredBlock, setHoveredBlock] = useState<HTMLElement | null>(null);
const [slashNav, setSlashNav] = useState({ index: 0, query: "" });
const rafId = useRef<number | null>(null);
const slashDropdownRef = useRef<HTMLDivElement | null>(null);
const slashState = useEditorState({
@@ -92,6 +80,9 @@ export function BlockMenu({ editor }: BlockMenuProps) {
},
});
const { hoveredBlock } = useHoveredBlock(editor, slashState.active);
const { triggerRefs, triggerStyles, isPositioned } = useBlockTrigger(hoveredBlock, 40);
const slashActiveIndex = slashState.query === slashNav.query
? slashNav.index
: 0;
@@ -114,7 +105,7 @@ export function BlockMenu({ editor }: BlockMenuProps) {
});
useLayoutEffect(() => {
if (!slashState.active || !editor) {
if (!slashState.active) {
slashMenuRefs.setPositionReference(null);
return;
}
@@ -142,7 +133,7 @@ export function BlockMenu({ editor }: BlockMenuProps) {
const handleSlashAction = useCallback(
(item: BlockItem) => {
if (!editor || !slashState.active) return;
if (!slashState.active) return;
const { from } = slashState;
const cursorPos = editor.state.selection.from;
@@ -163,7 +154,7 @@ export function BlockMenu({ editor }: BlockMenuProps) {
);
useEffect(() => {
if (!editor || editor.isDestroyed || !slashState.active) return;
if (editor.isDestroyed || !slashState.active) return;
const editorDom = editor.view.dom;
const onKeyDown = (e: KeyboardEvent) => {
@@ -201,68 +192,6 @@ export function BlockMenu({ editor }: BlockMenuProps) {
};
}, [editor, slashState.active, slashState.query, filteredItems, slashActiveIndex, handleSlashAction]);
const blockHeight = hoveredBlock?.getBoundingClientRect().height ?? 0;
const triggerPlacement = blockHeight > 2 * TRIGGER_HEIGHT ? "left-start" as const : "left" as const;
const {
refs: triggerRefs,
floatingStyles: triggerStyles,
isPositioned,
} = useFloating({
strategy: "fixed",
placement: triggerPlacement,
middleware: [offset(40)],
whileElementsMounted: autoUpdate,
});
useEffect(() => {
if (!editor || editor.isDestroyed) return;
const editorDom = editor.view.dom;
const onMouseMove = (e: MouseEvent) => {
if (slashState.active) return;
if (rafId.current) return;
rafId.current = requestAnimationFrame(() => {
rafId.current = null;
if (!editor.isEditable) {
setHoveredBlock(null);
return;
}
const elements = editorDom.ownerDocument.elementsFromPoint(e.clientX, e.clientY);
let block: HTMLElement | null = null;
for (const el of elements) {
if (!editorDom.contains(el)) continue;
block = findClosestRootBlock(el, editorDom);
if (block) break;
}
if (block) {
setHoveredBlock(block);
}
});
};
editorDom.addEventListener("mousemove", onMouseMove);
return () => {
editorDom.removeEventListener("mousemove", onMouseMove);
if (rafId.current) {
cancelAnimationFrame(rafId.current);
rafId.current = null;
}
};
}, [editor, slashState.active]);
useLayoutEffect(() => {
triggerRefs.setReference(hoveredBlock);
}, [hoveredBlock, triggerRefs]);
if (!editor) return null;
const handleTriggerClick = () => {
if (!hoveredBlock) return;

View File

@@ -16,10 +16,13 @@ import {
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 useEditor } from "@tiptap/react";
import { type DragEvent, useEffect, useLayoutEffect, useRef, useState } from "react";
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({
@@ -34,46 +37,21 @@ const optionsMenuVariants = tv({
const { trigger, menu } = optionsMenuVariants();
const TRIGGER_HEIGHT = 24;
function findClosestRootBlock(element: Element, editorDom: Element): HTMLElement | null {
let current: Element | null = element;
while (current?.parentElement && current.parentElement !== editorDom) {
current = current.parentElement;
}
return current?.parentElement === editorDom ? (current as HTMLElement) : null;
}
function startDrag(view: EditorView, slice: ReturnType<NodeSelection["content"]>, node: NodeSelection) {
view.dragging = { slice, move: true, node } as typeof view.dragging;
}
type OptionsMenuProps = {
editor: ReturnType<typeof useEditor>;
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] = useState<HTMLElement | null>(null);
const rafId = useRef<number | null>(null);
const blockHeight = hoveredBlock?.getBoundingClientRect().height ?? 0;
const triggerPlacement = blockHeight > 2 * TRIGGER_HEIGHT ? "left-start" as const : "left" as const;
const {
refs: triggerRefs,
floatingStyles: triggerStyles,
isPositioned,
} = useFloating({
strategy: "fixed",
placement: triggerPlacement,
middleware: [offset(16)],
whileElementsMounted: autoUpdate,
});
const { hoveredBlock, setHoveredBlock } = useHoveredBlock(editor, menuOpen);
const { triggerRefs, triggerStyles, isPositioned } = useBlockTrigger(hoveredBlock, 16);
const menuRootContext = useFloatingRootContext({
open: menuOpen,
@@ -93,90 +71,18 @@ export function OptionsMenu({ editor }: OptionsMenuProps) {
const dismiss = useDismiss(menuRootContext);
const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss]);
useEffect(() => {
if (!editor || editor.isDestroyed) return;
const editorDom = editor.view.dom;
const onMouseMove = (e: MouseEvent) => {
if (menuOpen) return;
if (rafId.current) return;
rafId.current = requestAnimationFrame(() => {
rafId.current = null;
if (!editor.isEditable) {
setHoveredBlock(null);
return;
}
const elements = editorDom.ownerDocument.elementsFromPoint(e.clientX, e.clientY);
let block: HTMLElement | null = null;
for (const el of elements) {
if (!editorDom.contains(el)) continue;
block = findClosestRootBlock(el, editorDom);
if (block) break;
}
if (block) {
setHoveredBlock(block);
}
});
};
editorDom.addEventListener("mousemove", onMouseMove);
return () => {
editorDom.removeEventListener("mousemove", onMouseMove);
if (rafId.current) {
cancelAnimationFrame(rafId.current);
rafId.current = null;
}
};
}, [editor, menuOpen]);
useLayoutEffect(() => {
triggerRefs.setReference(hoveredBlock);
}, [hoveredBlock, triggerRefs]);
const shouldShow = hoveredBlock != null || menuOpen;
if (!editor || !shouldShow) return null;
const getNodeAtHoveredBlock = () => {
if (!hoveredBlock) return null;
try {
const pos = editor.view.posAtDOM(hoveredBlock, 0);
const $pos = editor.state.doc.resolve(pos);
if ($pos.depth >= 1) {
return { node: $pos.node(1), pos: $pos.before(1) };
}
const nodeAfter = $pos.nodeAfter;
if (nodeAfter) {
return { node: nodeAfter, pos: pos };
}
return null;
} catch {
return null;
}
};
const isNodeType = (type: string, attrs?: Record<string, unknown>) => {
const data = getNodeAtHoveredBlock();
if (!data) return false;
if (data.node.type.name !== type) return false;
if (attrs) {
return Object.entries(attrs).every(
([key, value]) => data.node.attrs[key] === value,
);
}
return true;
};
if (!shouldShow) return null;
const handleAction = (
applyCommand: (chain: ReturnType<typeof editor.chain>) => ReturnType<typeof editor.chain>,
) => {
const data = getNodeAtHoveredBlock();
if (!hoveredBlock) {
setMenuOpen(false);
return;
}
const data = getBlockNode(editor, hoveredBlock);
if (!data) {
setMenuOpen(false);
return;
@@ -255,8 +161,9 @@ export function OptionsMenu({ editor }: OptionsMenuProps) {
};
const onDragStart = (e: DragEvent<HTMLButtonElement>) => {
const data = getNodeAtHoveredBlock();
if (!data || !hoveredBlock) return;
if (!hoveredBlock) return;
const data = getBlockNode(editor, hoveredBlock);
if (!data) return;
try {
const view = editor.view;
@@ -321,56 +228,56 @@ export function OptionsMenu({ editor }: OptionsMenuProps) {
>
<div className="p-1 font-semibold text-sm">Turn into</div>
<MenuButton
active={isNodeType("paragraph")}
active={hoveredBlock != null && isBlockNodeType(editor, hoveredBlock, "paragraph")}
onClick={() => handleAction(chain => chain.setParagraph())}
>
<TextTIcon size={16} weight="bold" />
Text
</MenuButton>
<MenuButton
active={isNodeType("heading", { level: 1 })}
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={isNodeType("heading", { level: 2 })}
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={isNodeType("heading", { level: 3 })}
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={isNodeType("bulletList")}
active={hoveredBlock != null && isBlockNodeType(editor, hoveredBlock, "bulletList")}
onClick={() => handleAction(chain => chain.toggleBulletList())}
>
<ListBulletsIcon size={16} weight="bold" />
Bullet List
</MenuButton>
<MenuButton
active={isNodeType("orderedList")}
active={hoveredBlock != null && isBlockNodeType(editor, hoveredBlock, "orderedList")}
onClick={() => handleAction(chain => chain.toggleOrderedList())}
>
<ListNumbersIcon size={16} weight="bold" />
Ordered List
</MenuButton>
<MenuButton
active={isNodeType("codeBlock")}
active={hoveredBlock != null && isBlockNodeType(editor, hoveredBlock, "codeBlock")}
onClick={() => handleAction(chain => chain.toggleCodeBlock())}
>
<CodeBlockIcon size={16} weight="bold" />
Code Block
</MenuButton>
<MenuButton
active={isNodeType("blockquote")}
active={hoveredBlock != null && isBlockNodeType(editor, hoveredBlock, "blockquote")}
onClick={() => handleAction(chain => chain.toggleBlockquote())}
>
<QuotesIcon size={16} weight="bold" />

View File

@@ -106,6 +106,8 @@ export function RichEditor(props: RichEditorProps) {
}
}, [content, watchedContent, onChangeContent]);
if (!editor) return null;
return (
<div className={richEditorVariants({ className })}>
<BubbleMenu editor={editor} />

View File

@@ -2,27 +2,19 @@
// Use of this source code is governed by the ISC license
// that can be found in the LICENSE file.
import {
autoUpdate,
flip,
offset,
shift,
useDismiss,
useFloating,
useFloatingRootContext,
useInteractions,
} from "@floating-ui/react";
import { autoUpdate, offset, useFloating } from "@floating-ui/react";
import { BroomIcon, CircleIcon, DotsThreeCircleVerticalIcon, IntersectIcon, SplitHorizontalIcon } from "@phosphor-icons/react";
import { TextSelection } from "@tiptap/pm/state";
import { cellAround, CellSelection, TableMap } from "@tiptap/pm/tables";
import { type useEditor, useEditorState } from "@tiptap/react";
import { type Editor, useEditorState } from "@tiptap/react";
import { useLayoutEffect, useRef, useState } from "react";
import { tv } from "tailwind-variants";
import { cellDomElement } from "./_lib/cellDomElement";
import { DRAG_THRESHOLD } from "./_lib/constants";
import { useTableDropdownMenu } from "./_lib/useTableDropdownMenu";
import { MenuButton } from "./MenuButton";
const DRAG_THRESHOLD = 4;
const tableCellMenuVariants = tv({
slots: {
trigger: [
@@ -36,20 +28,10 @@ const tableCellMenuVariants = tv({
const { trigger, menu } = tableCellMenuVariants();
type TableCellMenuProps = {
editor: ReturnType<typeof useEditor>;
editor: Editor;
};
function cellDomElement(editor: NonNullable<ReturnType<typeof useEditor>>, cellPos: number): HTMLElement | null {
const dom = editor.view.domAtPos(cellPos + 1);
let el: Node | null = dom.node;
if (el.nodeType === Node.TEXT_NODE) el = el.parentElement;
while (el && !(el instanceof HTMLTableCellElement)) {
el = (el as HTMLElement).parentElement;
}
return el as HTMLElement | null;
}
function getActiveCellEl(editor: NonNullable<ReturnType<typeof useEditor>>): HTMLElement | null {
function getActiveCellEl(editor: Editor): HTMLElement | null {
const { selection } = editor.state;
if (selection instanceof CellSelection) {
@@ -63,10 +45,17 @@ function getActiveCellEl(editor: NonNullable<ReturnType<typeof useEditor>>): HTM
}
export function TableCellMenu({ editor }: TableCellMenuProps) {
const [menuOpen, setMenuOpen] = useState(false);
const {
menuOpen,
setMenuOpen,
setTriggerEl,
setDropdownEl,
menuRefs,
menuStyles,
getFloatingProps,
} = useTableDropdownMenu();
const [handleHovered, setHandleHovered] = useState(false);
const [triggerEl, setTriggerEl] = useState<Element | null>(null);
const [dropdownEl, setDropdownEl] = useState<HTMLElement | null>(null);
const draggingRef = useRef(false);
const dragStartPos = useRef({ x: 0, y: 0 });
const anchorCellPosRef = useRef<number | null>(null);
@@ -95,25 +84,8 @@ export function TableCellMenu({ editor }: TableCellMenuProps) {
autoUpdate(ref, floating, update, { animationFrame: true }),
});
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 dismiss = useDismiss(menuRootContext);
const { getFloatingProps } = useInteractions([dismiss]);
useLayoutEffect(() => {
if (!editor || !activeCellEl) {
if (!activeCellEl) {
handleRefs.setReference(null);
return;
}
@@ -147,7 +119,7 @@ export function TableCellMenu({ editor }: TableCellMenuProps) {
});
}, [activeCellEl, editor, handleRefs]);
if (!editor || !activeCellEl) return null;
if (!activeCellEl) return null;
const getAnchorCellPos = (): number | null => {
try {

View File

@@ -2,17 +2,7 @@
// Use of this source code is governed by the ISC license
// that can be found in the LICENSE file.
import {
autoUpdate,
flip,
offset,
shift,
size,
useDismiss,
useFloating,
useFloatingRootContext,
useInteractions,
} from "@floating-ui/react";
import { autoUpdate, offset, size, useFloating } from "@floating-ui/react";
import {
BroomIcon,
CopyIcon,
@@ -24,14 +14,15 @@ import {
import type { Node as PMNode } from "@tiptap/pm/model";
import { TextSelection } from "@tiptap/pm/state";
import { cellAround, CellSelection, TableMap } from "@tiptap/pm/tables";
import { type useEditor } from "@tiptap/react";
import { type Editor } from "@tiptap/react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { tv } from "tailwind-variants";
import { cellDomElement } from "./_lib/cellDomElement";
import { DRAG_THRESHOLD } from "./_lib/constants";
import { useTableDropdownMenu } from "./_lib/useTableDropdownMenu";
import { MenuButton } from "./MenuButton";
const DRAG_THRESHOLD = 4;
const tableColumnMenuVariants = tv({
slots: {
trigger: [
@@ -51,24 +42,11 @@ type HoveredColumn = {
};
type TableColumnMenuProps = {
editor: ReturnType<typeof useEditor>;
editor: Editor;
};
function cellDomElement(
editor: NonNullable<ReturnType<typeof useEditor>>,
cellPos: number,
): HTMLElement | null {
const dom = editor.view.domAtPos(cellPos + 1);
let el: Node | null = dom.node;
if (el.nodeType === Node.TEXT_NODE) el = el.parentElement;
while (el && !(el instanceof HTMLTableCellElement)) {
el = (el as HTMLElement).parentElement;
}
return el as HTMLElement | null;
}
function getColumnRect(
editor: NonNullable<ReturnType<typeof useEditor>>,
editor: Editor,
tableStart: number,
colIndex: number,
): DOMRect | null {
@@ -108,7 +86,7 @@ function getColumnRect(
}
function moveColumn(
editor: NonNullable<ReturnType<typeof useEditor>>,
editor: Editor,
tableStart: number,
fromCol: number,
toCol: number,
@@ -135,10 +113,17 @@ function moveColumn(
}
export function TableColumnMenu({ editor }: TableColumnMenuProps) {
const [menuOpen, setMenuOpen] = useState(false);
const {
menuOpen,
setMenuOpen,
setTriggerEl,
setDropdownEl,
menuRefs,
menuStyles,
getFloatingProps,
} = useTableDropdownMenu();
const [hoveredCol, setHoveredCol] = useState<HoveredColumn | null>(null);
const [triggerEl, setTriggerEl] = useState<Element | null>(null);
const [dropdownEl, setDropdownEl] = useState<HTMLElement | null>(null);
const [dragIndicator, setDragIndicator] = useState<{
left: number;
top: number;
@@ -155,7 +140,7 @@ export function TableColumnMenu({ editor }: TableColumnMenuProps) {
}, [hoveredCol]);
useEffect(() => {
if (!editor || editor.isDestroyed || !editor.isEditable) return;
if (editor.isDestroyed || !editor.isEditable) return;
const editorDom = editor.view.dom;
@@ -255,25 +240,8 @@ export function TableColumnMenu({ editor }: TableColumnMenuProps) {
autoUpdate(ref, floating, update, { animationFrame: true }),
});
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 dismiss = useDismiss(menuRootContext);
const { getFloatingProps } = useInteractions([dismiss]);
useLayoutEffect(() => {
if (!editor || !hoveredCol) {
if (!hoveredCol) {
handleRefs.setReference(null);
return;
}
@@ -290,7 +258,7 @@ export function TableColumnMenu({ editor }: TableColumnMenuProps) {
});
}, [hoveredCol, editor, handleRefs]);
if (!editor || (!hoveredCol && !menuOpen)) return null;
if (!hoveredCol && !menuOpen) return null;
const computeTargetGap = (clientX: number, tableStart: number): number => {
const table = editor.state.doc.nodeAt(tableStart - 1);

View File

@@ -2,17 +2,7 @@
// Use of this source code is governed by the ISC license
// that can be found in the LICENSE file.
import {
autoUpdate,
flip,
offset,
shift,
size,
useDismiss,
useFloating,
useFloatingRootContext,
useInteractions,
} from "@floating-ui/react";
import { autoUpdate, offset, size, useFloating } from "@floating-ui/react";
import {
BroomIcon,
CopyIcon,
@@ -24,14 +14,15 @@ import {
import type { Node as PMNode } from "@tiptap/pm/model";
import { TextSelection } from "@tiptap/pm/state";
import { cellAround, CellSelection, TableMap } from "@tiptap/pm/tables";
import { type useEditor } from "@tiptap/react";
import { type Editor } from "@tiptap/react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { tv } from "tailwind-variants";
import { cellDomElement } from "./_lib/cellDomElement";
import { DRAG_THRESHOLD } from "./_lib/constants";
import { useTableDropdownMenu } from "./_lib/useTableDropdownMenu";
import { MenuButton } from "./MenuButton";
const DRAG_THRESHOLD = 4;
const tableRowMenuVariants = tv({
slots: {
trigger: [
@@ -51,24 +42,11 @@ type HoveredRow = {
};
type TableRowMenuProps = {
editor: ReturnType<typeof useEditor>;
editor: Editor;
};
function cellDomElement(
editor: NonNullable<ReturnType<typeof useEditor>>,
cellPos: number,
): HTMLElement | null {
const dom = editor.view.domAtPos(cellPos + 1);
let el: Node | null = dom.node;
if (el.nodeType === Node.TEXT_NODE) el = el.parentElement;
while (el && !(el instanceof HTMLTableCellElement)) {
el = (el as HTMLElement).parentElement;
}
return el as HTMLElement | null;
}
function getRowRect(
editor: NonNullable<ReturnType<typeof useEditor>>,
editor: Editor,
tableStart: number,
rowIndex: number,
): DOMRect | null {
@@ -108,7 +86,7 @@ function getRowRect(
}
function moveRow(
editor: NonNullable<ReturnType<typeof useEditor>>,
editor: Editor,
tableStart: number,
fromRow: number,
toRow: number,
@@ -131,10 +109,17 @@ function moveRow(
}
export function TableRowMenu({ editor }: TableRowMenuProps) {
const [menuOpen, setMenuOpen] = useState(false);
const {
menuOpen,
setMenuOpen,
setTriggerEl,
setDropdownEl,
menuRefs,
menuStyles,
getFloatingProps,
} = useTableDropdownMenu();
const [hoveredRow, setHoveredRow] = useState<HoveredRow | null>(null);
const [triggerEl, setTriggerEl] = useState<Element | null>(null);
const [dropdownEl, setDropdownEl] = useState<HTMLElement | null>(null);
const [dragIndicator, setDragIndicator] = useState<{
left: number;
top: number;
@@ -151,7 +136,7 @@ export function TableRowMenu({ editor }: TableRowMenuProps) {
}, [hoveredRow]);
useEffect(() => {
if (!editor || editor.isDestroyed || !editor.isEditable) return;
if (editor.isDestroyed || !editor.isEditable) return;
const editorDom = editor.view.dom;
@@ -251,25 +236,8 @@ export function TableRowMenu({ editor }: TableRowMenuProps) {
autoUpdate(ref, floating, update, { animationFrame: true }),
});
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 dismiss = useDismiss(menuRootContext);
const { getFloatingProps } = useInteractions([dismiss]);
useLayoutEffect(() => {
if (!editor || !hoveredRow) {
if (!hoveredRow) {
handleRefs.setReference(null);
return;
}
@@ -286,7 +254,7 @@ export function TableRowMenu({ editor }: TableRowMenuProps) {
});
}, [hoveredRow, editor, handleRefs]);
if (!editor || (!hoveredRow && !menuOpen)) return null;
if (!hoveredRow && !menuOpen) return null;
const computeTargetGap = (clientY: number, tableStart: number): number => {
const table = editor.state.doc.nodeAt(tableStart - 1);

View File

@@ -0,0 +1,18 @@
// 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";
export function cellDomElement(
editor: Editor,
cellPos: number,
): HTMLElement | null {
const dom = editor.view.domAtPos(cellPos + 1);
let el: Node | null = dom.node;
if (el.nodeType === Node.TEXT_NODE) el = el.parentElement;
while (el && !(el instanceof HTMLTableCellElement)) {
el = (el as HTMLElement).parentElement;
}
return el as HTMLElement | null;
}

View File

@@ -0,0 +1,5 @@
// 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.
export const DRAG_THRESHOLD = 4;

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
// Use of this source code is governed by the ISC license
// that can be found in the LICENSE file.
import type { Node as PmNode } from "@tiptap/pm/model";
import type { Editor } from "@tiptap/react";
type BlockNodeData = {
node: PmNode;
pos: number;
};
export function getBlockNode(
editor: Editor,
block: HTMLElement,
): BlockNodeData | null {
try {
const pos = editor.view.posAtDOM(block, 0);
const $pos = editor.state.doc.resolve(pos);
if ($pos.depth >= 1) {
return { node: $pos.node(1), pos: $pos.before(1) };
}
const nodeAfter = $pos.nodeAfter;
if (nodeAfter) {
return { node: nodeAfter, pos };
}
return null;
} catch {
return null;
}
}
export function isBlockNodeType(
editor: Editor,
block: HTMLElement,
type: string,
attrs?: Record<string, unknown>,
): boolean {
const data = getBlockNode(editor, block);
if (!data) return false;
if (data.node.type.name !== type) return false;
if (attrs) {
return Object.entries(attrs).every(
([key, value]) => data.node.attrs[key] === value,
);
}
return true;
}

View File

@@ -0,0 +1,30 @@
// 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, offset, useFloating } from "@floating-ui/react";
import { useLayoutEffect } from "react";
const TRIGGER_HEIGHT = 24;
export function useBlockTrigger(hoveredBlock: HTMLElement | null, offsetValue: number) {
const blockHeight = hoveredBlock?.getBoundingClientRect().height ?? 0;
const triggerPlacement = blockHeight > 2 * TRIGGER_HEIGHT ? "left-start" as const : "left" as const;
const {
refs: triggerRefs,
floatingStyles: triggerStyles,
isPositioned,
} = useFloating({
strategy: "fixed",
placement: triggerPlacement,
middleware: [offset(offsetValue)],
whileElementsMounted: autoUpdate,
});
useLayoutEffect(() => {
triggerRefs.setReference(hoveredBlock);
}, [hoveredBlock, triggerRefs]);
return { triggerRefs, triggerStyles, isPositioned };
}

View File

@@ -0,0 +1,73 @@
// 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 Dispatch, type SetStateAction, useEffect, useRef, useState } from "react";
function findClosestRootBlock(element: Element, editorDom: Element): HTMLElement | null {
let current: Element | null = element;
while (current?.parentElement && current.parentElement !== editorDom) {
current = current.parentElement;
}
return current?.parentElement === editorDom ? (current as HTMLElement) : null;
}
type UseHoveredBlockResult = {
hoveredBlock: HTMLElement | null;
setHoveredBlock: Dispatch<SetStateAction<HTMLElement | null>>;
};
export function useHoveredBlock(
editor: Editor,
isDisabled: boolean,
): UseHoveredBlockResult {
const [hoveredBlock, setHoveredBlock] = useState<HTMLElement | null>(null);
const rafId = useRef<number | null>(null);
useEffect(() => {
if (editor.isDestroyed) return;
const editorDom = editor.view.dom;
const onMouseMove = (e: MouseEvent) => {
if (isDisabled) return;
if (rafId.current) return;
rafId.current = requestAnimationFrame(() => {
rafId.current = null;
if (!editor.isEditable) {
setHoveredBlock(null);
return;
}
const elements = editorDom.ownerDocument.elementsFromPoint(e.clientX, e.clientY);
let block: HTMLElement | null = null;
for (const el of elements) {
if (!editorDom.contains(el)) continue;
block = findClosestRootBlock(el, editorDom);
if (block) break;
}
if (block) {
setHoveredBlock(block);
}
});
};
editorDom.addEventListener("mousemove", onMouseMove);
return () => {
editorDom.removeEventListener("mousemove", onMouseMove);
if (rafId.current) {
cancelAnimationFrame(rafId.current);
rafId.current = null;
}
};
}, [editor, isDisabled]);
return { hoveredBlock, setHoveredBlock };
}

View File

@@ -0,0 +1,50 @@
// 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,
useDismiss,
useFloating,
useFloatingRootContext,
useInteractions,
} from "@floating-ui/react";
import { useState } from "react";
export function useTableDropdownMenu() {
const [menuOpen, setMenuOpen] = useState(false);
const [triggerEl, setTriggerEl] = useState<Element | null>(null);
const [dropdownEl, setDropdownEl] = useState<HTMLElement | null>(null);
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 dismiss = useDismiss(menuRootContext);
const { getFloatingProps } = useInteractions([dismiss]);
return {
menuOpen,
setMenuOpen,
triggerEl,
setTriggerEl,
dropdownEl,
setDropdownEl,
menuRefs,
menuStyles,
getFloatingProps,
};
}