From aef0da4c5215724c4a55b339a5a0009462084a8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89mile=20R=C3=A9?= Date: Wed, 25 Mar 2026 16:56:58 +0400 Subject: [PATCH] Refactor duplicated code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Émile Ré --- packages/ui/src/RichEditor/BlockMenu.tsx | 95 ++---------- packages/ui/src/RichEditor/OptionsMenu.tsx | 143 +++--------------- packages/ui/src/RichEditor/RichEditor.tsx | 2 + packages/ui/src/RichEditor/TableCellMenu.tsx | 66 +++----- .../ui/src/RichEditor/TableColumnMenu.tsx | 74 +++------ packages/ui/src/RichEditor/TableRowMenu.tsx | 74 +++------ .../ui/src/RichEditor/_lib/cellDomElement.ts | 18 +++ packages/ui/src/RichEditor/_lib/constants.ts | 5 + .../ui/src/RichEditor/_lib/getBlockNode.ts | 48 ++++++ .../ui/src/RichEditor/_lib/useBlockTrigger.ts | 30 ++++ .../ui/src/RichEditor/_lib/useHoveredBlock.ts | 73 +++++++++ .../RichEditor/_lib/useTableDropdownMenu.ts | 50 ++++++ 12 files changed, 324 insertions(+), 354 deletions(-) create mode 100644 packages/ui/src/RichEditor/_lib/cellDomElement.ts create mode 100644 packages/ui/src/RichEditor/_lib/constants.ts create mode 100644 packages/ui/src/RichEditor/_lib/getBlockNode.ts create mode 100644 packages/ui/src/RichEditor/_lib/useBlockTrigger.ts create mode 100644 packages/ui/src/RichEditor/_lib/useHoveredBlock.ts create mode 100644 packages/ui/src/RichEditor/_lib/useTableDropdownMenu.ts diff --git a/packages/ui/src/RichEditor/BlockMenu.tsx b/packages/ui/src/RichEditor/BlockMenu.tsx index 142ff664a..4af16217f 100644 --- a/packages/ui/src/RichEditor/BlockMenu.tsx +++ b/packages/ui/src/RichEditor/BlockMenu.tsx @@ -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>["chain"]>; +type ChainCommands = ReturnType; 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>): SlashCommandStorage | undefined { +function getSlashStorage(editor: Editor): SlashCommandStorage | undefined { return (editor.storage as unknown as Record).slashCommand as | SlashCommandStorage | undefined; } type BlockMenuProps = { - editor: ReturnType; + editor: Editor; }; export function BlockMenu({ editor }: BlockMenuProps) { - const [hoveredBlock, setHoveredBlock] = useState(null); const [slashNav, setSlashNav] = useState({ index: 0, query: "" }); - const rafId = useRef(null); const slashDropdownRef = useRef(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; diff --git a/packages/ui/src/RichEditor/OptionsMenu.tsx b/packages/ui/src/RichEditor/OptionsMenu.tsx index 3cb727616..a5f2b44b1 100644 --- a/packages/ui/src/RichEditor/OptionsMenu.tsx +++ b/packages/ui/src/RichEditor/OptionsMenu.tsx @@ -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, node: NodeSelection) { view.dragging = { slice, move: true, node } as typeof view.dragging; } type OptionsMenuProps = { - editor: ReturnType; + editor: Editor; }; export function OptionsMenu({ editor }: OptionsMenuProps) { const [menuOpen, setMenuOpen] = useState(false); const [triggerEl, setTriggerEl] = useState(null); const [dropdownEl, setDropdownEl] = useState(null); - const [hoveredBlock, setHoveredBlock] = useState(null); - const rafId = useRef(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) => { - 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) => ReturnType, ) => { - 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) => { - 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) { >
Turn into
handleAction(chain => chain.setParagraph())} > Text handleAction(chain => chain.toggleHeading({ level: 1 }))} > Heading 1 handleAction(chain => chain.toggleHeading({ level: 2 }))} > Heading 2 handleAction(chain => chain.toggleHeading({ level: 3 }))} > Heading 3 handleAction(chain => chain.toggleBulletList())} > Bullet List handleAction(chain => chain.toggleOrderedList())} > Ordered List handleAction(chain => chain.toggleCodeBlock())} > Code Block handleAction(chain => chain.toggleBlockquote())} > diff --git a/packages/ui/src/RichEditor/RichEditor.tsx b/packages/ui/src/RichEditor/RichEditor.tsx index 6feed0514..34dd3e8b2 100644 --- a/packages/ui/src/RichEditor/RichEditor.tsx +++ b/packages/ui/src/RichEditor/RichEditor.tsx @@ -106,6 +106,8 @@ export function RichEditor(props: RichEditorProps) { } }, [content, watchedContent, onChangeContent]); + if (!editor) return null; + return (
diff --git a/packages/ui/src/RichEditor/TableCellMenu.tsx b/packages/ui/src/RichEditor/TableCellMenu.tsx index 33918977e..aeb542616 100644 --- a/packages/ui/src/RichEditor/TableCellMenu.tsx +++ b/packages/ui/src/RichEditor/TableCellMenu.tsx @@ -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; + editor: Editor; }; -function cellDomElement(editor: NonNullable>, 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>): HTMLElement | null { +function getActiveCellEl(editor: Editor): HTMLElement | null { const { selection } = editor.state; if (selection instanceof CellSelection) { @@ -63,10 +45,17 @@ function getActiveCellEl(editor: NonNullable>): 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(null); - const [dropdownEl, setDropdownEl] = useState(null); const draggingRef = useRef(false); const dragStartPos = useRef({ x: 0, y: 0 }); const anchorCellPosRef = useRef(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 { diff --git a/packages/ui/src/RichEditor/TableColumnMenu.tsx b/packages/ui/src/RichEditor/TableColumnMenu.tsx index 831cce62f..6112a46a5 100644 --- a/packages/ui/src/RichEditor/TableColumnMenu.tsx +++ b/packages/ui/src/RichEditor/TableColumnMenu.tsx @@ -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; + editor: Editor; }; -function cellDomElement( - editor: NonNullable>, - 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>, + editor: Editor, tableStart: number, colIndex: number, ): DOMRect | null { @@ -108,7 +86,7 @@ function getColumnRect( } function moveColumn( - editor: NonNullable>, + 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(null); - const [triggerEl, setTriggerEl] = useState(null); - const [dropdownEl, setDropdownEl] = useState(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); diff --git a/packages/ui/src/RichEditor/TableRowMenu.tsx b/packages/ui/src/RichEditor/TableRowMenu.tsx index cbaa13436..d61e9e3a5 100644 --- a/packages/ui/src/RichEditor/TableRowMenu.tsx +++ b/packages/ui/src/RichEditor/TableRowMenu.tsx @@ -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; + editor: Editor; }; -function cellDomElement( - editor: NonNullable>, - 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>, + editor: Editor, tableStart: number, rowIndex: number, ): DOMRect | null { @@ -108,7 +86,7 @@ function getRowRect( } function moveRow( - editor: NonNullable>, + 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(null); - const [triggerEl, setTriggerEl] = useState(null); - const [dropdownEl, setDropdownEl] = useState(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); diff --git a/packages/ui/src/RichEditor/_lib/cellDomElement.ts b/packages/ui/src/RichEditor/_lib/cellDomElement.ts new file mode 100644 index 000000000..4769d2822 --- /dev/null +++ b/packages/ui/src/RichEditor/_lib/cellDomElement.ts @@ -0,0 +1,18 @@ +// Copyright (c) 2026 Probo Inc . +// Use of this source code is governed by the ISC license +// that can be found in the LICENSE file. + +import type { Editor } from "@tiptap/react"; + +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; +} diff --git a/packages/ui/src/RichEditor/_lib/constants.ts b/packages/ui/src/RichEditor/_lib/constants.ts new file mode 100644 index 000000000..6bd47ce9a --- /dev/null +++ b/packages/ui/src/RichEditor/_lib/constants.ts @@ -0,0 +1,5 @@ +// Copyright (c) 2026 Probo Inc . +// Use of this source code is governed by the ISC license +// that can be found in the LICENSE file. + +export const DRAG_THRESHOLD = 4; diff --git a/packages/ui/src/RichEditor/_lib/getBlockNode.ts b/packages/ui/src/RichEditor/_lib/getBlockNode.ts new file mode 100644 index 000000000..321a73285 --- /dev/null +++ b/packages/ui/src/RichEditor/_lib/getBlockNode.ts @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Probo Inc . +// Use of this source code is governed by the ISC license +// that can be found in the LICENSE file. + +import type { 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, +): 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; +} diff --git a/packages/ui/src/RichEditor/_lib/useBlockTrigger.ts b/packages/ui/src/RichEditor/_lib/useBlockTrigger.ts new file mode 100644 index 000000000..f3dbf217a --- /dev/null +++ b/packages/ui/src/RichEditor/_lib/useBlockTrigger.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Probo Inc . +// Use of this source code is governed by the ISC license +// that can be found in the LICENSE file. + +import { autoUpdate, 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 }; +} diff --git a/packages/ui/src/RichEditor/_lib/useHoveredBlock.ts b/packages/ui/src/RichEditor/_lib/useHoveredBlock.ts new file mode 100644 index 000000000..d9e78c229 --- /dev/null +++ b/packages/ui/src/RichEditor/_lib/useHoveredBlock.ts @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Probo Inc . +// Use of this source code is governed by the ISC license +// that can be found in the LICENSE file. + +import type { Editor } from "@tiptap/react"; +import { type 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>; +}; + +export function useHoveredBlock( + editor: Editor, + isDisabled: boolean, +): UseHoveredBlockResult { + const [hoveredBlock, setHoveredBlock] = useState(null); + const rafId = useRef(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 }; +} diff --git a/packages/ui/src/RichEditor/_lib/useTableDropdownMenu.ts b/packages/ui/src/RichEditor/_lib/useTableDropdownMenu.ts new file mode 100644 index 000000000..859571904 --- /dev/null +++ b/packages/ui/src/RichEditor/_lib/useTableDropdownMenu.ts @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Probo Inc . +// Use of this source code is governed by the ISC license +// that can be found in the LICENSE file. + +import { + autoUpdate, + flip, + offset, + shift, + useDismiss, + useFloating, + useFloatingRootContext, + useInteractions, +} from "@floating-ui/react"; +import { useState } from "react"; + +export function useTableDropdownMenu() { + const [menuOpen, setMenuOpen] = useState(false); + const [triggerEl, setTriggerEl] = useState(null); + const [dropdownEl, setDropdownEl] = useState(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, + }; +}