@@ -11,15 +11,17 @@ import {
|
|||||||
} from "@floating-ui/react";
|
} from "@floating-ui/react";
|
||||||
import type { Icon } from "@phosphor-icons/react";
|
import type { Icon } from "@phosphor-icons/react";
|
||||||
import { CodeBlockIcon, GridFourIcon, ListBulletsIcon, ListNumbersIcon, MinusIcon, PlusIcon, QuotesIcon, TextHOneIcon, TextHThreeIcon, TextHTwoIcon, TextTIcon } from "@phosphor-icons/react";
|
import { CodeBlockIcon, GridFourIcon, ListBulletsIcon, ListNumbersIcon, MinusIcon, 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 { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { tv } from "tailwind-variants";
|
import { tv } from "tailwind-variants";
|
||||||
|
|
||||||
|
import { useBlockTrigger } from "./_lib/useBlockTrigger";
|
||||||
|
import { useHoveredBlock } from "./_lib/useHoveredBlock";
|
||||||
import { MenuButton } from "./MenuButton";
|
import { MenuButton } from "./MenuButton";
|
||||||
import type { SlashCommandStorage } from "./SlashCommandExtension";
|
import type { SlashCommandStorage } from "./SlashCommandExtension";
|
||||||
import { activateSlashCommand, deactivateSlashCommand } from "./SlashCommandExtension";
|
import { activateSlashCommand, deactivateSlashCommand } from "./SlashCommandExtension";
|
||||||
|
|
||||||
type ChainCommands = ReturnType<NonNullable<ReturnType<typeof useEditor>>["chain"]>;
|
type ChainCommands = ReturnType<Editor["chain"]>;
|
||||||
|
|
||||||
type BlockItem = {
|
type BlockItem = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -52,32 +54,18 @@ const blockMenuVariants = tv({
|
|||||||
|
|
||||||
const { trigger, menu } = blockMenuVariants();
|
const { trigger, menu } = blockMenuVariants();
|
||||||
|
|
||||||
const TRIGGER_HEIGHT = 24;
|
function getSlashStorage(editor: Editor): SlashCommandStorage | undefined {
|
||||||
|
|
||||||
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 {
|
|
||||||
return (editor.storage as unknown as Record<string, unknown>).slashCommand as
|
return (editor.storage as unknown as Record<string, unknown>).slashCommand as
|
||||||
| SlashCommandStorage
|
| SlashCommandStorage
|
||||||
| undefined;
|
| undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
type BlockMenuProps = {
|
type BlockMenuProps = {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: Editor;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function BlockMenu({ editor }: BlockMenuProps) {
|
export function BlockMenu({ editor }: BlockMenuProps) {
|
||||||
const [hoveredBlock, setHoveredBlock] = useState<HTMLElement | null>(null);
|
|
||||||
const [slashNav, setSlashNav] = useState({ index: 0, query: "" });
|
const [slashNav, setSlashNav] = useState({ index: 0, query: "" });
|
||||||
const rafId = useRef<number | null>(null);
|
|
||||||
const slashDropdownRef = useRef<HTMLDivElement | null>(null);
|
const slashDropdownRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const slashState = useEditorState({
|
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
|
const slashActiveIndex = slashState.query === slashNav.query
|
||||||
? slashNav.index
|
? slashNav.index
|
||||||
: 0;
|
: 0;
|
||||||
@@ -114,7 +105,7 @@ export function BlockMenu({ editor }: BlockMenuProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!slashState.active || !editor) {
|
if (!slashState.active) {
|
||||||
slashMenuRefs.setPositionReference(null);
|
slashMenuRefs.setPositionReference(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -142,7 +133,7 @@ export function BlockMenu({ editor }: BlockMenuProps) {
|
|||||||
|
|
||||||
const handleSlashAction = useCallback(
|
const handleSlashAction = useCallback(
|
||||||
(item: BlockItem) => {
|
(item: BlockItem) => {
|
||||||
if (!editor || !slashState.active) return;
|
if (!slashState.active) return;
|
||||||
const { from } = slashState;
|
const { from } = slashState;
|
||||||
const cursorPos = editor.state.selection.from;
|
const cursorPos = editor.state.selection.from;
|
||||||
|
|
||||||
@@ -163,7 +154,7 @@ export function BlockMenu({ editor }: BlockMenuProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor || editor.isDestroyed || !slashState.active) return;
|
if (editor.isDestroyed || !slashState.active) return;
|
||||||
const editorDom = editor.view.dom;
|
const editorDom = editor.view.dom;
|
||||||
|
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
@@ -201,68 +192,6 @@ export function BlockMenu({ editor }: BlockMenuProps) {
|
|||||||
};
|
};
|
||||||
}, [editor, slashState.active, slashState.query, filteredItems, slashActiveIndex, handleSlashAction]);
|
}, [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 = () => {
|
const handleTriggerClick = () => {
|
||||||
if (!hoveredBlock) return;
|
if (!hoveredBlock) return;
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,13 @@ import {
|
|||||||
import { CodeBlockIcon, DotsSixVerticalIcon, ListBulletsIcon, ListNumbersIcon, QuotesIcon, TextHOneIcon, TextHThreeIcon, TextHTwoIcon, TextTIcon } from "@phosphor-icons/react";
|
import { CodeBlockIcon, DotsSixVerticalIcon, ListBulletsIcon, ListNumbersIcon, QuotesIcon, TextHOneIcon, TextHThreeIcon, TextHTwoIcon, TextTIcon } from "@phosphor-icons/react";
|
||||||
import { NodeSelection, TextSelection } from "@tiptap/pm/state";
|
import { NodeSelection, TextSelection } from "@tiptap/pm/state";
|
||||||
import type { EditorView } from "@tiptap/pm/view";
|
import type { EditorView } from "@tiptap/pm/view";
|
||||||
import { type useEditor } from "@tiptap/react";
|
import { type Editor } from "@tiptap/react";
|
||||||
import { type DragEvent, useEffect, useLayoutEffect, useRef, useState } from "react";
|
import { type DragEvent, useState } from "react";
|
||||||
import { tv } from "tailwind-variants";
|
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";
|
import { MenuButton } from "./MenuButton";
|
||||||
|
|
||||||
const optionsMenuVariants = tv({
|
const optionsMenuVariants = tv({
|
||||||
@@ -34,46 +37,21 @@ const optionsMenuVariants = tv({
|
|||||||
|
|
||||||
const { trigger, menu } = optionsMenuVariants();
|
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) {
|
function startDrag(view: EditorView, slice: ReturnType<NodeSelection["content"]>, node: NodeSelection) {
|
||||||
view.dragging = { slice, move: true, node } as typeof view.dragging;
|
view.dragging = { slice, move: true, node } as typeof view.dragging;
|
||||||
}
|
}
|
||||||
|
|
||||||
type OptionsMenuProps = {
|
type OptionsMenuProps = {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: Editor;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function OptionsMenu({ editor }: OptionsMenuProps) {
|
export function OptionsMenu({ editor }: OptionsMenuProps) {
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
const [triggerEl, setTriggerEl] = useState<Element | null>(null);
|
const [triggerEl, setTriggerEl] = useState<Element | null>(null);
|
||||||
const [dropdownEl, setDropdownEl] = useState<HTMLElement | 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 { hoveredBlock, setHoveredBlock } = useHoveredBlock(editor, menuOpen);
|
||||||
const triggerPlacement = blockHeight > 2 * TRIGGER_HEIGHT ? "left-start" as const : "left" as const;
|
const { triggerRefs, triggerStyles, isPositioned } = useBlockTrigger(hoveredBlock, 16);
|
||||||
|
|
||||||
const {
|
|
||||||
refs: triggerRefs,
|
|
||||||
floatingStyles: triggerStyles,
|
|
||||||
isPositioned,
|
|
||||||
} = useFloating({
|
|
||||||
strategy: "fixed",
|
|
||||||
placement: triggerPlacement,
|
|
||||||
middleware: [offset(16)],
|
|
||||||
whileElementsMounted: autoUpdate,
|
|
||||||
});
|
|
||||||
|
|
||||||
const menuRootContext = useFloatingRootContext({
|
const menuRootContext = useFloatingRootContext({
|
||||||
open: menuOpen,
|
open: menuOpen,
|
||||||
@@ -93,90 +71,18 @@ export function OptionsMenu({ editor }: OptionsMenuProps) {
|
|||||||
const dismiss = useDismiss(menuRootContext);
|
const dismiss = useDismiss(menuRootContext);
|
||||||
const { getReferenceProps, getFloatingProps } = useInteractions([click, dismiss]);
|
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;
|
const shouldShow = hoveredBlock != null || menuOpen;
|
||||||
|
|
||||||
if (!editor || !shouldShow) return null;
|
if (!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;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAction = (
|
const handleAction = (
|
||||||
applyCommand: (chain: ReturnType<typeof editor.chain>) => ReturnType<typeof editor.chain>,
|
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) {
|
if (!data) {
|
||||||
setMenuOpen(false);
|
setMenuOpen(false);
|
||||||
return;
|
return;
|
||||||
@@ -255,8 +161,9 @@ export function OptionsMenu({ editor }: OptionsMenuProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onDragStart = (e: DragEvent<HTMLButtonElement>) => {
|
const onDragStart = (e: DragEvent<HTMLButtonElement>) => {
|
||||||
const data = getNodeAtHoveredBlock();
|
if (!hoveredBlock) return;
|
||||||
if (!data || !hoveredBlock) return;
|
const data = getBlockNode(editor, hoveredBlock);
|
||||||
|
if (!data) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const view = editor.view;
|
const view = editor.view;
|
||||||
@@ -321,56 +228,56 @@ export function OptionsMenu({ editor }: OptionsMenuProps) {
|
|||||||
>
|
>
|
||||||
<div className="p-1 font-semibold text-sm">Turn into</div>
|
<div className="p-1 font-semibold text-sm">Turn into</div>
|
||||||
<MenuButton
|
<MenuButton
|
||||||
active={isNodeType("paragraph")}
|
active={hoveredBlock != null && isBlockNodeType(editor, hoveredBlock, "paragraph")}
|
||||||
onClick={() => handleAction(chain => chain.setParagraph())}
|
onClick={() => handleAction(chain => chain.setParagraph())}
|
||||||
>
|
>
|
||||||
<TextTIcon size={16} weight="bold" />
|
<TextTIcon size={16} weight="bold" />
|
||||||
Text
|
Text
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
<MenuButton
|
<MenuButton
|
||||||
active={isNodeType("heading", { level: 1 })}
|
active={hoveredBlock != null && isBlockNodeType(editor, hoveredBlock, "heading", { level: 1 })}
|
||||||
onClick={() => handleAction(chain => chain.toggleHeading({ level: 1 }))}
|
onClick={() => handleAction(chain => chain.toggleHeading({ level: 1 }))}
|
||||||
>
|
>
|
||||||
<TextHOneIcon size={16} weight="bold" />
|
<TextHOneIcon size={16} weight="bold" />
|
||||||
Heading 1
|
Heading 1
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
<MenuButton
|
<MenuButton
|
||||||
active={isNodeType("heading", { level: 2 })}
|
active={hoveredBlock != null && isBlockNodeType(editor, hoveredBlock, "heading", { level: 2 })}
|
||||||
onClick={() => handleAction(chain => chain.toggleHeading({ level: 2 }))}
|
onClick={() => handleAction(chain => chain.toggleHeading({ level: 2 }))}
|
||||||
>
|
>
|
||||||
<TextHTwoIcon size={16} weight="bold" />
|
<TextHTwoIcon size={16} weight="bold" />
|
||||||
Heading 2
|
Heading 2
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
<MenuButton
|
<MenuButton
|
||||||
active={isNodeType("heading", { level: 3 })}
|
active={hoveredBlock != null && isBlockNodeType(editor, hoveredBlock, "heading", { level: 3 })}
|
||||||
onClick={() => handleAction(chain => chain.toggleHeading({ level: 3 }))}
|
onClick={() => handleAction(chain => chain.toggleHeading({ level: 3 }))}
|
||||||
>
|
>
|
||||||
<TextHThreeIcon size={16} weight="bold" />
|
<TextHThreeIcon size={16} weight="bold" />
|
||||||
Heading 3
|
Heading 3
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
<MenuButton
|
<MenuButton
|
||||||
active={isNodeType("bulletList")}
|
active={hoveredBlock != null && isBlockNodeType(editor, hoveredBlock, "bulletList")}
|
||||||
onClick={() => handleAction(chain => chain.toggleBulletList())}
|
onClick={() => handleAction(chain => chain.toggleBulletList())}
|
||||||
>
|
>
|
||||||
<ListBulletsIcon size={16} weight="bold" />
|
<ListBulletsIcon size={16} weight="bold" />
|
||||||
Bullet List
|
Bullet List
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
<MenuButton
|
<MenuButton
|
||||||
active={isNodeType("orderedList")}
|
active={hoveredBlock != null && isBlockNodeType(editor, hoveredBlock, "orderedList")}
|
||||||
onClick={() => handleAction(chain => chain.toggleOrderedList())}
|
onClick={() => handleAction(chain => chain.toggleOrderedList())}
|
||||||
>
|
>
|
||||||
<ListNumbersIcon size={16} weight="bold" />
|
<ListNumbersIcon size={16} weight="bold" />
|
||||||
Ordered List
|
Ordered List
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
<MenuButton
|
<MenuButton
|
||||||
active={isNodeType("codeBlock")}
|
active={hoveredBlock != null && isBlockNodeType(editor, hoveredBlock, "codeBlock")}
|
||||||
onClick={() => handleAction(chain => chain.toggleCodeBlock())}
|
onClick={() => handleAction(chain => chain.toggleCodeBlock())}
|
||||||
>
|
>
|
||||||
<CodeBlockIcon size={16} weight="bold" />
|
<CodeBlockIcon size={16} weight="bold" />
|
||||||
Code Block
|
Code Block
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
<MenuButton
|
<MenuButton
|
||||||
active={isNodeType("blockquote")}
|
active={hoveredBlock != null && isBlockNodeType(editor, hoveredBlock, "blockquote")}
|
||||||
onClick={() => handleAction(chain => chain.toggleBlockquote())}
|
onClick={() => handleAction(chain => chain.toggleBlockquote())}
|
||||||
>
|
>
|
||||||
<QuotesIcon size={16} weight="bold" />
|
<QuotesIcon size={16} weight="bold" />
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ export function RichEditor(props: RichEditorProps) {
|
|||||||
}
|
}
|
||||||
}, [content, watchedContent, onChangeContent]);
|
}, [content, watchedContent, onChangeContent]);
|
||||||
|
|
||||||
|
if (!editor) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={richEditorVariants({ className })}>
|
<div className={richEditorVariants({ className })}>
|
||||||
<BubbleMenu editor={editor} />
|
<BubbleMenu editor={editor} />
|
||||||
|
|||||||
@@ -2,27 +2,19 @@
|
|||||||
// Use of this source code is governed by the ISC license
|
// Use of this source code is governed by the ISC license
|
||||||
// that can be found in the LICENSE file.
|
// that can be found in the LICENSE file.
|
||||||
|
|
||||||
import {
|
import { autoUpdate, offset, useFloating } from "@floating-ui/react";
|
||||||
autoUpdate,
|
|
||||||
flip,
|
|
||||||
offset,
|
|
||||||
shift,
|
|
||||||
useDismiss,
|
|
||||||
useFloating,
|
|
||||||
useFloatingRootContext,
|
|
||||||
useInteractions,
|
|
||||||
} from "@floating-ui/react";
|
|
||||||
import { BroomIcon, CircleIcon, DotsThreeCircleVerticalIcon, IntersectIcon, SplitHorizontalIcon } from "@phosphor-icons/react";
|
import { BroomIcon, CircleIcon, DotsThreeCircleVerticalIcon, IntersectIcon, SplitHorizontalIcon } from "@phosphor-icons/react";
|
||||||
import { TextSelection } from "@tiptap/pm/state";
|
import { TextSelection } from "@tiptap/pm/state";
|
||||||
import { cellAround, CellSelection, TableMap } from "@tiptap/pm/tables";
|
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 { useLayoutEffect, useRef, useState } from "react";
|
||||||
import { tv } from "tailwind-variants";
|
import { tv } from "tailwind-variants";
|
||||||
|
|
||||||
|
import { cellDomElement } from "./_lib/cellDomElement";
|
||||||
|
import { DRAG_THRESHOLD } from "./_lib/constants";
|
||||||
|
import { useTableDropdownMenu } from "./_lib/useTableDropdownMenu";
|
||||||
import { MenuButton } from "./MenuButton";
|
import { MenuButton } from "./MenuButton";
|
||||||
|
|
||||||
const DRAG_THRESHOLD = 4;
|
|
||||||
|
|
||||||
const tableCellMenuVariants = tv({
|
const tableCellMenuVariants = tv({
|
||||||
slots: {
|
slots: {
|
||||||
trigger: [
|
trigger: [
|
||||||
@@ -36,20 +28,10 @@ const tableCellMenuVariants = tv({
|
|||||||
const { trigger, menu } = tableCellMenuVariants();
|
const { trigger, menu } = tableCellMenuVariants();
|
||||||
|
|
||||||
type TableCellMenuProps = {
|
type TableCellMenuProps = {
|
||||||
editor: ReturnType<typeof useEditor>;
|
editor: Editor;
|
||||||
};
|
};
|
||||||
|
|
||||||
function cellDomElement(editor: NonNullable<ReturnType<typeof useEditor>>, cellPos: number): HTMLElement | null {
|
function getActiveCellEl(editor: Editor): 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 {
|
|
||||||
const { selection } = editor.state;
|
const { selection } = editor.state;
|
||||||
|
|
||||||
if (selection instanceof CellSelection) {
|
if (selection instanceof CellSelection) {
|
||||||
@@ -63,10 +45,17 @@ function getActiveCellEl(editor: NonNullable<ReturnType<typeof useEditor>>): HTM
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TableCellMenu({ editor }: TableCellMenuProps) {
|
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 [handleHovered, setHandleHovered] = useState(false);
|
||||||
const [triggerEl, setTriggerEl] = useState<Element | null>(null);
|
|
||||||
const [dropdownEl, setDropdownEl] = useState<HTMLElement | null>(null);
|
|
||||||
const draggingRef = useRef(false);
|
const draggingRef = useRef(false);
|
||||||
const dragStartPos = useRef({ x: 0, y: 0 });
|
const dragStartPos = useRef({ x: 0, y: 0 });
|
||||||
const anchorCellPosRef = useRef<number | null>(null);
|
const anchorCellPosRef = useRef<number | null>(null);
|
||||||
@@ -95,25 +84,8 @@ export function TableCellMenu({ editor }: TableCellMenuProps) {
|
|||||||
autoUpdate(ref, floating, update, { animationFrame: true }),
|
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(() => {
|
useLayoutEffect(() => {
|
||||||
if (!editor || !activeCellEl) {
|
if (!activeCellEl) {
|
||||||
handleRefs.setReference(null);
|
handleRefs.setReference(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -147,7 +119,7 @@ export function TableCellMenu({ editor }: TableCellMenuProps) {
|
|||||||
});
|
});
|
||||||
}, [activeCellEl, editor, handleRefs]);
|
}, [activeCellEl, editor, handleRefs]);
|
||||||
|
|
||||||
if (!editor || !activeCellEl) return null;
|
if (!activeCellEl) return null;
|
||||||
|
|
||||||
const getAnchorCellPos = (): number | null => {
|
const getAnchorCellPos = (): number | null => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,17 +2,7 @@
|
|||||||
// Use of this source code is governed by the ISC license
|
// Use of this source code is governed by the ISC license
|
||||||
// that can be found in the LICENSE file.
|
// that can be found in the LICENSE file.
|
||||||
|
|
||||||
import {
|
import { autoUpdate, offset, size, useFloating } from "@floating-ui/react";
|
||||||
autoUpdate,
|
|
||||||
flip,
|
|
||||||
offset,
|
|
||||||
shift,
|
|
||||||
size,
|
|
||||||
useDismiss,
|
|
||||||
useFloating,
|
|
||||||
useFloatingRootContext,
|
|
||||||
useInteractions,
|
|
||||||
} from "@floating-ui/react";
|
|
||||||
import {
|
import {
|
||||||
BroomIcon,
|
BroomIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
@@ -24,14 +14,15 @@ import {
|
|||||||
import type { Node as PMNode } from "@tiptap/pm/model";
|
import type { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import { TextSelection } from "@tiptap/pm/state";
|
import { TextSelection } from "@tiptap/pm/state";
|
||||||
import { cellAround, CellSelection, TableMap } from "@tiptap/pm/tables";
|
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 { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { tv } from "tailwind-variants";
|
import { tv } from "tailwind-variants";
|
||||||
|
|
||||||
|
import { cellDomElement } from "./_lib/cellDomElement";
|
||||||
|
import { DRAG_THRESHOLD } from "./_lib/constants";
|
||||||
|
import { useTableDropdownMenu } from "./_lib/useTableDropdownMenu";
|
||||||
import { MenuButton } from "./MenuButton";
|
import { MenuButton } from "./MenuButton";
|
||||||
|
|
||||||
const DRAG_THRESHOLD = 4;
|
|
||||||
|
|
||||||
const tableColumnMenuVariants = tv({
|
const tableColumnMenuVariants = tv({
|
||||||
slots: {
|
slots: {
|
||||||
trigger: [
|
trigger: [
|
||||||
@@ -51,24 +42,11 @@ type HoveredColumn = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type TableColumnMenuProps = {
|
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(
|
function getColumnRect(
|
||||||
editor: NonNullable<ReturnType<typeof useEditor>>,
|
editor: Editor,
|
||||||
tableStart: number,
|
tableStart: number,
|
||||||
colIndex: number,
|
colIndex: number,
|
||||||
): DOMRect | null {
|
): DOMRect | null {
|
||||||
@@ -108,7 +86,7 @@ function getColumnRect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function moveColumn(
|
function moveColumn(
|
||||||
editor: NonNullable<ReturnType<typeof useEditor>>,
|
editor: Editor,
|
||||||
tableStart: number,
|
tableStart: number,
|
||||||
fromCol: number,
|
fromCol: number,
|
||||||
toCol: number,
|
toCol: number,
|
||||||
@@ -135,10 +113,17 @@ function moveColumn(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TableColumnMenu({ editor }: TableColumnMenuProps) {
|
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 [hoveredCol, setHoveredCol] = useState<HoveredColumn | null>(null);
|
||||||
const [triggerEl, setTriggerEl] = useState<Element | null>(null);
|
|
||||||
const [dropdownEl, setDropdownEl] = useState<HTMLElement | null>(null);
|
|
||||||
const [dragIndicator, setDragIndicator] = useState<{
|
const [dragIndicator, setDragIndicator] = useState<{
|
||||||
left: number;
|
left: number;
|
||||||
top: number;
|
top: number;
|
||||||
@@ -155,7 +140,7 @@ export function TableColumnMenu({ editor }: TableColumnMenuProps) {
|
|||||||
}, [hoveredCol]);
|
}, [hoveredCol]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor || editor.isDestroyed || !editor.isEditable) return;
|
if (editor.isDestroyed || !editor.isEditable) return;
|
||||||
|
|
||||||
const editorDom = editor.view.dom;
|
const editorDom = editor.view.dom;
|
||||||
|
|
||||||
@@ -255,25 +240,8 @@ export function TableColumnMenu({ editor }: TableColumnMenuProps) {
|
|||||||
autoUpdate(ref, floating, update, { animationFrame: true }),
|
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(() => {
|
useLayoutEffect(() => {
|
||||||
if (!editor || !hoveredCol) {
|
if (!hoveredCol) {
|
||||||
handleRefs.setReference(null);
|
handleRefs.setReference(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -290,7 +258,7 @@ export function TableColumnMenu({ editor }: TableColumnMenuProps) {
|
|||||||
});
|
});
|
||||||
}, [hoveredCol, editor, handleRefs]);
|
}, [hoveredCol, editor, handleRefs]);
|
||||||
|
|
||||||
if (!editor || (!hoveredCol && !menuOpen)) return null;
|
if (!hoveredCol && !menuOpen) return null;
|
||||||
|
|
||||||
const computeTargetGap = (clientX: number, tableStart: number): number => {
|
const computeTargetGap = (clientX: number, tableStart: number): number => {
|
||||||
const table = editor.state.doc.nodeAt(tableStart - 1);
|
const table = editor.state.doc.nodeAt(tableStart - 1);
|
||||||
|
|||||||
@@ -2,17 +2,7 @@
|
|||||||
// Use of this source code is governed by the ISC license
|
// Use of this source code is governed by the ISC license
|
||||||
// that can be found in the LICENSE file.
|
// that can be found in the LICENSE file.
|
||||||
|
|
||||||
import {
|
import { autoUpdate, offset, size, useFloating } from "@floating-ui/react";
|
||||||
autoUpdate,
|
|
||||||
flip,
|
|
||||||
offset,
|
|
||||||
shift,
|
|
||||||
size,
|
|
||||||
useDismiss,
|
|
||||||
useFloating,
|
|
||||||
useFloatingRootContext,
|
|
||||||
useInteractions,
|
|
||||||
} from "@floating-ui/react";
|
|
||||||
import {
|
import {
|
||||||
BroomIcon,
|
BroomIcon,
|
||||||
CopyIcon,
|
CopyIcon,
|
||||||
@@ -24,14 +14,15 @@ import {
|
|||||||
import type { Node as PMNode } from "@tiptap/pm/model";
|
import type { Node as PMNode } from "@tiptap/pm/model";
|
||||||
import { TextSelection } from "@tiptap/pm/state";
|
import { TextSelection } from "@tiptap/pm/state";
|
||||||
import { cellAround, CellSelection, TableMap } from "@tiptap/pm/tables";
|
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 { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { tv } from "tailwind-variants";
|
import { tv } from "tailwind-variants";
|
||||||
|
|
||||||
|
import { cellDomElement } from "./_lib/cellDomElement";
|
||||||
|
import { DRAG_THRESHOLD } from "./_lib/constants";
|
||||||
|
import { useTableDropdownMenu } from "./_lib/useTableDropdownMenu";
|
||||||
import { MenuButton } from "./MenuButton";
|
import { MenuButton } from "./MenuButton";
|
||||||
|
|
||||||
const DRAG_THRESHOLD = 4;
|
|
||||||
|
|
||||||
const tableRowMenuVariants = tv({
|
const tableRowMenuVariants = tv({
|
||||||
slots: {
|
slots: {
|
||||||
trigger: [
|
trigger: [
|
||||||
@@ -51,24 +42,11 @@ type HoveredRow = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type TableRowMenuProps = {
|
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(
|
function getRowRect(
|
||||||
editor: NonNullable<ReturnType<typeof useEditor>>,
|
editor: Editor,
|
||||||
tableStart: number,
|
tableStart: number,
|
||||||
rowIndex: number,
|
rowIndex: number,
|
||||||
): DOMRect | null {
|
): DOMRect | null {
|
||||||
@@ -108,7 +86,7 @@ function getRowRect(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function moveRow(
|
function moveRow(
|
||||||
editor: NonNullable<ReturnType<typeof useEditor>>,
|
editor: Editor,
|
||||||
tableStart: number,
|
tableStart: number,
|
||||||
fromRow: number,
|
fromRow: number,
|
||||||
toRow: number,
|
toRow: number,
|
||||||
@@ -131,10 +109,17 @@ function moveRow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function TableRowMenu({ editor }: TableRowMenuProps) {
|
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 [hoveredRow, setHoveredRow] = useState<HoveredRow | null>(null);
|
||||||
const [triggerEl, setTriggerEl] = useState<Element | null>(null);
|
|
||||||
const [dropdownEl, setDropdownEl] = useState<HTMLElement | null>(null);
|
|
||||||
const [dragIndicator, setDragIndicator] = useState<{
|
const [dragIndicator, setDragIndicator] = useState<{
|
||||||
left: number;
|
left: number;
|
||||||
top: number;
|
top: number;
|
||||||
@@ -151,7 +136,7 @@ export function TableRowMenu({ editor }: TableRowMenuProps) {
|
|||||||
}, [hoveredRow]);
|
}, [hoveredRow]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor || editor.isDestroyed || !editor.isEditable) return;
|
if (editor.isDestroyed || !editor.isEditable) return;
|
||||||
|
|
||||||
const editorDom = editor.view.dom;
|
const editorDom = editor.view.dom;
|
||||||
|
|
||||||
@@ -251,25 +236,8 @@ export function TableRowMenu({ editor }: TableRowMenuProps) {
|
|||||||
autoUpdate(ref, floating, update, { animationFrame: true }),
|
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(() => {
|
useLayoutEffect(() => {
|
||||||
if (!editor || !hoveredRow) {
|
if (!hoveredRow) {
|
||||||
handleRefs.setReference(null);
|
handleRefs.setReference(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -286,7 +254,7 @@ export function TableRowMenu({ editor }: TableRowMenuProps) {
|
|||||||
});
|
});
|
||||||
}, [hoveredRow, editor, handleRefs]);
|
}, [hoveredRow, editor, handleRefs]);
|
||||||
|
|
||||||
if (!editor || (!hoveredRow && !menuOpen)) return null;
|
if (!hoveredRow && !menuOpen) return null;
|
||||||
|
|
||||||
const computeTargetGap = (clientY: number, tableStart: number): number => {
|
const computeTargetGap = (clientY: number, tableStart: number): number => {
|
||||||
const table = editor.state.doc.nodeAt(tableStart - 1);
|
const table = editor.state.doc.nodeAt(tableStart - 1);
|
||||||
|
|||||||
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