@@ -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;
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -106,6 +106,8 @@ export function RichEditor(props: RichEditorProps) {
|
||||
}
|
||||
}, [content, watchedContent, onChangeContent]);
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return (
|
||||
<div className={richEditorVariants({ className })}>
|
||||
<BubbleMenu editor={editor} />
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
18
packages/ui/src/RichEditor/_lib/cellDomElement.ts
Normal file
18
packages/ui/src/RichEditor/_lib/cellDomElement.ts
Normal 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;
|
||||
}
|
||||
5
packages/ui/src/RichEditor/_lib/constants.ts
Normal file
5
packages/ui/src/RichEditor/_lib/constants.ts
Normal 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;
|
||||
48
packages/ui/src/RichEditor/_lib/getBlockNode.ts
Normal file
48
packages/ui/src/RichEditor/_lib/getBlockNode.ts
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 { 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;
|
||||
}
|
||||
30
packages/ui/src/RichEditor/_lib/useBlockTrigger.ts
Normal file
30
packages/ui/src/RichEditor/_lib/useBlockTrigger.ts
Normal 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 };
|
||||
}
|
||||
73
packages/ui/src/RichEditor/_lib/useHoveredBlock.ts
Normal file
73
packages/ui/src/RichEditor/_lib/useHoveredBlock.ts
Normal 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 };
|
||||
}
|
||||
50
packages/ui/src/RichEditor/_lib/useTableDropdownMenu.ts
Normal file
50
packages/ui/src/RichEditor/_lib/useTableDropdownMenu.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user